feat: signed one-time uploads for material files

- classes/uploads.go: tickets signed with HMAC-SHA256 under the
  jz.bm.upload.v1: label (app.key), 10-minute expiry, the nonce pulled
  from the cache once (duplicated from golem15.presentations on purpose)
- POST /api/v1/bm/materials/{id}/files/upload-url issues the URL with a
  curl example; POST /_bm/api/v1/uploads/{ticket} takes one file part,
  no bearer, throttled per IP and per ticket, user and permission
  re-checked, stored through storeMaterialFile
- bm_manage_create_material_upload; README documents the file routes
This commit is contained in:
2026-10-28 15:10:32 +01:00
parent ce6ac0a78e
commit 924e6aa0dc
5 changed files with 450 additions and 27 deletions

View File

@@ -2,23 +2,63 @@ 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 "<kind>|<target>|<user>|<expiry unix>|<nonce>", 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 upload ticket.
// 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.
// 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")
)
// UploadTicket is what a signed upload URL grants.
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
@@ -27,23 +67,126 @@ type UploadTicket struct {
Nonce string
}
// UploadKey is the signing key from the app key.
func UploadKey(appKey string) []byte { return nil }
// 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
}
// SignUpload signs t.
func SignUpload(key []byte, t UploadTicket) (string, error) { return "", nil }
// 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
}
// VerifyUpload checks a ticket.
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) {
return UploadTicket{}, nil
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
}
// IssueUpload signs and registers a ticket.
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) {
return "", time.Time{}, nil
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 and consumes a ticket.
// 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) {
return UploadTicket{}, nil
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
}