signing-service: public pkg/httpapi test surface + admin-api interop test (DEV-599)
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Successful in 22s

- services/signing-service/pkg/httpapi exposes NewTestHandler so sibling
  modules can build the real signing-service in-process (same pattern
  transparency-log already uses).
- services/admin-api/internal/attest/interop_signing_test.go drives the real
  attest.HTTPSigner against the real signing-service handler and asserts the
  end-to-end ADR-0005 round-trip: envelope shape, keyid, content_hash
  self-consistency across canonicalisers, Ed25519 signature verification
  with the server's public key, and canonical-payload byte equality.

Closes the "Interop-Test: admin-api attest.HTTPSigner-Client erfolgreich
gegen diesen Server" acceptance criterion from DEV-599.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO Agent 2026-10-25 18:11:09 +00:00
parent 9f1eb84032
commit 5d338dd725
3 changed files with 213 additions and 4 deletions

View file

@ -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
)

View file

@ -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:])
}

View file

@ -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
}