142 lines
4.9 KiB
Go
142 lines
4.9 KiB
Go
|
|
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:])
|
||
|
|
}
|