Files
backend_fiesta/utils/card.go
2026-09-23 17:26:13 +05:30

121 lines
4.6 KiB
Go

package utils
import (
"crypto/hmac"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// The approval card.
//
// Nearle Buddy never writes anything. When a question would change something,
// the assistant RESOLVES what would happen and hands back a card; a person reads
// it and presses approve; the server then performs the write itself. The model
// is not in that second half at all.
//
// ── Why the card is signed rather than stored ───────────────────────────────
//
// The resolved action cannot be kept on the client, or the thing approved would
// be whatever the browser sent back. It could be kept on the server in a table
// or in Redis — but a pending approval lives for about a minute, and a signed
// card needs no storage, no expiry sweep, and no shared state between pods. The
// signature is what makes it trustworthy, exactly as with the session token next
// door, and with the same key.
//
// So a card says: this user, in this shop, approved this exact action, and here
// is the proof it was this server that resolved it.
//
// ── Replay ─────────────────────────────────────────────────────────────────
//
// A signed card carries no nonce, so nothing here stops it being submitted
// twice. That is deliberate and is handled where it belongs: every write
// re-validates against the live database before it runs. Approving the same
// stock request twice finds it already approved the second time and refuses.
// A single-use token would put that guarantee in the wrong place — the state a
// write depends on can change between resolving and approving anyway, so the
// check has to happen at execution whether or not a card can be replayed.
type Card struct {
// The tool that resolved this, and the arguments it resolved to. Not the
// arguments the MODEL sent: resolved ones, after defaults and validation.
Tool string `json:"t"`
Args map[string]any `json:"a"`
// Who may approve it. A card is not transferable — the session presenting
// it must be the session it was issued to, or one person's approval could
// be replayed by another.
Userid int `json:"uid"`
Tenantid int `json:"tid"`
// Short. A card is read and pressed within a minute or abandoned; an hour
// would mean approving something resolved against a shop that has moved on.
Expiresat int64 `json:"exp"`
}
// CardTTL is how long a resolved action stays approvable.
const CardTTL = 5 * time.Minute
const cardPrefix = "c1."
// MintCard signs a resolved action.
//
// Shares the session signing key. One key for the deployment, one place it can
// be missing — and the prefix is what stops a card verifying as a session token
// or the other way round.
func MintCard(card Card, now time.Time) (string, error) {
secret, err := posTokenSecret()
if err != nil {
return "", err
}
card.Expiresat = now.Add(CardTTL).Unix()
payload, err := json.Marshal(card)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return cardPrefix + encoded + "." + sign(encoded, secret), nil
}
// ParseCard verifies a card and returns what it authorises.
//
// The signature is checked before anything in the payload is believed —
// including the expiry, and including whose card it is. Reading `uid` out of an
// unverified payload would be taking the caller's word for whose approval this
// was.
func ParseCard(raw string, now time.Time) (Card, error) {
secret, err := posTokenSecret()
if err != nil {
return Card{}, err
}
after, found := strings.CutPrefix(strings.TrimSpace(raw), cardPrefix)
if !found {
return Card{}, fmt.Errorf("not an approval card")
}
encoded, signature, found := strings.Cut(after, ".")
if !found || encoded == "" || signature == "" {
return Card{}, fmt.Errorf("malformed approval card")
}
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
return Card{}, fmt.Errorf("this approval was not issued by this server")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return Card{}, fmt.Errorf("malformed approval card")
}
var card Card
if err := json.Unmarshal(payload, &card); err != nil {
return Card{}, fmt.Errorf("malformed approval card")
}
if card.Expiresat > 0 && now.Unix() >= card.Expiresat {
return Card{}, fmt.Errorf("this approval has expired; ask again to get a fresh one")
}
if card.Tool == "" || card.Userid <= 0 {
return Card{}, fmt.Errorf("this approval names no action")
}
return card, nil
}