382 lines
12 KiB
Go
382 lines
12 KiB
Go
|
|
package httpapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto"
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/rsa"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"math/big"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
|
||
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/registry"
|
||
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
|
||
|
|
"basicstack.de/proofedmesh/services/signing-service/internal/token"
|
||
|
|
)
|
||
|
|
|
||
|
|
// mockPocketID issues RS256 JWTs and serves OIDC discovery + JWKS over
|
||
|
|
// httptest so the signing-service's Pocket-ID validator thinks it is
|
||
|
|
// talking to real Pocket ID. The suite exercises OIDC-Discovery +
|
||
|
|
// JWK-Cache + kid rotation.
|
||
|
|
type mockPocketID struct {
|
||
|
|
srv *httptest.Server
|
||
|
|
keys map[string]*rsa.PrivateKey // active kid → key
|
||
|
|
// activeKIDs returns the order in which keys appear in the JWKS.
|
||
|
|
activeKIDs []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func newMockPocketID(t *testing.T) *mockPocketID {
|
||
|
|
t.Helper()
|
||
|
|
m := &mockPocketID{keys: map[string]*rsa.PrivateKey{}}
|
||
|
|
k1, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
m.keys["k1"] = k1
|
||
|
|
m.activeKIDs = []string{"k1"}
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
fmt.Fprintf(w, `{
|
||
|
|
"issuer":"%s",
|
||
|
|
"authorization_endpoint":"%s/auth",
|
||
|
|
"token_endpoint":"%s/token",
|
||
|
|
"jwks_uri":"%s/jwks",
|
||
|
|
"id_token_signing_alg_values_supported":["RS256"]
|
||
|
|
}`, m.issuer(), m.issuer(), m.issuer(), m.issuer())
|
||
|
|
})
|
||
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
var keys []map[string]any
|
||
|
|
for _, kid := range m.activeKIDs {
|
||
|
|
k := m.keys[kid]
|
||
|
|
n := base64.RawURLEncoding.EncodeToString(k.N.Bytes())
|
||
|
|
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes())
|
||
|
|
keys = append(keys, map[string]any{
|
||
|
|
"kty": "RSA", "use": "sig", "kid": kid, "alg": "RS256", "n": n, "e": e,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": keys})
|
||
|
|
})
|
||
|
|
m.srv = httptest.NewServer(mux)
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockPocketID) issuer() string { return m.srv.URL }
|
||
|
|
|
||
|
|
func (m *mockPocketID) close() { m.srv.Close() }
|
||
|
|
|
||
|
|
func (m *mockPocketID) rotateAddKey(t *testing.T, kid string) {
|
||
|
|
t.Helper()
|
||
|
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
m.keys[kid] = k
|
||
|
|
m.activeKIDs = append(m.activeKIDs, kid)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *mockPocketID) signToken(t *testing.T, kid, sub, aud string) string {
|
||
|
|
t.Helper()
|
||
|
|
k, ok := m.keys[kid]
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("no key %q", kid)
|
||
|
|
}
|
||
|
|
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}
|
||
|
|
claims := map[string]any{
|
||
|
|
"iss": m.issuer(),
|
||
|
|
"sub": sub,
|
||
|
|
"aud": aud,
|
||
|
|
"iat": time.Now().Unix(),
|
||
|
|
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||
|
|
}
|
||
|
|
hJ, _ := json.Marshal(header)
|
||
|
|
cJ, _ := json.Marshal(claims)
|
||
|
|
signingInput := base64.RawURLEncoding.EncodeToString(hJ) + "." + base64.RawURLEncoding.EncodeToString(cJ)
|
||
|
|
h := sha256.Sum256([]byte(signingInput))
|
||
|
|
sig, err := rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, h[:])
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----- fake TokenReviewer -----
|
||
|
|
|
||
|
|
func newFakeTokenReviewer(table map[string]token.K8sIdentity) token.TokenReviewer {
|
||
|
|
return token.StaticTokenReviewer(table)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----- fake registry seeded with Phase-1 Phase 1 agents -----
|
||
|
|
|
||
|
|
func newPhase1Registry() registry.Resolver {
|
||
|
|
return registry.NewStatic([]registry.Agent{
|
||
|
|
{
|
||
|
|
AgentID: "agent-approval-service",
|
||
|
|
PocketIDClient: "agent-approval-service",
|
||
|
|
K8sNamespace: "agent-trust",
|
||
|
|
K8sServiceAccount: "approval-service",
|
||
|
|
AllowedActions: []string{"expense.submit"},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
AgentID: "agent-testagent-transform",
|
||
|
|
PocketIDClient: "agent-testagent-transform",
|
||
|
|
K8sNamespace: "agent-trust",
|
||
|
|
K8sServiceAccount: "testagent-transform",
|
||
|
|
AllowedActions: []string{"expense.normalize", "expense.map_to_sink"},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadPolicy(t *testing.T) policy.StructuredDecider {
|
||
|
|
t.Helper()
|
||
|
|
p := filepath.Join("..", "..", "..", "..", "policies", "sinks", "signing_service.rego")
|
||
|
|
opa, err := policy.NewOPAEvaluator(context.Background(), p)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("load policy: %v", err)
|
||
|
|
}
|
||
|
|
return opa
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----- harness wiring everything together -----
|
||
|
|
|
||
|
|
type prodHarness struct {
|
||
|
|
hsrv *httptest.Server
|
||
|
|
mockPID *mockPocketID
|
||
|
|
approvalPub ed25519.PublicKey
|
||
|
|
transformPub ed25519.PublicKey
|
||
|
|
workloadToken map[string]token.K8sIdentity
|
||
|
|
approvalBearer string
|
||
|
|
transformBearer string
|
||
|
|
}
|
||
|
|
|
||
|
|
func newProdHarness(t *testing.T) *prodHarness {
|
||
|
|
t.Helper()
|
||
|
|
m := newMockPocketID(t)
|
||
|
|
t.Cleanup(m.close)
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
pv, err := token.NewPocketIDValidator(ctx, m.issuer(), "trust-api")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
signerBackend := signer.NewInMemory()
|
||
|
|
_, approvalPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||
|
|
signerBackend.AddKey("trust-api/approval-signer-2026-q4", approvalPriv)
|
||
|
|
_, transformPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||
|
|
signerBackend.AddKey("trust-api/agent-testagent-transform-2026-q4", transformPriv)
|
||
|
|
|
||
|
|
wl := map[string]token.K8sIdentity{
|
||
|
|
"wl-approval": {Namespace: "agent-trust", ServiceAccount: "approval-service"},
|
||
|
|
"wl-transform": {Namespace: "agent-trust", ServiceAccount: "testagent-transform"},
|
||
|
|
}
|
||
|
|
|
||
|
|
srv := Server{
|
||
|
|
Signer: signerBackend,
|
||
|
|
Validator: pv,
|
||
|
|
TokenReviewer: newFakeTokenReviewer(wl),
|
||
|
|
Registry: newPhase1Registry(),
|
||
|
|
StructuredPolicy: loadPolicy(t),
|
||
|
|
Policy: policy.AllowAll{}, // unused in the structured path
|
||
|
|
Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) },
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
srv.Mount(mux)
|
||
|
|
hsrv := httptest.NewServer(mux)
|
||
|
|
t.Cleanup(hsrv.Close)
|
||
|
|
|
||
|
|
return &prodHarness{
|
||
|
|
hsrv: hsrv,
|
||
|
|
mockPID: m,
|
||
|
|
approvalPub: approvalPriv.Public().(ed25519.PublicKey),
|
||
|
|
transformPub: transformPriv.Public().(ed25519.PublicKey),
|
||
|
|
workloadToken: wl,
|
||
|
|
approvalBearer: m.signToken(t, "k1", "agent-approval-service", "trust-api"),
|
||
|
|
transformBearer: m.signToken(t, "k1", "agent-testagent-transform", "trust-api"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func sign(h *prodHarness, bearer, workloadToken, body string) (*http.Response, error) {
|
||
|
|
req, _ := http.NewRequest("POST", h.hsrv.URL+"/api/v1/sign/dsse", bytes.NewBufferString(body))
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||
|
|
if workloadToken != "" {
|
||
|
|
req.Header.Set(token.WorkloadTokenHeader, workloadToken)
|
||
|
|
}
|
||
|
|
return http.DefaultClient.Do(req)
|
||
|
|
}
|
||
|
|
|
||
|
|
const approvalStatement = `{
|
||
|
|
"key_ref":"trust-api/approval-signer-2026-q4",
|
||
|
|
"statement":{
|
||
|
|
"_type":"https://in-toto.io/Statement/v1",
|
||
|
|
"subject":[{"name":"approval/APR-1","digest":{"sha256":"abcdef"}}],
|
||
|
|
"predicateType":"https://basicstack.de/attestations/approval/v1",
|
||
|
|
"predicate":{"approval_id":"APR-1"}
|
||
|
|
}
|
||
|
|
}`
|
||
|
|
|
||
|
|
const transformStatement = `{
|
||
|
|
"key_ref":"trust-api/agent-testagent-transform-2026-q4",
|
||
|
|
"statement":{
|
||
|
|
"_type":"https://in-toto.io/Statement/v1",
|
||
|
|
"subject":[{"name":"transform/TR-1","digest":{"sha256":"abcdef"}}],
|
||
|
|
"predicateType":"https://basicstack.de/attestations/transform/v1",
|
||
|
|
"predicate":{"transform_id":"TR-1"}
|
||
|
|
}
|
||
|
|
}`
|
||
|
|
|
||
|
|
// --- happy paths ---
|
||
|
|
|
||
|
|
func TestProd_HappyPath_ApprovalService(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
resp, err := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
b, _ := jsonBody(resp)
|
||
|
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||
|
|
}
|
||
|
|
assertSignatureVerifies(t, resp, h.approvalPub, "application/vnd.in-toto+json")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProd_HappyPath_TestAgentTransform(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
resp, err := sign(h, h.transformBearer, "wl-transform", transformStatement)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
b, _ := jsonBody(resp)
|
||
|
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||
|
|
}
|
||
|
|
assertSignatureVerifies(t, resp, h.transformPub, "application/vnd.in-toto+json")
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- denies ---
|
||
|
|
|
||
|
|
func TestProd_DeniesOnRegistryMismatch(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
// Pocket-ID says Approval Service, but the X-Workload-Token is the
|
||
|
|
// transform agent's. Registry lookups land on different rows → 403.
|
||
|
|
resp, _ := sign(h, h.approvalBearer, "wl-transform", approvalStatement)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusForbidden {
|
||
|
|
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
var e errResponse
|
||
|
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||
|
|
if e.Error.Code != "signing_denied" {
|
||
|
|
t.Errorf("expected signing_denied code, got %q (%s)", e.Error.Code, e.Error.Message)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProd_DeniesOnPolicyWrongKeyRef(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
// Approval service identity on both sides, but asks for the transform
|
||
|
|
// agent's key_ref. Policy must deny.
|
||
|
|
body := strings.ReplaceAll(approvalStatement,
|
||
|
|
"trust-api/approval-signer-2026-q4",
|
||
|
|
"trust-api/agent-testagent-transform-2026-q4")
|
||
|
|
resp, _ := sign(h, h.approvalBearer, "wl-approval", body)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusForbidden {
|
||
|
|
t.Fatalf("expected 403 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProd_RejectsMissingWorkloadToken(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
resp, _ := sign(h, h.approvalBearer, "", approvalStatement)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProd_RejectsBadWorkloadToken(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
resp, _ := sign(h, h.approvalBearer, "wl-does-not-exist", approvalStatement)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProd_RejectsBadPocketIDToken(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
resp, _ := sign(h, "not.a.jwt", "wl-approval", approvalStatement)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("expected 401 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- kid rotation ---
|
||
|
|
|
||
|
|
func TestProd_PocketID_KidRotation(t *testing.T) {
|
||
|
|
h := newProdHarness(t)
|
||
|
|
// Baseline works with k1.
|
||
|
|
resp, _ := sign(h, h.approvalBearer, "wl-approval", approvalStatement)
|
||
|
|
resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("baseline: expected 200 got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
// Rotate: new kid k2 appears in JWKS. A token signed with k2 must verify
|
||
|
|
// after the validator refreshes the JWKS (go-oidc does this on kid miss).
|
||
|
|
h.mockPID.rotateAddKey(t, "k2")
|
||
|
|
bearer := h.mockPID.signToken(t, "k2", "agent-approval-service", "trust-api")
|
||
|
|
resp2, _ := sign(h, bearer, "wl-approval", approvalStatement)
|
||
|
|
defer resp2.Body.Close()
|
||
|
|
if resp2.StatusCode != http.StatusOK {
|
||
|
|
b, _ := jsonBody(resp2)
|
||
|
|
t.Fatalf("after rotation: expected 200 got %d: %s", resp2.StatusCode, b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- helpers ---
|
||
|
|
|
||
|
|
func jsonBody(resp *http.Response) (string, error) {
|
||
|
|
var buf bytes.Buffer
|
||
|
|
_, err := buf.ReadFrom(resp.Body)
|
||
|
|
return buf.String(), err
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertSignatureVerifies(t *testing.T, resp *http.Response, pub ed25519.PublicKey, payloadType string) {
|
||
|
|
t.Helper()
|
||
|
|
var out signResponse
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||
|
|
t.Fatalf("decode: %v", err)
|
||
|
|
}
|
||
|
|
payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("decode payload: %v", err)
|
||
|
|
}
|
||
|
|
pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload)))
|
||
|
|
pae = append(pae, payload...)
|
||
|
|
sig, err := base64.RawURLEncoding.DecodeString(out.Envelope.Signatures[0].Sig)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("decode sig: %v", err)
|
||
|
|
}
|
||
|
|
if !ed25519.Verify(pub, pae, sig) {
|
||
|
|
t.Fatal("returned signature does not verify")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|