121 lines
4.6 KiB
Go
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
|
|
}
|