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>
134 lines
4.9 KiB
Go
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),
|
|
}
|
|
}
|