niko_trust/internal/protocol/vectors_test.go
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

238 lines
7.5 KiB
Go

package protocol_test
import (
"encoding/hex"
"encoding/json"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Golden tests against the frozen reference vectors.
//
// These are the heart of cross-implementation agreement: the Go code must
// reproduce every byte of the Python reference implementation's output, must
// verify every reference signature, and must assign every object the same
// SHA-256 object ID. Any disagreement here means the Go implementation is
// wrong, not the vectors (docs/PROTOCOL.md section 14).
type vectorJSON struct {
Type string `json:"type"`
}
// jsonString and jsonUint pull fields out of the JSON view for comparison.
func jsonString(t *testing.T, raw json.RawMessage, key string) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("json view: %v", err)
}
s, _ := m[key].(string)
return s
}
func jsonNonce(t *testing.T, raw json.RawMessage) string {
return jsonString(t, raw, "nonce")
}
// reencodeAndCompare decodes one object, encodes its public fields back, and
// requires the result to equal the received bytes byte for byte. This is the
// cross-implementation identity: the encoder and the Python reference must
// agree, and the decoder must have read exactly the reference's field order.
func reencodeAndCompare(t *testing.T, label string, got []byte, want []byte) {
t.Helper()
if !eqBytes(got, want) {
t.Fatalf("%s: encoder did not reproduce the reference bytes\n got %x\nwant %x", label, got, want)
}
}
func eqBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestVectorPartiesDerivedFromSeeds(t *testing.T) {
vf := loadVectors(t)
if len(vf.Parties) != 2 {
t.Fatalf("expect exactly two parties, got %d", len(vf.Parties))
}
for name, p := range vf.Parties {
seed := mustHex(t, p.SeedHex)
s := seedSigner(t, seed)
if hex.EncodeToString(s.Public()) != p.PubkeyHex {
t.Errorf("%s: public key does not match the reference", name)
}
if s.Address().String() != p.Address {
t.Errorf("%s: address does not match the reference", name)
}
}
}
func TestVectorGolden(t *testing.T) {
vf := loadVectors(t)
if len(vf.Vectors) != 9 {
t.Fatalf("expect 9 object vectors, got %d", len(vf.Vectors))
}
byName := map[string]vectorEntry{}
for _, v := range vf.Vectors {
byName[v.Name] = v
}
claimBool := byName["claim/boolean"]
req := byName["approval_request/ban"]
var revClaim *protocol.Claim
var rev *protocol.Revocation
for _, v := range vf.Vectors {
t.Run(v.Name, func(t *testing.T) {
tceBytes := mustHex(t, v.TCEHex)
if len(tceBytes) != v.TCELen {
t.Fatalf("tce_len: field says %d, hex has %d", v.TCELen, len(tceBytes))
}
sig := mustHex(t, v.SignatureHex)
if got, want := hex.EncodeToString(tce.ComputeID(tceBytes).Bytes()), v.ObjectIDHex; got != want {
t.Fatalf("object ID mismatch\n got %s\nwant %s", got, want)
}
var typ vectorJSON
if err := json.Unmarshal(v.JSON, &typ); err != nil {
t.Fatal(err)
}
switch typ.Type {
case "identity":
obj, err := protocol.VerifyIdentity(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyIdentity: %v", err)
}
reencodeAndCompare(t, "identity", mustTCE(t, obj), tceBytes)
if obj.Alias != jsonString(t, v.JSON, "alias") {
t.Errorf("alias decoded as %q", obj.Alias)
}
if obj.CreatedAt != 1_700_000_000 {
t.Errorf("created_at = %d", obj.CreatedAt)
}
checkAddress(t, obj.PubKey, v.SignerAddress)
case "claim":
obj, err := protocol.VerifyClaim(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyClaim: %v", err)
}
reencodeAndCompare(t, "claim", mustTCE(t, obj), tceBytes)
if len(obj.Claims) < 1 || len(obj.Claims) > 32 {
t.Errorf("claims size %d out of bounds", len(obj.Claims))
}
checkAddress(t, obj.Issuer, v.SignerAddress)
if hex.EncodeToString(obj.Nonce) != jsonNonce(t, v.JSON) {
t.Errorf("nonce mismatch")
}
// The two response vectors and the revocation reference this
// claim by ID; capture the verified claim for later checks.
if v.Name == "claim/boolean" {
revClaim = obj
}
case "revocation":
obj, err := protocol.VerifyRevocation(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyRevocation: %v", err)
}
reencodeAndCompare(t, "revocation", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Issuer, v.SignerAddress)
rev = obj
// The frozen revocation withdraws claim/boolean by ID; verify
// the binding passes against the decoded claim.
if claimBool.ObjectIDHex != "" && obj.ClaimID.String() != claimBool.ObjectIDHex {
t.Errorf("revocation targets %s, want the boolean claim", obj.ClaimID)
}
case "approval_request":
obj, err := protocol.VerifyApprovalRequest(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyApprovalRequest: %v", err)
}
reencodeAndCompare(t, "approval_request", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Sender, v.SignerAddress)
if obj.ExpiresAt-obj.CreatedAt > 60 {
t.Errorf("request lifetime %d exceeds the format bound", obj.ExpiresAt-obj.CreatedAt)
}
case "approval_response":
reqBytes := mustHex(t, req.TCEHex)
reqSig := mustHex(t, req.SignatureHex)
obj, err := protocol.VerifyApprovalResponse(reqBytes, reqSig, tceBytes, sig)
if err != nil {
t.Fatalf("VerifyApprovalResponse: %v", err)
}
reencodeAndCompare(t, "approval_response", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Responder, v.SignerAddress)
if jsonString(t, v.JSON, "decision") != obj.Decision.String() {
t.Errorf("decision field mismatch")
}
// request_hash is the object ID of the exact request.
if obj.RequestHash.String() != req.ObjectIDHex {
t.Errorf("request_hash does not match the request object ID")
}
case "auth_assertion":
obj, err := protocol.VerifyAuthAssertion(tceBytes, sig, "trust.n1ko.dev")
if err != nil {
t.Fatalf("VerifyAuthAssertion: %v", err)
}
reencodeAndCompare(t, "auth_assertion", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.PubKey, v.SignerAddress)
if jsonString(t, v.JSON, "challenge") != hex.EncodeToString(obj.Challenge) {
t.Errorf("challenge mismatch")
}
default:
t.Fatalf("unknown json type %q", typ.Type)
}
})
}
// Cross-object bindings that only become checkable once every vector is
// verified.
t.Run("revocation withdraws the boolean claim", func(t *testing.T) {
if revClaim == nil || rev == nil {
t.Fatal("precondition: claim/boolean and revocation not both decoded")
}
if err := protocol.VerifyRevocationOf(rev, revClaim); err != nil {
t.Fatalf("VerifyRevocationOf: %v", err)
}
})
}
// mustTCE returns the verified object's canonical bytes.
func mustTCE(t *testing.T, obj interface{ TCE() []byte }) []byte {
t.Helper()
return obj.TCE()
}
// checkAddress asserts that the decoded key renders as the reference address.
func checkAddress(t *testing.T, pub []byte, wantAddr string) {
t.Helper()
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatalf("decoded key is not a valid address: %v", err)
}
if a.String() != wantAddr {
t.Fatalf("address %s, want %s", a.String(), wantAddr)
}
}
// TestVectorSignatureMutation is the signature half of the cross-check: a
// signature that verifies over the unchanged bytes must fail over any
// mutation of the TCE bytes (see mutation_test.go for the byte-by-byte
// sweep).