agent
This commit is contained in:
120
utils/card.go
Normal file
120
utils/card.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user