ProofedMesh/services/signing-service/internal/httpapi/sign_test.go

219 lines
6.3 KiB
Go
Raw Normal View History

package httpapi
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"basicstack.de/proofedmesh/services/signing-service/internal/policy"
"basicstack.de/proofedmesh/services/signing-service/internal/signer"
"basicstack.de/proofedmesh/services/signing-service/internal/token"
)
func mustGenKey(t *testing.T) ed25519.PrivateKey {
t.Helper()
_, k, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return k
}
func newTestServer(t *testing.T) (*httptest.Server, ed25519.PublicKey) {
t.Helper()
priv := mustGenKey(t)
s := signer.NewInMemory()
s.AddKey("trust-api/test-key", priv)
srv := Server{
Signer: s,
Policy: policy.AllowAll{},
Validator: token.Static{"dev-token": "agent-admin-api"},
Now: func() time.Time { return time.Date(2026, 10, 25, 18, 30, 0, 0, time.UTC) },
}
mux := http.NewServeMux()
srv.Mount(mux)
return httptest.NewServer(mux), priv.Public().(ed25519.PublicKey)
}
func post(url, bearer, body string) (*http.Response, error) {
req, _ := http.NewRequest("POST", url, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return http.DefaultClient.Do(req)
}
func TestSign_HappyPath_VerifiesEndToEnd(t *testing.T) {
srv, pub := newTestServer(t)
defer srv.Close()
body := `{
"key_ref":"trust-api/test-key",
"statement":{
"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"per_diem_rate/PDR-1","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],
"predicateType":"https://basicstack.de/attestations/rate_change/v1",
"predicate":{"change_kind":"create","rate_id":"PDR-1"}
}
}`
resp, err := post(srv.URL+"/api/v1/sign/dsse", "dev-token", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
var out signResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if !strings.HasPrefix(out.ContentHash, "sha256:") || len(out.ContentHash) != 71 {
t.Fatalf("content_hash shape: %q", out.ContentHash)
}
// Independently verify the signature with the server's public key.
payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload)
if err != nil {
t.Fatalf("decode payload: %v", err)
}
pae := encodePAEForTest("application/vnd.in-toto+json", 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 against server public key")
}
}
func encodePAEForTest(payloadType string, payload []byte) []byte {
// Mirror of signer.encodePAE — kept local so this test does not depend on
// an unexported helper and so a drift between client and server would
// surface here.
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:])
}
func TestSign_RejectsMissingBearer(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
resp, _ := post(srv.URL+"/api/v1/sign/dsse", "", "{}")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestSign_RejectsUnknownBearer(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
resp, _ := post(srv.URL+"/api/v1/sign/dsse", "bogus", "{}")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestSign_RejectsUnknownKeyRef(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
body := `{"key_ref":"nope","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"x"}}`
resp, _ := post(srv.URL+"/api/v1/sign/dsse", "dev-token", body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
var out errResponse
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error.Code != "unknown_key" {
t.Errorf("expected code=unknown_key, got %q (%s)", out.Error.Code, out.Error.Message)
}
}
type denyAll struct{}
func (denyAll) Allow(_ context.Context, _, _ string) (policy.Decision, error) {
return policy.Decision{Allow: false, Reason: "no such key allowed for this client"}, nil
}
func TestSign_RejectsOnPolicyDeny(t *testing.T) {
priv := mustGenKey(t)
s := signer.NewInMemory()
s.AddKey("k", priv)
srv := Server{
Signer: s,
Policy: denyAll{},
Validator: token.Static{"dev-token": "agent-x"},
}
mux := http.NewServeMux()
srv.Mount(mux)
hs := httptest.NewServer(mux)
defer hs.Close()
body := `{"key_ref":"k","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"p"}}`
resp, _ := post(hs.URL+"/api/v1/sign/dsse", "dev-token", body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
var out errResponse
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error.Code != "signing_denied" {
t.Errorf("expected signing_denied, got %q", out.Error.Code)
}
}
func TestSign_RejectsBadStatement(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
bad := []string{
`{"key_ref":"trust-api/test-key","statement":{}}`,
`{"key_ref":"trust-api/test-key","statement":{"_type":"wrong"}}`,
`{"key_ref":"trust-api/test-key","statement":{"_type":"https://in-toto.io/Statement/v1"}}`,
`{"key_ref":"","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"p"}}`,
}
for i, b := range bad {
resp, _ := post(srv.URL+"/api/v1/sign/dsse", "dev-token", b)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("case %d: expected 400, got %d", i, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestHealth(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
}