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

90 lines
2.3 KiB
Go
Raw 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
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestBindStableHash(t *testing.T) {
payload := Phase1Rendered{
"title": "Approve policy change v3",
"action": "sign",
"detail": map[string]any{"policy_id": "pol-42"},
}
a, err := Bind(payload)
if err != nil {
t.Fatalf("Bind: %v", err)
}
b, err := Bind(payload)
if err != nil {
t.Fatalf("Bind again: %v", err)
}
if a.ContentHash != b.ContentHash {
t.Errorf("content_hash not stable: %q vs %q", a.ContentHash, b.ContentHash)
}
if !bytes.Equal(a.ContentCanonical, b.ContentCanonical) {
t.Errorf("canonical bytes not stable")
}
if bytes.Equal(a.Salt, b.Salt) {
t.Errorf("salt should be random (equal across two calls)")
}
if bytes.Equal(a.Nonce, b.Nonce) {
t.Errorf("nonce should be random (equal across two calls)")
}
}
func TestBindRejectsNil(t *testing.T) {
if _, err := Bind(nil); err == nil {
t.Fatalf("Bind(nil) must error")
}
}
func TestVerificationDigitsFormat(t *testing.T) {
b, err := Bind(Phase1Rendered{"k": "v"})
if err != nil {
t.Fatal(err)
}
if l := len(b.VerificationDigits); l != 6 {
t.Errorf("want 6 digits, got %d (%q)", l, b.VerificationDigits)
}
for _, r := range b.VerificationDigits {
if r < '0' || r > '9' {
t.Errorf("non-digit %q in verification digits %q", r, b.VerificationDigits)
}
}
}
func TestChallengeFor_Deterministic(t *testing.T) {
b, err := Bind(Phase1Rendered{"k": "v"})
if err != nil {
t.Fatal(err)
}
got := ChallengeFor(b.Nonce, b.ContentCanonical)
if !bytes.Equal(got, b.Challenge) {
t.Errorf("ChallengeFor must reproduce the challenge")
}
// Flip a byte in canonical content — challenge must change.
alt := append([]byte(nil), b.ContentCanonical...)
alt[0] ^= 0xff
if bytes.Equal(ChallengeFor(b.Nonce, alt), b.Challenge) {
t.Errorf("challenge did not change when canonical content was tampered")
}
}
func TestRehashCanonicalMatchesBind(t *testing.T) {
b, err := Bind(Phase1Rendered{"foo": "bar"})
if err != nil {
t.Fatal(err)
}
if got := RehashCanonical(b.ContentCanonical); got != b.ContentHash {
t.Errorf("RehashCanonical=%q want %q", got, b.ContentHash)
}
// Sanity: computed independently
sum := sha256.Sum256(b.ContentCanonical)
if "sha256:"+hex.EncodeToString(sum[:]) != b.ContentHash {
t.Errorf("ContentHash does not match computed sha256")
}
}