ProofedMesh/services/approval-service/internal/webauthn/webauthn_test.go

135 lines
4.9 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 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),
}
}