ProofedMesh/services/approval-service/internal/webauthn/webauthn_test.go
Paperclip 3a01cc651f 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

134 lines
4.9 KiB
Go

package webauthn
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"testing"
)
func TestVerify_HappyPath(t *testing.T) {
pub, priv, cred, store := setupCredential(t, "alice")
_ = pub
challenge := []byte("challenge-bytes-just-for-the-test")
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
got, err := Verify(cfg, store, a, challenge)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.ID != cred.ID {
t.Errorf("credential id mismatch")
}
}
func TestVerify_WrongChallenge(t *testing.T) {
_, priv, cred, store := setupCredential(t, "alice")
challenge := []byte("original-challenge-bytes-xxxxxxxx")
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
_, err := Verify(cfg, store, a, []byte("different-challenge-bytes-yyyyyy"))
if !errors.Is(err, ErrChallengeMismatch) {
t.Fatalf("want ErrChallengeMismatch, got %v", err)
}
}
func TestVerify_UnknownCredential(t *testing.T) {
store := NewStore()
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
_, err := Verify(cfg, store, Assertion{CredentialID: "nope"}, []byte("c"))
if !errors.Is(err, ErrCredentialUnknown) {
t.Fatalf("want ErrCredentialUnknown, got %v", err)
}
}
func TestVerify_WrongOrigin(t *testing.T) {
_, priv, cred, store := setupCredential(t, "alice")
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://evil.example", challenge)
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
_, err := Verify(cfg, store, a, challenge)
if err == nil || !errors.Is(err, ErrAssertionInvalid) {
t.Fatalf("want ErrAssertionInvalid, got %v", err)
}
}
func TestVerify_WrongType(t *testing.T) {
_, priv, cred, store := setupCredential(t, "alice")
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
a := makeAssertionWithType(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge, "webauthn.create")
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
_, err := Verify(cfg, store, a, challenge)
if !errors.Is(err, ErrAssertionInvalid) {
t.Fatalf("want ErrAssertionInvalid, got %v", err)
}
}
func TestVerify_BadSignature(t *testing.T) {
_, priv, cred, store := setupCredential(t, "alice")
challenge := []byte("challenge-xxx-xxx-xxx-xxx")
a := makeAssertion(t, priv, cred.ID, "trust.basicstack.de", "https://trust.basicstack.de", challenge)
// Tamper with the signature.
sig, _ := base64.RawURLEncoding.DecodeString(a.Signature)
sig[0] ^= 0xff
a.Signature = base64.RawURLEncoding.EncodeToString(sig)
cfg := VerifyConfig{RPID: "trust.basicstack.de", AllowedOrigins: []string{"https://trust.basicstack.de"}}
_, err := Verify(cfg, store, a, challenge)
if !errors.Is(err, ErrAssertionInvalid) {
t.Fatalf("want ErrAssertionInvalid, got %v", err)
}
}
// --- helpers --------------------------------------------------------------
func setupCredential(t *testing.T, userSub string) (ed25519.PublicKey, ed25519.PrivateKey, Credential, *Store) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
idBytes := make([]byte, 16)
_, _ = rand.Read(idBytes)
cred := Credential{
ID: base64.RawURLEncoding.EncodeToString(idBytes),
UserSub: userSub,
PublicKey: pub,
}
store := NewStore()
if err := store.AddCredential(cred); err != nil {
t.Fatalf("AddCredential: %v", err)
}
return pub, priv, cred, store
}
// makeAssertion signs a mock WebAuthn assertion with the given private
// key over the standard authenticator_data || sha256(clientDataJSON).
func makeAssertion(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte) Assertion {
return makeAssertionWithType(t, priv, credID, rpID, origin, challenge, "webauthn.get")
}
func makeAssertionWithType(t *testing.T, priv ed25519.PrivateKey, credID, rpID, origin string, challenge []byte, typ string) Assertion {
t.Helper()
cd := map[string]string{
"type": typ,
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
"origin": origin,
}
cdBytes, _ := json.Marshal(cd)
authData := MakeAuthenticatorData(rpID)
cdHash := sha256.Sum256(cdBytes)
signed := append(append([]byte(nil), authData...), cdHash[:]...)
sig := ed25519.Sign(priv, signed)
return Assertion{
CredentialID: credID,
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
}