218 lines
6.3 KiB
Go
218 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|