ProofedMesh/services/approval-service/internal/content/binding.go

118 lines
4.4 KiB
Go
Raw Permalink Normal View History

approval-service: Phase-1 content-binding approval service (DEV-608) New services/approval-service with the Session → Preview → Confirm pipeline required by DEV-606/DEV-608: * POST /api/v1/sessions starts a Pocket-ID-bound approval session. * POST /api/v1/approvals/preview renders content server-side, computes H_content = sha256(canonical JSON), stores the (nonce, salt, canonical, challenge) tuple and returns the human verification digits plus the WebAuthn challenge = nonce || H_content. * POST /api/v1/approvals/confirm verifies the WebAuthn assertion against the stored challenge, DSSE-signs the Approval statement via the signing-service (key_ref=trust-api/approval-signer-2026-q4), submits to the transparency-log, and returns {approval_id, attestation_ref, log_index}. TLog failures leave the row at attestation-pending for the reconciler. * db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables approval_sessions, approval_previews, approvals (schema matches the Phase 1 store contract). * internal/content: canonical JSON + H_content + 6-digit verification code (byte-compatible with signing-service/internal/canonical). * internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn. * internal/signer, internal/tlog: HTTP clients mapping 5xx / connection errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the HTTP layer can return 502 for signing failures and persist attestation-pending on TLog failure. * internal/service.Orchestrator ties the pipeline together and persists the Approval BEFORE the TLog submit (never return a log_index for an un-persisted approval). Tests cover: happy path, tampered H_content → 400, expired challenge → 403, signer 5xx + conn-refused → 502, TLog failure → row stays attestation-pending, duplicate confirm → 409, session expired → 403. go vet clean, go test ./... passes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:06 +00:00
// 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)
}