118 lines
4.4 KiB
Go
118 lines
4.4 KiB
Go
|
|
// 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:<hex>" 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)
|
||
|
|
}
|