ProofedMesh/services/approval-service/internal/httpapi/server_test.go

464 lines
14 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 httpapi_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
"basicstack.de/proofedmesh/services/approval-service/internal/httpapi"
"basicstack.de/proofedmesh/services/approval-service/internal/service"
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
"basicstack.de/proofedmesh/services/approval-service/internal/store"
"basicstack.de/proofedmesh/services/approval-service/internal/tlog"
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
)
// fixture bundles everything a test needs to drive the HTTP server.
type fixture struct {
t *testing.T
server *httptest.Server
userSub string
credID string
credPriv ed25519.PrivateKey
rpID string
origin string
fakeSigner *fakeSigner
fakeTLog *fakeTLog
store *store.Memory
}
type fakeSigner struct {
mu sync.Mutex
err error
wantKeyRef string
seenStatement signer.Statement
}
func (f *fakeSigner) SignDSSE(_ context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return signer.Envelope{}, "", f.err
}
if f.wantKeyRef != "" && keyRef != f.wantKeyRef {
return signer.Envelope{}, "", errors.New("unexpected keyRef")
}
f.seenStatement = stmt
// Produce a deterministic envelope so tests can assert on it.
payload := base64.RawURLEncoding.EncodeToString([]byte("payload-bytes"))
env := signer.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: payload,
Signatures: []signer.EnvelopeSignature{{KeyID: keyRef, Sig: "AA"}},
}
envBytes, _ := json.Marshal(env)
sum := sha256.Sum256(envBytes)
return env, "sha256:" + hex(sum[:]), nil
}
type fakeTLog struct {
mu sync.Mutex
err error
submits int
lastHash string
}
func (f *fakeTLog) Submit(_ context.Context, _ signer.Envelope, contentHash string) (tlog.Receipt, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.submits++
f.lastHash = contentHash
if f.err != nil {
return tlog.Receipt{}, f.err
}
return tlog.Receipt{
LogIndex: 42,
ContentHash: contentHash,
LoggedAt: time.Now().UTC(),
}, nil
}
// newFixture wires a Server backed by an in-memory store + fakes. A single
// WebAuthn credential is pre-registered for userSub so tests can confirm
// previews without going through a real enrollment ceremony.
func newFixture(t *testing.T, opts ...func(*service.Orchestrator)) *fixture {
t.Helper()
st := store.NewMemory()
mgr := sessions.New(st)
mgr.SessionTTL = 15 * time.Minute
mgr.ChallengeTTL = 2 * time.Minute
userSub := "user-alice"
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
idBytes := make([]byte, 16)
_, _ = rand.Read(idBytes)
credID := base64.RawURLEncoding.EncodeToString(idBytes)
webStore := webauthn.NewStore()
if err := webStore.AddCredential(webauthn.Credential{
ID: credID,
UserSub: userSub,
PublicKey: pub,
}); err != nil {
t.Fatalf("register credential: %v", err)
}
rpID := "trust.basicstack.de"
origin := "https://trust.basicstack.de"
sig := &fakeSigner{wantKeyRef: "trust-api/approval-signer-2026-q4"}
submitter := &fakeTLog{}
orch := &service.Orchestrator{
Store: st,
Sessions: mgr,
Webauthn: webStore,
WebauthnCfg: webauthn.VerifyConfig{
RPID: rpID,
AllowedOrigins: []string{origin},
},
Signer: sig,
Submitter: submitter,
SigningKeyRef: "trust-api/approval-signer-2026-q4",
AttestationIssuer: "https://auth.basicstack.de",
}
for _, o := range opts {
o(orch)
}
srv := &httpapi.Server{
Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
return auth.Principal{Sub: userSub, Groups: []string{"proofedmesh-admin"}}, nil
}),
Sessions: mgr,
Orchestrator: orch,
}
mux := http.NewServeMux()
srv.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return &fixture{
t: t,
server: ts,
userSub: userSub,
credID: credID,
credPriv: priv,
rpID: rpID,
origin: origin,
fakeSigner: sig,
fakeTLog: submitter,
store: st,
}
}
func (f *fixture) startSession() pkgapi.CreateSessionResponse {
f.t.Helper()
var out pkgapi.CreateSessionResponse
f.do("POST", "/api/v1/sessions", nil, http.StatusCreated, &out)
return out
}
func (f *fixture) preview(sessionID string, content map[string]any, want int) pkgapi.PreviewResponse {
f.t.Helper()
var out pkgapi.PreviewResponse
f.do("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
SessionID: sessionID,
Content: content,
}, want, &out)
return out
}
func (f *fixture) confirm(req pkgapi.ConfirmRequest, want int) (*pkgapi.ConfirmResponse, *pkgapi.Error) {
f.t.Helper()
resp := f.doRaw("POST", "/api/v1/approvals/confirm", req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
f.t.Fatalf("confirm: status %d want %d: %s", resp.StatusCode, want, string(body))
}
if want >= 200 && want < 300 {
var out pkgapi.ConfirmResponse
if err := json.Unmarshal(body, &out); err != nil {
f.t.Fatalf("decode confirm: %v (%s)", err, string(body))
}
return &out, nil
}
var ebody pkgapi.Error
if err := json.Unmarshal(body, &ebody); err != nil {
f.t.Fatalf("decode confirm error: %v (%s)", err, string(body))
}
return nil, &ebody
}
func (f *fixture) do(method, path string, body any, want int, out any) {
f.t.Helper()
resp := f.doRaw(method, path, body)
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
f.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, string(raw))
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
f.t.Fatalf("decode %s: %v (%s)", path, err, string(raw))
}
}
}
func (f *fixture) doRaw(method, path string, body any) *http.Response {
f.t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
f.t.Fatalf("marshal body: %v", err)
}
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, f.server.URL+path, r)
if err != nil {
f.t.Fatalf("new request: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := f.server.Client().Do(req)
if err != nil {
f.t.Fatalf("do: %v", err)
}
return resp
}
func (f *fixture) signAssertion(challenge []byte) pkgapi.Assertion {
f.t.Helper()
cd := map[string]string{
"type": "webauthn.get",
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
"origin": f.origin,
}
cdBytes, _ := json.Marshal(cd)
authData := webauthn.MakeAuthenticatorData(f.rpID)
cdHash := sha256.Sum256(cdBytes)
signed := append(append([]byte(nil), authData...), cdHash[:]...)
sig := ed25519.Sign(f.credPriv, signed)
return pkgapi.Assertion{
CredentialID: f.credID,
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
}
func hex(b []byte) string {
const hexchars = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, c := range b {
out[i*2] = hexchars[c>>4]
out[i*2+1] = hexchars[c&0x0f]
}
return string(out)
}
// --- actual tests ---------------------------------------------------------
func TestHappyPath(t *testing.T) {
f := newFixture(t)
sess := f.startSession()
if sess.SessionID == "" {
t.Fatalf("no session_id returned")
}
if sess.UserSub != f.userSub {
t.Errorf("session.user_sub = %q want %q", sess.UserSub, f.userSub)
}
pv := f.preview(sess.SessionID, map[string]any{
"title": "Approve rate change",
"action": "sign",
}, http.StatusOK)
if !strings.HasPrefix(pv.ContentHash, "sha256:") {
t.Errorf("preview content_hash: %q", pv.ContentHash)
}
if pv.VerificationDigits == "" {
t.Errorf("empty verification digits")
}
challenge, err := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
if err != nil {
t.Fatalf("decode challenge: %v", err)
}
assertion := f.signAssertion(challenge)
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
if ok.TLogStatus != "logged" {
t.Errorf("tlog status: %q want logged", ok.TLogStatus)
}
if ok.LogIndex == nil || *ok.LogIndex != 42 {
t.Errorf("log_index = %v want 42", ok.LogIndex)
}
if ok.AttestationRef == "" || !strings.HasPrefix(ok.AttestationRef, "sha256:") {
t.Errorf("attestation_ref: %q", ok.AttestationRef)
}
// Assert the signer saw a Statement whose subject.digest.sha256 is
// the H_content of the preview (no "sha256:" prefix per in-toto).
f.fakeSigner.mu.Lock()
stmt := f.fakeSigner.seenStatement
f.fakeSigner.mu.Unlock()
subj, _ := stmt["subject"].([]any)
if len(subj) != 1 {
t.Fatalf("statement subject: %+v", subj)
}
first, _ := subj[0].(map[string]any)
digest, _ := first["digest"].(map[string]any)
gotDigest, _ := digest["sha256"].(string)
wantDigest := strings.TrimPrefix(pv.ContentHash, "sha256:")
if gotDigest != wantDigest {
t.Errorf("signed subject digest=%q want %q", gotDigest, wantDigest)
}
}
func TestConfirm_TamperedHContent(t *testing.T) {
f := newFixture(t)
sess := f.startSession()
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
// Flip the last byte of the challenge (equivalent to signing with a
// different H_content), the signature will be over wrong data.
tampered := append([]byte(nil), challenge...)
tampered[len(tampered)-1] ^= 0xff
assertion := f.signAssertion(tampered)
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadRequest)
if errBody.Error.Code != "content_hash_mismatch" {
t.Errorf("error code = %q want content_hash_mismatch", errBody.Error.Code)
}
}
func TestConfirm_ExpiredChallenge(t *testing.T) {
// Force a short challenge TTL so we can race the clock.
f := newFixture(t)
sess := f.startSession()
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
// Walk the preview's ExpiresAt backward directly in the store to
// simulate an expired challenge without a sleep.
prev, err := f.store.GetPreview(context.Background(), pv.PreviewID)
if err != nil {
t.Fatalf("GetPreview: %v", err)
}
prev.ExpiresAt = time.Now().Add(-1 * time.Second)
if err := f.store.SavePreview(context.Background(), prev); err != nil {
t.Fatalf("SavePreview: %v", err)
}
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
assertion := f.signAssertion(challenge)
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusForbidden)
if errBody.Error.Code != "challenge_expired" {
t.Errorf("error code = %q want challenge_expired", errBody.Error.Code)
}
}
func TestConfirm_SignerUnavailable(t *testing.T) {
f := newFixture(t, func(o *service.Orchestrator) {
o.Signer = &fakeSigner{err: signer.ErrSignerUnavailable}
})
sess := f.startSession()
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
assertion := f.signAssertion(challenge)
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadGateway)
if errBody.Error.Code != "signer_unavailable" {
t.Errorf("error code = %q want signer_unavailable", errBody.Error.Code)
}
}
func TestConfirm_TLogFailureStaysPending(t *testing.T) {
f := newFixture(t, func(o *service.Orchestrator) {
o.Submitter = &fakeTLog{err: tlog.ErrTLogUnavailable}
})
sess := f.startSession()
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
assertion := f.signAssertion(challenge)
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
if ok.TLogStatus != "attestation-pending" {
t.Errorf("tlog status = %q want attestation-pending", ok.TLogStatus)
}
if ok.LogIndex != nil {
t.Errorf("log_index = %v want nil", ok.LogIndex)
}
// Approval must still have been persisted.
appr, ok2 := f.store.GetApproval(ok.ApprovalID)
if !ok2 {
t.Fatalf("approval not persisted after tlog failure")
}
if appr.TLogStatus != store.TLogPending {
t.Errorf("stored tlog status = %q want attestation-pending", appr.TLogStatus)
}
if appr.TLogError == "" {
t.Errorf("expected tlog_error to be recorded")
}
}
func TestPreview_SessionExpired(t *testing.T) {
f := newFixture(t)
sess := f.startSession()
// Simulate session expiry.
raw, err := f.store.GetSession(context.Background(), sess.SessionID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
raw.ActiveUntil = time.Now().Add(-1 * time.Second)
if err := f.store.CreateSession(context.Background(), raw); err != nil {
t.Fatalf("CreateSession: %v", err)
}
resp := f.doRaw("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
SessionID: sess.SessionID,
Content: map[string]any{"k": "v"},
})
defer resp.Body.Close()
raw2, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status %d want 403: %s", resp.StatusCode, raw2)
}
var ebody pkgapi.Error
_ = json.Unmarshal(raw2, &ebody)
if ebody.Error.Code != "session_expired" {
t.Errorf("error code = %q want session_expired", ebody.Error.Code)
}
}
func TestConfirm_PreviewConsumedTwice(t *testing.T) {
f := newFixture(t)
sess := f.startSession()
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
assertion := f.signAssertion(challenge)
_, _ = f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
// Second confirm must be refused.
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusConflict)
if errBody.Error.Code != "preview_consumed" {
t.Errorf("error code = %q want preview_consumed", errBody.Error.Code)
}
}