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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user