- BLAKE3 keyed proof-of-work on object storage and auth challenges, with frozen vectors cross-checked against an independent Python reference implementing the single-block hash it needs. - Sparse Merkle trie over object IDs: order-independent roots, inclusion and absence proofs (internal/smt). - Signed checkpoint chain per relay: transport key amendment to INV-1, /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe epoch continuity (internal/checkpoint). - Head gossip with TOFU pinning and equivocation detection; light node (cmd/lightnode) that stores no history: quorum of pinned relays, every served object proven against the agreed root, LRU disk cache. - WebSocket streaming on relay and light node (coder/websocket): scoped channels mirroring REST, raw envelopes verified client-side; light node marks streamed objects unproven until checkpoint coverage. - Protocol v1 additions: DelegationClaim tag 0x07 with deterministic chain resolution in verify.Graph, KeyRotationRequest/Confirm tags 0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge; spec sections, frozen vectors appended byte-identically, Python reference extended. - Optional permissioned BFT finality over gossip (internal/bft): prevote/precommit with quorum certificates verifiable offline. - Quick wins: Policy.TrustedIssuers, per-type stored metrics, batch fetch, lexicographic lists with stable cursor pagination. - Security review of the network layer (docs/SECURITY-REVIEW.md) with findings F-01..F-09; hub send/close race and unstable pagination fixed under review. 12 packages green, vet/gofmt clean, protocol fuzzing stable.
898 lines
25 KiB
Go
898 lines
25 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/server"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/verify"
|
|
)
|
|
|
|
func newTestServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := server.New("trust.n1ko.dev", "")
|
|
return httptest.NewServer(srv.Handler())
|
|
}
|
|
|
|
func postEnvelope(t *testing.T, url string, env *transport.Envelope) *http.Response {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := json.NewEncoder(&buf).Encode(env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := http.Post(url, "application/json", &buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// authToken performs the challenge/assert handshake and returns the session
|
|
// token, for use by tests that need an authenticated read.
|
|
func authToken(t *testing.T, baseURL string, key *signer.Signer) string {
|
|
t.Helper()
|
|
cr, err := http.Post(baseURL+"/v1/auth/challenge", "application/json", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var chRes struct {
|
|
Challenge string `json:"challenge"`
|
|
}
|
|
_ = json.NewDecoder(cr.Body).Decode(&chRes)
|
|
cr.Body.Close()
|
|
ch, err := hex.DecodeString(chRes.Challenge)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: key.Public(),
|
|
Challenge: ch,
|
|
Scope: "read",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
tceBytes, err := protocol.EncodeAuthAssertion(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sig := key.Sign(tceBytes)
|
|
resp := postEnvelope(t, baseURL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("assert status %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
if out.SessionToken == "" {
|
|
t.Fatal("no session token")
|
|
}
|
|
return out.SessionToken
|
|
}
|
|
|
|
func TestPublishAndGetClaim(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"flag.example": tce.Bool(true)},
|
|
CreatedAt: 1_700_000_000,
|
|
ExpiresAt: 1_700_086_400,
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
|
|
}
|
|
tceBytes, err := protocol.EncodeClaim(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sig := issuer.Sign(tceBytes)
|
|
|
|
// Publish.
|
|
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("publish status %d", resp.StatusCode)
|
|
}
|
|
var pub struct {
|
|
ObjectID string `json:"object_id"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&pub)
|
|
resp.Body.Close()
|
|
if pub.ObjectID == "" {
|
|
t.Fatal("no object_id returned")
|
|
}
|
|
// The returned ID must equal SHA-256(tce).
|
|
if pub.ObjectID != tce.ComputeID(tceBytes).String() {
|
|
t.Fatal("object_id mismatch")
|
|
}
|
|
|
|
// Fetch it back.
|
|
got, err := http.Get(ts.URL + "/v1/objects/" + pub.ObjectID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer got.Body.Close()
|
|
if got.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status %d", got.StatusCode)
|
|
}
|
|
var env transport.Envelope
|
|
if err := json.NewDecoder(got.Body).Decode(&env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The verifier checks the signature over the exact TCE bytes.
|
|
if typ, err := env.Verify(); err != nil {
|
|
t.Fatalf("verify %s: %v", typ, err)
|
|
}
|
|
|
|
// Query by subject. The read endpoint now requires an authenticated
|
|
// session; authenticate as the subject itself with scope read:claims.
|
|
tok := authToken(t, ts.URL, subject)
|
|
q, err := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q.Header.Set("Authorization", "Bearer "+tok)
|
|
qresp, err := http.DefaultClient.Do(q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer qresp.Body.Close()
|
|
if qresp.StatusCode != http.StatusOK {
|
|
t.Fatalf("claims status %d", qresp.StatusCode)
|
|
}
|
|
var list struct {
|
|
Claims []json.RawMessage `json:"claims"`
|
|
}
|
|
if err := json.NewDecoder(qresp.Body).Decode(&list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list.Claims) != 1 {
|
|
t.Fatalf("expected 1 claim, got %d", len(list.Claims))
|
|
}
|
|
}
|
|
|
|
func TestAuthHandshake(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
client, _ := signer.Generate()
|
|
|
|
// Challenge.
|
|
cr, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var chRes struct {
|
|
Challenge string `json:"challenge"`
|
|
}
|
|
_ = json.NewDecoder(cr.Body).Decode(&chRes)
|
|
cr.Body.Close()
|
|
ch, err := hex.DecodeString(chRes.Challenge)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Assertion.
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: client.Public(),
|
|
Challenge: ch,
|
|
Scope: "read:claims",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
b, err := protocol.EncodeAuthAssertion(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sig := client.Sign(b)
|
|
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: b, Signature: sig})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("assert status %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
SessionToken string `json:"session_token"`
|
|
Identity string `json:"identity"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
if out.SessionToken == "" {
|
|
t.Fatal("no session token")
|
|
}
|
|
if out.Identity != client.Address().String() {
|
|
t.Fatalf("identity mismatch: %s != %s", out.Identity, client.Address().String())
|
|
}
|
|
}
|
|
|
|
func TestRejectsBadObjectID(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: issuer.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: 1_700_000_000,
|
|
ExpiresAt: 1_700_086_400,
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
|
|
}
|
|
tceBytes, _ := protocol.EncodeClaim(c)
|
|
sig := issuer.Sign(tceBytes)
|
|
|
|
// Lie about the object_id.
|
|
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{
|
|
TCE: tceBytes, Signature: sig, ObjectID: "deadbeef",
|
|
})
|
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected 422, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestGetMissingReturns404(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
resp, _ := http.Get(ts.URL + "/v1/objects/" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestDuplicatePutIsIdempotent(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: issuer.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: 1_700_000_000,
|
|
ExpiresAt: 1_700_086_400,
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{0x03}, tce.NonceSize),
|
|
}
|
|
tceBytes, _ := protocol.EncodeClaim(c)
|
|
sig := issuer.Sign(tceBytes)
|
|
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("put %d: expected 200, got %d", i, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestAssertRejectsBadSignature(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
claimer, _ := signer.Generate()
|
|
impostor, _ := signer.Generate()
|
|
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: claimer.Public(),
|
|
Challenge: bytes.Repeat([]byte{0x07}, tce.ChallengeSize),
|
|
Scope: "read:claims",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
tceBytes, _ := protocol.EncodeAuthAssertion(a)
|
|
// Sign with the wrong key.
|
|
sig := impostor.Sign(tceBytes)
|
|
|
|
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestAssertRejectsUnissuedChallenge(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
client, _ := signer.Generate()
|
|
|
|
// Assertion bound to a challenge the server never issued.
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: client.Public(),
|
|
Challenge: bytes.Repeat([]byte{0x09}, tce.ChallengeSize),
|
|
Scope: "read:claims",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
tceBytes, _ := protocol.EncodeAuthAssertion(a)
|
|
sig := client.Sign(tceBytes)
|
|
|
|
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unissued challenge, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestAssertChallengeIsSingleUse(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
client, _ := signer.Generate()
|
|
|
|
chRes := struct {
|
|
Challenge string `json:"challenge"`
|
|
}{}
|
|
resp, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
|
|
_ = json.NewDecoder(resp.Body).Decode(&chRes)
|
|
resp.Body.Close()
|
|
ch, _ := hex.DecodeString(chRes.Challenge)
|
|
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: client.Public(),
|
|
Challenge: ch,
|
|
Scope: "read:claims",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
tceBytes, _ := protocol.EncodeAuthAssertion(a)
|
|
sig := client.Sign(tceBytes)
|
|
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
|
|
|
|
// First use succeeds.
|
|
r1 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
|
|
if r1.StatusCode != http.StatusOK {
|
|
t.Fatalf("first assert: expected 200, got %d", r1.StatusCode)
|
|
}
|
|
r1.Body.Close()
|
|
|
|
// Replaying the same challenge (captured assertion) is rejected.
|
|
r2 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
|
|
if r2.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("replay: expected 401, got %d", r2.StatusCode)
|
|
}
|
|
r2.Body.Close()
|
|
}
|
|
|
|
func TestClaimsRequiresAuth(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
resp, _ := http.Get(ts.URL + "/v1/claims?subject=" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestClaimsInsufficientScope(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
client, _ := signer.Generate()
|
|
// Authenticate with a scope that does not cover read:claims.
|
|
cr, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
|
|
var chRes struct {
|
|
Challenge string `json:"challenge"`
|
|
}
|
|
_ = json.NewDecoder(cr.Body).Decode(&chRes)
|
|
cr.Body.Close()
|
|
ch, _ := hex.DecodeString(chRes.Challenge)
|
|
a := &protocol.AuthAssertion{
|
|
PubKey: client.Public(),
|
|
Challenge: ch,
|
|
Scope: "read:requests",
|
|
Audience: "trust.n1ko.dev",
|
|
CreatedAt: uint64(time.Now().Unix()),
|
|
}
|
|
tceBytes, _ := protocol.EncodeAuthAssertion(a)
|
|
sig := client.Sign(tceBytes)
|
|
ar := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
var out struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
_ = json.NewDecoder(ar.Body).Decode(&out)
|
|
ar.Body.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject=x", nil)
|
|
req.Header.Set("Authorization", "Bearer "+out.SessionToken)
|
|
resp, _ := http.DefaultClient.Do(req)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestStorePersistsAcrossRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Start one server, publish an object.
|
|
s1 := server.New("trust.n1ko.dev", dir)
|
|
issuer, _ := signer.Generate()
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: issuer.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: 1_700_000_000,
|
|
ExpiresAt: 1_700_086_400,
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{0x04}, tce.NonceSize),
|
|
}
|
|
tceBytes, _ := protocol.EncodeClaim(c)
|
|
sig := issuer.Sign(tceBytes)
|
|
id, _, err := s1.Store().Put(tceBytes, sig, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A fresh server over the same directory must recover the object.
|
|
s2 := server.New("trust.n1ko.dev", dir)
|
|
env, err := s2.Store().Get(id)
|
|
if err != nil {
|
|
t.Fatalf("object not recovered after restart: %v", err)
|
|
}
|
|
if _, err := env.Verify(); err != nil {
|
|
t.Fatalf("recovered object failed verify: %v", err)
|
|
}
|
|
if got, _ := s2.Store().ClaimsBySubject(issuer.Address().String()); len(got) != 1 {
|
|
t.Fatalf("expected 1 recovered claim, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestConfigReportsAudience(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
resp, _ := http.Get(ts.URL + "/v1/config")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("config status %d", resp.StatusCode)
|
|
}
|
|
var cfg struct {
|
|
Audience string `json:"audience"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&cfg)
|
|
resp.Body.Close()
|
|
if cfg.Audience != "trust.n1ko.dev" {
|
|
t.Fatalf("audience = %q", cfg.Audience)
|
|
}
|
|
}
|
|
|
|
func TestReadEndpointsRequireAuth(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
for _, ep := range []string{
|
|
"/v1/claims?subject=x",
|
|
"/v1/requests?recipient=x",
|
|
"/v1/responses?request=x",
|
|
"/v1/revocations?claim=x",
|
|
} {
|
|
resp, _ := http.Get(ts.URL + ep)
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("%s: expected 401, got %d", ep, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestApprovalFlow(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
approver, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
|
|
now := uint64(time.Now().Unix())
|
|
|
|
req := &protocol.ApprovalRequest{
|
|
Sender: issuer.Public(),
|
|
Recipient: approver.Public(),
|
|
Action: "admin",
|
|
Message: "approve",
|
|
CreatedAt: now,
|
|
ExpiresAt: now + 30,
|
|
Nonce: bytes.Repeat([]byte{0x11}, tce.NonceSize),
|
|
}
|
|
reqTCE, _ := protocol.EncodeApprovalRequest(req)
|
|
reqID := tce.ComputeID(reqTCE).String()
|
|
if _, err := sPut(ts, reqTCE, issuer.Sign(reqTCE)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp := &protocol.ApprovalResponse{
|
|
RequestHash: tce.ComputeID(reqTCE),
|
|
Responder: approver.Public(),
|
|
Decision: protocol.Allow,
|
|
CreatedAt: now + 5,
|
|
Nonce: bytes.Repeat([]byte{0x22}, tce.NonceSize),
|
|
}
|
|
respTCE, _ := protocol.EncodeApprovalResponse(resp)
|
|
if _, err := sPut(ts, respTCE, approver.Sign(respTCE)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
|
|
CreatedAt: now - 10,
|
|
Serial: 1,
|
|
Nonce: bytes.Repeat([]byte{0x33}, tce.NonceSize),
|
|
}
|
|
claimTCE, _ := protocol.EncodeClaim(c)
|
|
claimID, err := sPut(ts, claimTCE, issuer.Sign(claimTCE))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rv := &protocol.Revocation{
|
|
Issuer: issuer.Public(),
|
|
ClaimID: tce.ComputeID(claimTCE),
|
|
Reason: "bad",
|
|
CreatedAt: now,
|
|
Nonce: bytes.Repeat([]byte{0x44}, tce.NonceSize),
|
|
}
|
|
rvTCE, _ := protocol.EncodeRevocation(rv)
|
|
if _, err := sPut(ts, rvTCE, issuer.Sign(rvTCE)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Authenticate with a generic read token.
|
|
tok := authToken(t, ts.URL, subject)
|
|
|
|
check := func(ep string, want int) []json.RawMessage {
|
|
r, _ := http.NewRequest(http.MethodGet, ts.URL+ep, nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
resp, _ := http.DefaultClient.Do(r)
|
|
if resp.StatusCode != want {
|
|
t.Fatalf("%s: expected %d, got %d", ep, want, resp.StatusCode)
|
|
}
|
|
var wrap struct {
|
|
Requests []json.RawMessage `json:"requests"`
|
|
Responses []json.RawMessage `json:"responses"`
|
|
Revocations []json.RawMessage `json:"revocations"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&wrap)
|
|
resp.Body.Close()
|
|
switch {
|
|
case wrap.Requests != nil:
|
|
return wrap.Requests
|
|
case wrap.Responses != nil:
|
|
return wrap.Responses
|
|
case wrap.Revocations != nil:
|
|
return wrap.Revocations
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if got := check("/v1/requests?recipient="+approver.Address().String(), http.StatusOK); len(got) != 1 {
|
|
t.Fatalf("requests: expected 1, got %d", len(got))
|
|
}
|
|
if got := check("/v1/responses?request="+reqID, http.StatusOK); len(got) != 1 {
|
|
t.Fatalf("responses: expected 1, got %d", len(got))
|
|
}
|
|
if got := check("/v1/revocations?claim="+claimID, http.StatusOK); len(got) != 1 {
|
|
t.Fatalf("revocations: expected 1, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
// sPut publishes a raw envelope to the relay.
|
|
func sPut(ts *httptest.Server, tceBytes, sig []byte) (string, error) {
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(&transport.Envelope{TCE: tceBytes, Signature: sig})
|
|
resp, err := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return "", fmt.Errorf("put status %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
ObjectID string `json:"object_id"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
resp.Body.Close()
|
|
return out.ObjectID, nil
|
|
}
|
|
|
|
func TestPutStoresUnverifiedEnvelope(t *testing.T) {
|
|
// The relay is a dumb store: it accepts any well-formed envelope regardless
|
|
// of signature validity. Signature checking is deferred to the verifier
|
|
// (verify.Graph.Add), which is what decides "should I believe it".
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: now - 10,
|
|
Serial: 1,
|
|
Nonce: make([]byte, tce.NonceSize),
|
|
}
|
|
cb, _ := protocol.EncodeClaim(c)
|
|
sig := issuer.Sign(cb)
|
|
// Tamper with the signature so verification must fail downstream.
|
|
bad := make([]byte, len(sig))
|
|
copy(bad, sig)
|
|
bad[len(bad)-1] ^= 0xff
|
|
|
|
env := &transport.Envelope{TCE: cb, Signature: bad}
|
|
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("relay should store unverified envelope, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// The verifier must reject the tampered envelope.
|
|
if err := verify.NewGraph().Add(env); err == nil {
|
|
t.Fatal("expected verify.Graph.Add to reject tampered signature")
|
|
}
|
|
}
|
|
|
|
func TestIdempotentPut(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: now - 10,
|
|
Serial: 1,
|
|
Nonce: make([]byte, tce.NonceSize),
|
|
}
|
|
cb, _ := protocol.EncodeClaim(c)
|
|
sig := issuer.Sign(cb)
|
|
|
|
id1, err := sPut(ts, cb, sig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id2, err := sPut(ts, cb, sig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id1 != id2 {
|
|
t.Fatalf("expected identical object id on replay, got %q and %q", id1, id2)
|
|
}
|
|
|
|
// A replayed claim must not create a duplicate: only one claim for subject.
|
|
tok := authToken(t, ts.URL, subject)
|
|
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
resp, _ := http.DefaultClient.Do(r)
|
|
var list struct {
|
|
Claims []json.RawMessage `json:"claims"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&list)
|
|
resp.Body.Close()
|
|
if len(list.Claims) != 1 {
|
|
t.Fatalf("expected exactly 1 stored claim after replay, got %d", len(list.Claims))
|
|
}
|
|
}
|
|
|
|
func TestHealthMetricsEndpoints(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
for _, ep := range []string{"/v1/healthz", "/v1/readyz"} {
|
|
resp, _ := http.Get(ts.URL + ep)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s: expected 200, got %d", ep, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
resp, _ := http.Get(ts.URL + "/v1/metrics")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("metrics: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if !strings.Contains(string(body), "trust_objects_stored") {
|
|
t.Fatalf("metrics missing objects_stored: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestPagination(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
|
|
// Publish three distinct claims about the same subject.
|
|
for i := 0; i < 3; i++ {
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
|
|
CreatedAt: now - 10,
|
|
Serial: uint64(i + 1),
|
|
Nonce: bytes.Repeat([]byte{byte(i + 1)}, tce.NonceSize),
|
|
}
|
|
cb, _ := protocol.EncodeClaim(c)
|
|
if _, err := sPut(ts, cb, issuer.Sign(cb)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
tok := authToken(t, ts.URL, subject)
|
|
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String()+"&limit=1&offset=1", nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
resp, _ := http.DefaultClient.Do(r)
|
|
var list struct {
|
|
Claims []json.RawMessage `json:"claims"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&list)
|
|
resp.Body.Close()
|
|
if len(list.Claims) != 1 {
|
|
t.Fatalf("expected 1 paginated claim, got %d", len(list.Claims))
|
|
}
|
|
}
|
|
|
|
func TestPerSubjectQuota(t *testing.T) {
|
|
// Exercise handlePut directly (bypassing the HTTP rate limiter) so we can
|
|
// publish enough distinct claims to reach the per-subject quota.
|
|
srv := server.New("trust.n1ko.dev", "")
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
|
|
const n = 1000
|
|
for i := 0; i < n; i++ {
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
|
|
CreatedAt: now - 10,
|
|
Serial: uint64(i + 1),
|
|
Nonce: bytes.Repeat([]byte{byte((i + 1) & 0xff)}, tce.NonceSize),
|
|
}
|
|
cb, _ := protocol.EncodeClaim(c)
|
|
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(env)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
|
|
srv.HandlePut(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("claim %d: expected 200, got %d", i, rec.Code)
|
|
}
|
|
}
|
|
// The next distinct claim exceeds the per-subject quota.
|
|
over := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Number("9999")},
|
|
CreatedAt: now - 10,
|
|
Serial: 9999,
|
|
Nonce: bytes.Repeat([]byte{0xee}, tce.NonceSize),
|
|
}
|
|
ob, _ := protocol.EncodeClaim(over)
|
|
env := &transport.Envelope{TCE: ob, Signature: issuer.Sign(ob)}
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(env)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
|
|
srv.HandlePut(rec, req)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("expected 422 for quota exceeded, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestOneResponsePerRequest(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
approver, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
|
|
req := &protocol.ApprovalRequest{
|
|
Sender: issuer.Public(),
|
|
Recipient: approver.Public(),
|
|
Action: "admin",
|
|
CreatedAt: now,
|
|
ExpiresAt: now + 30,
|
|
Nonce: make([]byte, tce.NonceSize),
|
|
}
|
|
reqb, _ := protocol.EncodeApprovalRequest(req)
|
|
|
|
resp1 := &protocol.ApprovalResponse{
|
|
RequestHash: tce.ComputeID(reqb),
|
|
Responder: approver.Public(),
|
|
Decision: protocol.Allow,
|
|
CreatedAt: now + 5,
|
|
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
|
|
}
|
|
r1, _ := protocol.EncodeApprovalResponse(resp1)
|
|
if _, err := sPut(ts, r1, approver.Sign(r1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp2 := &protocol.ApprovalResponse{
|
|
RequestHash: tce.ComputeID(reqb),
|
|
Responder: approver.Public(),
|
|
Decision: protocol.Allow,
|
|
CreatedAt: now + 6,
|
|
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
|
|
}
|
|
r2, _ := protocol.EncodeApprovalResponse(resp2)
|
|
if _, err := sPut(ts, r2, approver.Sign(r2)); err == nil {
|
|
t.Fatal("expected second response to same request to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestRateLimit(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
issuer, _ := signer.Generate()
|
|
subject, _ := signer.Generate()
|
|
now := uint64(time.Now().Unix())
|
|
c := &protocol.Claim{
|
|
Issuer: issuer.Public(),
|
|
Subject: subject.Public(),
|
|
Claims: map[string]tce.Value{"x": tce.Bool(true)},
|
|
CreatedAt: now - 10,
|
|
Serial: 1,
|
|
Nonce: make([]byte, tce.NonceSize),
|
|
}
|
|
cb, _ := protocol.EncodeClaim(c)
|
|
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
|
|
|
|
got429 := false
|
|
for i := 0; i < 65; i++ {
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(env)
|
|
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
got429 = true
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
if !got429 {
|
|
t.Fatal("expected rate limit (429) after bursting PUT")
|
|
}
|
|
}
|
|
|
|
func TestRejectsOversizeBody(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
// An envelope whose base64 TCE exceeds the server body cap.
|
|
env := &transport.Envelope{TCE: bytes.Repeat([]byte{0xff}, 10000)}
|
|
var buf bytes.Buffer
|
|
_ = json.NewEncoder(&buf).Encode(env)
|
|
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("expected 413, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|