- 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
193 lines
6.6 KiB
Go
193 lines
6.6 KiB
Go
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 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
|
|
}
|