diff --git a/services/admin-api/go.mod b/services/admin-api/go.mod index cdd7f34..0329091 100644 --- a/services/admin-api/go.mod +++ b/services/admin-api/go.mod @@ -2,9 +2,15 @@ module basicstack.de/proofedmesh/services/admin-api go 1.23 -// Interop test in internal/tlog exercises the real transparency-log HTTP -// handler via its public pkg API. The module is only available locally in -// this monorepo; keep the replace directive aligned with the sibling path. +// Interop tests under internal/tlog and internal/attest exercise the real +// sibling services (transparency-log and signing-service) via their public +// pkg APIs. These modules are only available locally in this monorepo; keep +// each replace directive aligned with the sibling's path. replace basicstack.de/proofedmesh/services/transparency-log => ../transparency-log -require basicstack.de/proofedmesh/services/transparency-log v0.0.0 +replace basicstack.de/proofedmesh/services/signing-service => ../signing-service + +require ( + basicstack.de/proofedmesh/services/signing-service v0.0.0 + basicstack.de/proofedmesh/services/transparency-log v0.0.0 +) diff --git a/services/admin-api/internal/attest/interop_signing_test.go b/services/admin-api/internal/attest/interop_signing_test.go new file mode 100644 index 0000000..b470b68 --- /dev/null +++ b/services/admin-api/internal/attest/interop_signing_test.go @@ -0,0 +1,141 @@ +package attest + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "net/http/httptest" + "strings" + "testing" + "time" + + signinghttp "basicstack.de/proofedmesh/services/signing-service/pkg/httpapi" +) + +// Interop test for DEV-599: the real attest.HTTPSigner client is pointed at a +// real signing-service HTTP handler (same code paths production uses, just +// backed by the in-memory Ed25519 keyring and AllowAll policy), and we assert +// the full ADR-0005 round-trip: +// +// 1. HTTPSigner POSTs to /api/v1/sign/dsse with (key_ref, statement). +// 2. The signing-service canonicalises, builds the DSSE PAE, signs with +// Ed25519, assembles the envelope, hashes it. +// 3. HTTPSigner receives (envelope, content_hash) and returns them. +// 4. The test re-derives content_hash from the envelope bytes to prove +// stability, decodes the base64url payload+signature, rebuilds the PAE, +// and verifies the Ed25519 signature with the server's public key. +// +// A green run proves every admin-api consumer of HTTPSigner (admin HTTP +// routes, rate_change Orchestrator, future agents) is wire-compatible with +// the signing-service as it is actually built today. +func TestInterop_HTTPSigner_AgainstRealSigningService(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + const keyRef = "trust-api/rate-change-signer-2026-q4" + handler := signinghttp.NewTestHandler(signinghttp.TestServerConfig{ + Keys: map[string]ed25519.PrivateKey{keyRef: priv}, + Tokens: map[string]string{"interop-token": "agent-admin-api"}, + Now: func() time.Time { return time.Date(2026, 10, 25, 18, 30, 0, 0, time.UTC) }, + }) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + // The real admin-api client, pointed at the real signing-service handler. + s := HTTPSigner{BaseURL: server.URL, TokenSource: StaticToken("interop-token")} + stmt := Statement{ + Type: "https://in-toto.io/Statement/v1", + Subject: []Subject{{ + Name: "per_diem_rate/PDR-1", + Digest: map[string]string{"sha256": "0000000000000000000000000000000000000000000000000000000000000000"}, + }}, + PredicateType: "https://basicstack.de/attestations/rate_change/v1", + Predicate: map[string]any{ + "change_kind": "create", + "rate_id": "PDR-1", + "country": "DE", + "currency": "EUR", + }, + } + env, contentHash, err := s.SignDSSE(context.Background(), keyRef, stmt) + if err != nil { + t.Fatalf("HTTPSigner.SignDSSE against real signing-service: %v", err) + } + + // Envelope shape per ADR-0005. + if env.PayloadType != "application/vnd.in-toto+json" { + t.Errorf("payloadType: %q", env.PayloadType) + } + if len(env.Signatures) != 1 { + t.Fatalf("want 1 signature, got %d", len(env.Signatures)) + } + if env.Signatures[0].KeyID != keyRef { + t.Errorf("keyid: %q (want %q)", env.Signatures[0].KeyID, keyRef) + } + if !strings.HasPrefix(contentHash, "sha256:") || len(contentHash) != len("sha256:")+64 { + t.Errorf("content_hash shape: %q", contentHash) + } + + // content_hash must be self-consistent: recomputing it from the envelope + // bytes via the admin-api's own canonicalisation yields the same value. + // This is the stability guarantee ADR-0005 relies on everywhere + // `rate_change_attestation_ref` is stored. + recomputed, err := EnvelopeContentHash(env) + if err != nil { + t.Fatal(err) + } + if recomputed != contentHash { + t.Fatalf("content_hash is not self-consistent across admin-api canonicaliser:\n server=%s\n client=%s", contentHash, recomputed) + } + + // Verify the Ed25519 signature end-to-end with the server's public key. + payload, err := base64.RawURLEncoding.DecodeString(env.Payload) + if err != nil { + t.Fatalf("payload decode: %v", err) + } + sig, err := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig) + if err != nil { + t.Fatalf("sig decode: %v", err) + } + pae := paeDSSEv1(env.PayloadType, payload) + if !ed25519.Verify(pub, pae, sig) { + t.Fatal("Ed25519.Verify failed on (pae, sig) returned by the signing-service via HTTPSigner") + } + + // The payload bytes are the canonical JSON of the submitted statement. + // Any divergence here would mean HTTPSigner and the signing-service + // disagree on canonicalisation, which breaks every downstream verifier. + expectedPayload, err := canonicalJSON(stmt) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(payload, expectedPayload) { + t.Fatalf("payload bytes diverge:\n got=%s\nwant=%s", string(payload), string(expectedPayload)) + } +} + +// paeDSSEv1 mirrors the signing-service's PAE encoder. Kept local to this +// test so a drift between client and server would surface here instead of +// relying on an unexported helper on either side. +func paeDSSEv1(payloadType string, payload []byte) []byte { + s := "DSSEv1 " + s += itoa(len(payloadType)) + " " + payloadType + " " + itoa(len(payload)) + " " + return append([]byte(s), payload...) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/services/signing-service/pkg/httpapi/httpapi.go b/services/signing-service/pkg/httpapi/httpapi.go new file mode 100644 index 0000000..e2bf659 --- /dev/null +++ b/services/signing-service/pkg/httpapi/httpapi.go @@ -0,0 +1,62 @@ +// Package httpapi exposes the signing-service's HTTP contract as a public +// package so sibling modules (admin-api interop tests, future verifier +// services) can build a real, in-process signing-service against which to +// run end-to-end checks without duplicating ADR-0005 wire assembly. +// +// Production builds reach the handler through cmd/signing-service/main.go, +// which wires the same internal/httpapi.Server with Pocket-ID JWKS, OPA, and +// OpenBao backends. The signing-service module itself does not expose those +// prod backends through this package; this is the test/interop surface only. +package httpapi + +import ( + "crypto/ed25519" + "net/http" + "time" + + internalhttpapi "basicstack.de/proofedmesh/services/signing-service/internal/httpapi" + "basicstack.de/proofedmesh/services/signing-service/internal/policy" + "basicstack.de/proofedmesh/services/signing-service/internal/signer" + "basicstack.de/proofedmesh/services/signing-service/internal/token" +) + +// TestServerConfig configures the in-memory signing-service for interop tests. +// All fields are required; the factory panics on empty maps so a silent typo +// in a sibling module's test does not look like a successful setup. +type TestServerConfig struct { + // Keys maps key_ref → Ed25519 private key. The matching public key must + // be held by the test so it can independently verify the returned + // envelope signatures. + Keys map[string]ed25519.PrivateKey + // Tokens maps bearer token → subject (the Pocket-ID "sub" claim). The + // handler returns 401 for any token not in this map. + Tokens map[string]string + // Now is injected so SignedAt is deterministic in tests. Optional. + Now func() time.Time +} + +// NewTestHandler constructs the full signing-service HTTP handler with the +// in-memory signer, AllowAll policy, and static token validator. The handler +// implements the ADR-0005 POST /api/v1/sign/dsse contract and exposes +// GET /healthz. It is safe to wrap in an httptest.Server. +func NewTestHandler(cfg TestServerConfig) http.Handler { + if len(cfg.Keys) == 0 { + panic("signing-service/pkg/httpapi: TestServerConfig.Keys must not be empty") + } + if len(cfg.Tokens) == 0 { + panic("signing-service/pkg/httpapi: TestServerConfig.Tokens must not be empty") + } + s := signer.NewInMemory() + for ref, priv := range cfg.Keys { + s.AddKey(ref, priv) + } + srv := internalhttpapi.Server{ + Signer: s, + Policy: policy.AllowAll{}, + Validator: token.Static(cfg.Tokens), + Now: cfg.Now, + } + mux := http.NewServeMux() + srv.Mount(mux) + return mux +}