// Package content implements the content-binding primitives of the // approval-service: the H_content hash of a renderable approval payload, // the WebAuthn challenge derivation (nonce || H_content), and the human- // readable verification digits an operator cross-checks against the UI. // // The *content* passed in from the HTTP handler is server-rendered JSON โ€” // not the raw request payload. "Server-rendered" means we have already // resolved template variables, lookups, and defaults, so H_content is a // hash of exactly what the approving human saw. See // docs/architecture/v1.1.md ยง4.3. package content import ( "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "fmt" "basicstack.de/proofedmesh/services/approval-service/internal/canonical" ) // Phase1Rendered is the server-rendered approval payload. For Phase 1 the // approval-service does not own a template language โ€” the caller hands us // the fully resolved object (title, bullets, action, etc.) and we hash it // as-is. Phase 2 will introduce typed template kinds. type Phase1Rendered = map[string]any // Binding is everything the preview endpoint persists so the confirm // endpoint can verify the WebAuthn assertion unambiguously. type Binding struct { // ContentCanonical is the canonical JSON bytes of the rendered content. ContentCanonical []byte // ContentHash is "sha256:" over ContentCanonical. ContentHash string // Salt is 16 random bytes mixed into VerificationDigits so an attacker // who guesses a content payload cannot pre-compute its digits. Salt []byte // Nonce is 32 random bytes forming the first half of the WebAuthn // challenge; the second half is the raw sha256 of ContentCanonical. Nonce []byte // Challenge is Nonce || sha256(ContentCanonical). Base64url-encoded on // the wire inside the WebAuthn ClientDataJSON `challenge` field. Challenge []byte // VerificationDigits is a short 6-digit number derived from the // H_content + Salt, shown to the human for cross-checking. VerificationDigits string } // Bind computes the full content-binding for rendered. The caller MUST // pass server-rendered content (NOT raw user input). func Bind(rendered Phase1Rendered) (Binding, error) { if rendered == nil { return Binding{}, errors.New("content: rendered payload is nil") } raw, err := canonical.Marshal(rendered) if err != nil { return Binding{}, fmt.Errorf("canonicalise rendered: %w", err) } sum := sha256.Sum256(raw) salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return Binding{}, fmt.Errorf("salt: %w", err) } nonce := make([]byte, 32) if _, err := rand.Read(nonce); err != nil { return Binding{}, fmt.Errorf("nonce: %w", err) } challenge := make([]byte, 0, len(nonce)+len(sum)) challenge = append(challenge, nonce...) challenge = append(challenge, sum[:]...) return Binding{ ContentCanonical: raw, ContentHash: "sha256:" + hex.EncodeToString(sum[:]), Salt: salt, Nonce: nonce, Challenge: challenge, VerificationDigits: digits(sum[:], salt), }, nil } // RehashCanonical takes previously-stored canonical bytes and recomputes // the hash. Used on the confirm path to prove the preview's canonical // bytes still produce the expected ContentHash โ€” defense-in-depth against // store tampering. func RehashCanonical(canonicalJSON []byte) string { sum := sha256.Sum256(canonicalJSON) return "sha256:" + hex.EncodeToString(sum[:]) } // ChallengeFor returns the canonical WebAuthn challenge bytes given a // stored (nonce, canonicalJSON) pair. We re-derive the challenge rather // than storing it so a corrupted challenge column cannot smuggle an // attacker-chosen value into the signature path. func ChallengeFor(nonce, canonicalJSON []byte) []byte { sum := sha256.Sum256(canonicalJSON) out := make([]byte, 0, len(nonce)+len(sum)) out = append(out, nonce...) out = append(out, sum[:]...) return out } // digits folds H_content and salt into a 6-digit decimal code. The salt // prevents an attacker who knows a candidate rendered payload from pre- // computing the digits an operator will see. func digits(hContent, salt []byte) string { h := sha256.New() h.Write([]byte("verification-digits/v1\x00")) h.Write(hContent) h.Write([]byte{0x00}) h.Write(salt) sum := h.Sum(nil) // take the first 8 bytes as a big-endian uint64, mod 10^6 for 6 digits. n := binary.BigEndian.Uint64(sum[:8]) % 1_000_000 return fmt.Sprintf("%06d", n) }