package classes import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "regexp" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/cooler" ) // Signed one-time upload URLs for material files (D-10, D-12). The owner's // agent asks for an upload URL over MCP and sends the file with curl, so // the bytes never pass through MCP. Each plugin signs its own tickets with // its own label: golem15.presentations has the same design, and the code is // duplicated on purpose so jz.bm imports neither that plugin nor new // golem15.user code. A ticket is // // base64url(payload) + "." + base64url(HMAC-SHA256(key, uploadLabel + payload)) // // with payload "||||", the nonce 16 // random bytes in hex. It is valid for UploadTTL and redeemable once: // issuing stores the payload under the nonce in the app cache, redeeming // pulls it atomically. A restart of a memory cache forgets every issued // ticket, so they fail closed. // uploadLabel separates the upload HMAC from any other use of the key. const uploadLabel = "jz.bm.upload.v1:" // uploadCachePrefix is the cache key prefix of issued tickets. const uploadCachePrefix = "jz.bm.upload:" // UploadTTL is how long an upload URL is valid. const UploadTTL = 10 * time.Minute // uploadTicketMaxLen bounds the ticket text read from a URL. const uploadTicketMaxLen = 512 var ( // ErrUploadTicket is every refused ticket: malformed, forged, signed // with another key or label, expired, unknown or already used. ErrUploadTicket = errors.New("bm: invalid or expired upload ticket") // ErrUploadKeyMissing means no key to sign upload tickets with (app.key // is empty): no upload URL is issued. ErrUploadKeyMissing = errors.New("bm: upload key is not configured") ) var ( uploadKind = regexp.MustCompile(`^[a-z]{1,20}$`) uploadNonce = regexp.MustCompile(`^[0-9a-f]{32}$`) ) // UploadTicket is what a signed upload URL grants: one upload of Kind to // Target (a material id) by User, until Expires. type UploadTicket struct { Kind string Target uint User uint Expires time.Time Nonce string } // payload is the signed text of t. func (t UploadTicket) payload() string { return t.Kind + "|" + strconv.FormatUint(uint64(t.Target), 10) + "|" + strconv.FormatUint(uint64(t.User), 10) + "|" + strconv.FormatInt(t.Expires.Unix(), 10) + "|" + t.Nonce } // UploadKey is the signing key from the app key: trimmed, nil when empty. func UploadKey(appKey string) []byte { if k := strings.TrimSpace(appKey); k != "" { return []byte(k) } return nil } func uploadMAC(key []byte, payload string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(uploadLabel + payload)) return mac.Sum(nil) } // SignUpload signs t. It does not register the ticket; IssueUpload does. func SignUpload(key []byte, t UploadTicket) (string, error) { if len(key) == 0 { return "", ErrUploadKeyMissing } payload := t.payload() return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(uploadMAC(key, payload)), nil } // VerifyUpload checks a ticket's signature and expiry at now. An expiry // further away than UploadTTL (and a minute of clock skew) is refused too. // Every failure is ErrUploadTicket. func VerifyUpload(key []byte, ticket string, now time.Time) (UploadTicket, error) { if len(key) == 0 || ticket == "" || len(ticket) > uploadTicketMaxLen { return UploadTicket{}, ErrUploadTicket } parts := strings.Split(ticket, ".") if len(parts) != 2 { return UploadTicket{}, ErrUploadTicket } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return UploadTicket{}, ErrUploadTicket } mac, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return UploadTicket{}, ErrUploadTicket } if !hmac.Equal(mac, uploadMAC(key, string(payload))) { return UploadTicket{}, ErrUploadTicket } t, ok := parseUploadPayload(string(payload)) if !ok || !now.Before(t.Expires) || t.Expires.Sub(now) > UploadTTL+time.Minute { return UploadTicket{}, ErrUploadTicket } return t, nil } func parseUploadPayload(payload string) (UploadTicket, bool) { fields := strings.Split(payload, "|") if len(fields) != 5 || !uploadKind.MatchString(fields[0]) || !uploadNonce.MatchString(fields[4]) { return UploadTicket{}, false } target, err := strconv.ParseUint(fields[1], 10, 32) if err != nil || target == 0 { return UploadTicket{}, false } user, err := strconv.ParseUint(fields[2], 10, 32) if err != nil || user == 0 { return UploadTicket{}, false } expiry, err := strconv.ParseInt(fields[3], 10, 64) if err != nil { return UploadTicket{}, false } return UploadTicket{Kind: fields[0], Target: uint(target), User: uint(user), Expires: time.Unix(expiry, 0).UTC(), Nonce: fields[4]}, true } // IssueUpload signs a ticket for one upload of kind to target by user, // valid for UploadTTL from now, and stores its payload in cache under the // nonce for RedeemUpload. It answers the ticket and its expiry. func IssueUpload(ctx context.Context, cache cooler.Cache, key []byte, kind string, target, user uint, now time.Time) (string, time.Time, error) { if len(key) == 0 { return "", time.Time{}, ErrUploadKeyMissing } if cache == nil { return "", time.Time{}, errors.New("bm: upload cache is not available") } if !uploadKind.MatchString(kind) || target == 0 || user == 0 { return "", time.Time{}, errors.New("bm: invalid upload target") } var nonce [16]byte if _, err := rand.Read(nonce[:]); err != nil { return "", time.Time{}, err } t := UploadTicket{Kind: kind, Target: target, User: user, Expires: now.Add(UploadTTL).Truncate(time.Second).UTC(), Nonce: hex.EncodeToString(nonce[:])} signed, err := SignUpload(key, t) if err != nil { return "", time.Time{}, err } if err := cache.Set(ctx, uploadCachePrefix+t.Nonce, []byte(t.payload()), UploadTTL); err != nil { return "", time.Time{}, err } return signed, t.Expires, nil } // RedeemUpload verifies ticket and takes it out of cache in one atomic Pull, // so it works once. A ticket whose cache entry is missing (used, expired or // forgotten by a restart) or differs from its payload is refused. Every // failure is ErrUploadTicket. func RedeemUpload(ctx context.Context, cache cooler.Cache, key []byte, ticket string, now time.Time) (UploadTicket, error) { t, err := VerifyUpload(key, ticket, now) if err != nil { return UploadTicket{}, err } if cache == nil { return UploadTicket{}, ErrUploadTicket } raw, found, err := cache.Pull(ctx, uploadCachePrefix+t.Nonce) if err != nil || !found || string(raw) != t.payload() { return UploadTicket{}, ErrUploadTicket } return t, nil }