From 3d00a39ae7d2040a993fbdcb7e77a8cd32014d36 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:29:36 +0530 Subject: [PATCH 1/2] fix(receipts): enforce manifest snapshot bounds Reject out-of-window manifest signers and inconsistent range metadata. Pin export signing to its snapshot across key rotation. Accept authenticated empty exports without changing v1 bytes. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/agentgate-verify/receiptspec_test.go | 60 +++++++ docs/spec/RECEIPT-v1.md | 84 +++++++-- internal/receipt/export_handler.go | 132 +++++++------- internal/receipt/export_handler_test.go | 71 ++++++++ pkg/receiptspec/manifest.go | 26 ++- pkg/receiptspec/manifest_test.go | 67 +++++++ .../testdata/v1/encoding-lock.json | 32 +++- pkg/receiptspec/testdata/v1/gen/main.go | 53 +++++- .../testdata/v1/manifest-count-mismatch.jsonl | 6 + .../v1/manifest-empty-endpoint-mismatch.jsonl | 3 + .../manifest-first-entry-hash-mismatch.jsonl | 6 + .../v1/manifest-head-hash-mismatch.jsonl | 6 + .../testdata/v1/manifest-negative-count.jsonl | 6 + .../manifest-retired-signer-full-range.jsonl | 6 + .../testdata/v1/manifest-reversed-range.jsonl | 6 + .../v1/manifest-signer-before-window.jsonl | 4 + .../testdata/v1/valid-empty-anchor.jsonl | 3 + .../testdata/v1/valid-empty-genesis.jsonl | 3 + .../testdata/v1/valid-historical-range.jsonl | 4 + .../v1/valid-retired-at-boundary.jsonl | 5 + .../valid-rotation-before-first-receipt.jsonl | 5 + .../testdata/v1/valid-rotation-straddle.jsonl | 6 + pkg/receiptspec/testdata/v1/vectors.json | 164 +++++++++++++++++- pkg/receiptspec/verifier.go | 58 +++++-- 24 files changed, 715 insertions(+), 101 deletions(-) create mode 100644 pkg/receiptspec/manifest_test.go create mode 100644 pkg/receiptspec/testdata/v1/manifest-count-mismatch.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-empty-endpoint-mismatch.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-first-entry-hash-mismatch.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-head-hash-mismatch.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-negative-count.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-retired-signer-full-range.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-reversed-range.jsonl create mode 100644 pkg/receiptspec/testdata/v1/manifest-signer-before-window.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-empty-anchor.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-empty-genesis.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-historical-range.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-retired-at-boundary.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-rotation-before-first-receipt.jsonl create mode 100644 pkg/receiptspec/testdata/v1/valid-rotation-straddle.jsonl diff --git a/cmd/agentgate-verify/receiptspec_test.go b/cmd/agentgate-verify/receiptspec_test.go index 5ce613f..6035382 100644 --- a/cmd/agentgate-verify/receiptspec_test.go +++ b/cmd/agentgate-verify/receiptspec_test.go @@ -12,11 +12,71 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) +func TestRunManifestGoldenVectors(t *testing.T) { + dir := filepath.Join("..", "..", "pkg", "receiptspec", "testdata", "v1") + data, err := os.ReadFile(filepath.Join(dir, "vectors.json")) + if err != nil { + t.Fatal(err) + } + var catalog struct { + Vectors []struct { + Name string `json:"name"` + JSONL string `json:"jsonl"` + Keys string `json:"keys"` + Expected struct { + Class string `json:"class"` + Complete bool `json:"complete"` + } `json:"expected"` + } `json:"vectors"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + for _, v := range catalog.Vectors { + if v.Expected.Class != "ok" && !strings.HasPrefix(v.Expected.Class, "manifest_") { + continue + } + t.Run(v.Name, func(t *testing.T) { + for _, explicitTrust := range []bool{false, true} { + for _, format := range []string{"text", "json"} { + args := []string{"--source", "jsonl", "--path", filepath.Join(dir, v.JSONL), "--format", format} + if explicitTrust { + args = append(args, "--trust-root", filepath.Join(dir, v.Keys)) + } + var stdout, stderr bytes.Buffer + code := run(args, &stdout, &stderr) + wantCode := 1 + if v.Expected.Class == "ok" { + wantCode = 0 + } + if code != wantCode { + t.Fatalf("trust=%v format=%s: exit %d, want %d; stdout=%s stderr=%s", + explicitTrust, format, code, wantCode, stdout.String(), stderr.String()) + } + if format == "json" { + var got jsonResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.OK != (wantCode == 0) || got.Complete != v.Expected.Complete || + (wantCode == 1 && got.Reason != v.Expected.Class) { + t.Fatalf("trust=%v: result %+v, want %+v", explicitTrust, got, v.Expected) + } + } else if wantCode == 1 && !strings.Contains(stderr.String(), "reason="+v.Expected.Class) { + t.Fatalf("missing reason in stderr: %s", stderr.String()) + } + } + } + }) + } +} + // A second producer that only uses pkg/receiptspec must verify with this command. func TestRunVerifiesReceiptspecChain(t *testing.T) { priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize)) diff --git a/docs/spec/RECEIPT-v1.md b/docs/spec/RECEIPT-v1.md index 94c904b..2fe47c9 100644 --- a/docs/spec/RECEIPT-v1.md +++ b/docs/spec/RECEIPT-v1.md @@ -387,7 +387,9 @@ The signature itself is excluded from the preimage. `ComputeManifestHash` does not validate manifest semantics. Direct calls to `VerifyManifest` do not check the version. JSONL manifest parsing requires `format_version = 1`. -No dedicated manifest string-length, positive-count, or range-consistency checks exist. +Manifest counts may be zero, but cannot be negative. +Verification enforces the range and key-window rules in section 9.1. +No dedicated manifest string-length checks exist. Manifest JSON members appear in this order: @@ -423,7 +425,11 @@ For an empty selection, both equal the anchor hash. `head_seq` and `head_hash` describe the snapshot head. The keyset includes all stored signer keys. The active signer signs the manifest after the read transaction finishes. -The endpoint can emit an empty selection, but `VerifyChain` rejects an empty chain. +A signer lease covers snapshot acquisition through signing, excluding concurrent appends and rotation. +The lease is released before writing the HTTP response. +The endpoint can emit an empty selection at `from = head + 1`. +`VerifyBundle` accepts that signed empty range when its endpoint equals its anchor. +Bare empty chains remain invalid. ## 9. Verification algorithm and error classes @@ -438,19 +444,46 @@ The endpoint can emit an empty selection, but `VerifyChain` rejects an empty cha 5. Reject invalid key length or signature with `ManifestError` wrapping `ErrManifestSignatureInvalid`. 6. Recompute the embedded keyset digest. 7. Reject inequality with `ManifestError` wrapping `ErrKeysetDigestMismatch`. -8. Use the signed manifest's anchor. -9. Unless explicitly overridden, expect `(resolved_to, last_entry_hash)` as the endpoint. -10. Run the chain checks below. - -Manifest signing keys are not checked against sequence validity windows. +8. Require `valid_from_seq <= head_seq + 1`, evaluated without unsigned overflow. +9. Require `valid_until_seq` to be nil or at least `head_seq`. +10. Require `resolved_to <= head_seq`. +11. Require `resolved_to >= anchor_seq` and nonnegative `count == resolved_to - anchor_seq`. +12. When `resolved_to == head_seq`, require `head_hash == last_entry_hash`. +13. Use the signed manifest's anchor. +14. Unless explicitly overridden, expect `(resolved_to, last_entry_hash)` as the endpoint. +15. For zero receipts and zero count, require the manifest endpoint to equal the anchor. +16. Check that empty anchor against any explicit expected head. Otherwise run the chain checks below. +17. After successful chain checks, require `first_entry_hash` to match the first receipt's entry hash when `count > 0`. + +Rotation activates the new key at the committed head plus 1. +An export can use that key before its first receipt exists. +That is why the lower bound permits `head_seq + 1`, rather than requiring `valid_from_seq <= head_seq`. +Historical ranges can start before the manifest signer's window. Do not compare its lower bound with the export anchor. +The upper bound is inclusive, matching receipt key windows. +An archived export can therefore verify after its signer retires, provided its snapshot head remains within that window. Explicit trust replaces embedded keys for lookup, but embedded keys still determine the signed keyset digest. Embedded keys alone prove internal consistency, not the producer's identity. Pin an independently obtained trust file when identity matters. -The verifier does not separately check manifest `count`, `requested_from`, `requested_to`, or `first_entry_hash`. -It does not compare `head_seq` or `head_hash` against receipts. -Those fields are signed but are not independently enforced. +Manifest consistency failures return `OK = false`, a stable reason, and no Go error. + +| Check | `VerifyResult.Reason` | `FailedAtSeq` | +|---|---|---| +| Signer outside snapshot window | `manifest_signer_inactive_at_head` | `head_seq` | +| Endpoint beyond snapshot head | `manifest_resolved_to_beyond_head` | `resolved_to` | +| Negative count, reversed range, or count mismatch | `manifest_count_mismatch` | `resolved_to` | +| Full-range head hash differs | `manifest_head_hash_mismatch` | `head_seq` | +| First receipt hash differs | `manifest_first_entry_hash_mismatch` | First receipt sequence | + +Metadata checks precede chain checks, except the first receipt hash comparison follows successful chain verification. +The first receipt hash failure retains the chain's verified count and clears `Complete`. +Other manifest consistency failures have zero verified receipts. +The verifier does not separately check `requested_from` or `requested_to`. An explicit expected head replaces the manifest-derived endpoint, rather than adding another endpoint comparison. +An empty export must still match its own anchor, even with an explicit expected head. +Key windows cannot prove freshness against a signer's self-selected historical head. +A retired key can still authenticate its historical window. +Pin an independent expected head when freshness matters. ### 9.2 Chain checks @@ -486,6 +519,7 @@ The first failing check determines the result: `VerifiedCount` counts only fully checked receipts. Unknown signer errors return a zero result, discarding earlier progress. `Complete` is true only when a supplied or manifest-derived endpoint matches. +For authenticated empty ranges, the final verified head is the anchor. It means completeness against that endpoint, not against unseen future receipts. Without an endpoint, a valid prefix returns `OK = true` and `Complete = false`. `VerifyReceipt` checks fields, entry hash, and signature only. @@ -495,7 +529,7 @@ The CLI exits `0` for success, `1` for mismatches, and `2` for parsing or config All `ManifestError` values exit `1`, including an unknown manifest signer. An unknown receipt signer exits `2`. CLI range reporting uses `resolved_to == head_seq` to label an export `full`. -That label does not add a head-hash comparison. +Successful full-range verification also requires the signed head hash to match the endpoint hash. ## 10. Worked bytes @@ -600,7 +634,13 @@ Tests exercise both explicit and embedded trust. | `valid-partial` | `ok`, signed non-genesis anchor | | `valid-boundary` | `ok`, maximum sequence and string sizes, 32 delegation elements | | `valid-unpinned-prefix` | `ok`, deleted suffix cannot be detected without an endpoint | -| `valid-unchecked-manifest-count` | `ok`, signed count is not compared with receipts | +| `valid-unchecked-manifest-count` | `manifest_count_mismatch`, historical fixture now rejected | +| `valid-rotation-straddle` | `ok`, old and new receipt keys, new manifest signer | +| `valid-rotation-before-first-receipt` | `ok`, new signer starts at snapshot head plus 1 | +| `valid-historical-range` | `ok`, range starts before the manifest signer's window | +| `valid-retired-at-boundary` | `ok`, inclusive retired-key upper bound equals snapshot head | +| `valid-empty-genesis` | `ok`, zero count and genesis anchor | +| `valid-empty-anchor` | `ok`, zero count and non-genesis anchor | | `tampered-body` | `entry_hash_mismatch` | | `reordered-chain` | `sequence_gap_or_not_genesis_anchored` | | `missing-middle` | `sequence_gap_or_not_genesis_anchored` | @@ -614,7 +654,15 @@ Tests exercise both explicit and embedded trust. | `manifest-mismatch` | `ManifestError/ErrManifestSignatureInvalid` | | `manifest-keyset-mismatch` | `ManifestError/ErrKeysetDigestMismatch` | | `manifest-unknown-signer` | `ManifestError/ErrUnknownSignerKID` | -| `manifest-endpoint-mismatch` | `expected_head_mismatch` | +| `manifest-endpoint-mismatch` | `manifest_resolved_to_beyond_head` | +| `manifest-retired-signer-full-range` | `manifest_signer_inactive_at_head` | +| `manifest-signer-before-window` | `manifest_signer_inactive_at_head` | +| `manifest-count-mismatch` | `manifest_count_mismatch` | +| `manifest-negative-count` | `manifest_count_mismatch` | +| `manifest-reversed-range` | `manifest_count_mismatch` | +| `manifest-first-entry-hash-mismatch` | `manifest_first_entry_hash_mismatch` | +| `manifest-head-hash-mismatch` | `manifest_head_hash_mismatch` | +| `manifest-empty-endpoint-mismatch` | `expected_head_mismatch` | Seeds are public, fixed, test-only values. Never deploy these keys. Seed A contains bytes `00` through `1f`. Seed B contains `20` through `3f`. @@ -622,6 +670,8 @@ The generator writes deterministic files and does not update `encoding-lock.json That separate file pins SHA-256 digests of canonical receipt and manifest preimages for every accepted vector. `TestV1VersionLock` compares real encoding against those committed digests. Do not refresh the v1 lock to hide an encoding change. +New accepted vectors add lock entries. Rejected vectors no longer need accepted-vector locks. +Existing accepted-vector hashes and the version-lock test remain unchanged. The older independent binary fixture remains in `internal/receipt/testdata/v1`. Its filename includes `genesis`, but its encoded sequence is `MaxUint64`, not 1. @@ -634,7 +684,7 @@ Missing JSON versions decode to 0 and are rejected. The receipt binary preimage selects v1 through `agentgate.receipt.hash.v1`. The manifest preimage includes both its v1 domain and its integer version. -Keep v1 byte encodings and accepted semantics frozen. +Keep v1 byte encodings and receipt field semantics frozen. Changes to field order, widths, endianness, prefixes, or domain strings require v2. Changing signed fields, hash algorithms, signature algorithms, or signature messages requires v2. Changing params canonicalization or delegation digest meaning requires v2. @@ -646,6 +696,10 @@ Do not claim that newly emitted values are interoperable unless old v1 readers a A v2 implementation needs a new format version, separate domain strings, and `RECEIPT-v2.md`. Preserve v1 vectors and verification support. +Tightening manifest verification is outside the wire freeze. +These checks enforce existing signed fields and key windows without changing their encoding. +Accepting gateway-produced empty exports also changes no bytes. + ## 13. Existing documentation differences Code takes precedence for this freeze. @@ -654,8 +708,6 @@ No discrepancy below changes production behavior. - `DECISION.md` calls decision records objects. `CanonicalRecord` also accepts arrays and scalar roots. - `DECISION.md` names a canonical-size limit. The code also limits raw input to 1 MiB. - The old `genesis-unicode-max.bin` fixture contains sequence `MaxUint64`, not a genesis chain. -- Manifest comments describe bound metadata. Verification enforces the anchor, endpoint, signature, and keyset digest only. -- The export endpoint can emit an empty range. The verifier rejects it with `ErrEmptyChain`. - Gateway delegation output is 64-character hex. Receipt validation accepts shorter nonhex ASCII elements. The stream-digest proposal correctly describes an unimplemented v2 feature. diff --git a/internal/receipt/export_handler.go b/internal/receipt/export_handler.go index 27eb962..94cc065 100644 --- a/internal/receipt/export_handler.go +++ b/internal/receipt/export_handler.go @@ -7,6 +7,7 @@ package receipt import ( "context" + "crypto/ed25519" "database/sql" "encoding/json" "errors" @@ -79,79 +80,86 @@ func ExportHandler(db *sql.DB, signerStore *signer.Store) http.HandlerFunc { return } - ctx := r.Context() - tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - writeExportError(w, http.StatusInternalServerError, "snapshot failed") - return - } - defer tx.Rollback() + var manifest ExportManifest + var receipts []Receipt + var keys []TrustedKey + status := http.StatusInternalServerError + snapshotStarted := false + // Acquire the signer lease before SQLite, matching the ledger's lock order. + // Release it before writing the response to avoid blocking rotation on network I/O. + err := signerStore.WithActiveKey(1, func(activeKey signer.KeyRecord, priv ed25519.PrivateKey) error { + snapshotStarted = true + ctx := r.Context() + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return errors.New("snapshot failed") + } + defer tx.Rollback() - headSeq, headHash, err := readHead(ctx, tx) - if err != nil { - writeExportError(w, http.StatusInternalServerError, "read head failed") - return - } + headSeq, headHash, err := readHead(ctx, tx) + if err != nil { + return errors.New("read head failed") + } - resolvedTo, errMsg, ok := resolveExportRange(from, requestedTo, hasTo, headSeq) - if !ok { - writeExportError(w, http.StatusBadRequest, errMsg) - return - } + resolvedTo, errMsg, ok := resolveExportRange(from, requestedTo, hasTo, headSeq) + if !ok { + status = http.StatusBadRequest + return errors.New(errMsg) + } - anchorSeq := from - 1 - var anchorHash [32]byte - if anchorSeq > 0 { - anchorHash, err = queryEntryHashAt(ctx, tx, anchorSeq) - if err != nil { - writeExportError(w, http.StatusInternalServerError, "read anchor failed") - return + anchorSeq := from - 1 + var anchorHash [32]byte + if anchorSeq > 0 { + anchorHash, err = queryEntryHashAt(ctx, tx, anchorSeq) + if err != nil { + return errors.New("read anchor failed") + } } - } - receipts, err := queryReceiptRange(ctx, tx, from, resolvedTo) - if err != nil { - writeExportError(w, http.StatusInternalServerError, "read receipts failed") - return - } - keys, err := queryAllSignerKeys(ctx, tx) - if err != nil { - writeExportError(w, http.StatusInternalServerError, "read keys failed") - return - } - if err := tx.Commit(); err != nil { - writeExportError(w, http.StatusInternalServerError, "snapshot commit failed") - return - } + receipts, err = queryReceiptRange(ctx, tx, from, resolvedTo) + if err != nil { + return errors.New("read receipts failed") + } + keys, err = queryAllSignerKeys(ctx, tx) + if err != nil { + return errors.New("read keys failed") + } + if err := tx.Commit(); err != nil { + return errors.New("snapshot commit failed") + } - firstHash, lastHash := anchorHash, anchorHash - if len(receipts) > 0 { - firstHash = receipts[0].EntryHash - lastHash = receipts[len(receipts)-1].EntryHash - } + firstHash, lastHash := anchorHash, anchorHash + if len(receipts) > 0 { + firstHash = receipts[0].EntryHash + lastHash = receipts[len(receipts)-1].EntryHash + } - manifest := ExportManifest{ - FormatVersion: ExportFormatVersion, - RequestedFrom: from, - RequestedTo: requestedTo, - ResolvedTo: resolvedTo, - Count: len(receipts), - AnchorSeq: anchorSeq, - AnchorHash: anchorHash, - FirstEntryHash: firstHash, - LastEntryHash: lastHash, - HeadSeq: headSeq, - HeadHash: headHash, - KeysetDigest: ComputeKeysetDigest(keys), - } + manifest = ExportManifest{ + FormatVersion: ExportFormatVersion, + RequestedFrom: from, + RequestedTo: requestedTo, + ResolvedTo: resolvedTo, + Count: len(receipts), + AnchorSeq: anchorSeq, + AnchorHash: anchorHash, + FirstEntryHash: firstHash, + LastEntryHash: lastHash, + HeadSeq: headSeq, + HeadHash: headHash, + KeysetDigest: ComputeKeysetDigest(keys), + } - activeKey, priv, err := signerStore.LoadOrCreateActive(1) + manifest = SignManifest(manifest, activeKey.KID, priv) + return nil + }) if err != nil { - writeExportError(w, http.StatusInternalServerError, "signing key unavailable") + message := "signing key unavailable" + if snapshotStarted { + message = err.Error() + } + writeExportError(w, status, message) return } - manifest = SignManifest(manifest, activeKey.KID, priv) - clear(priv) w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/internal/receipt/export_handler_test.go b/internal/receipt/export_handler_test.go index 58e6feb..f6c71ac 100644 --- a/internal/receipt/export_handler_test.go +++ b/internal/receipt/export_handler_test.go @@ -6,12 +6,83 @@ Licensed under the Apache License, Version 2.0. package receipt import ( + "fmt" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/Clawdlinux/agentgate/pkg/receiptspec" ) +func TestExportHandler_EmptyRangesVerifyOffline(t *testing.T) { + for _, count := range []int{0, 3} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + ledger, store := newTestLedgerAndStore(t) + all := appendN(t, ledger, count) + w := httptest.NewRecorder() + ExportHandler(ledger.db, store)(w, httptest.NewRequest("GET", + fmt.Sprintf("/v1/receipts/export?from=%d", count+1), nil)) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + bundle, err := receiptspec.ReadJSONL(strings.NewReader(w.Body.String())) + if err != nil { + t.Fatal(err) + } + result, err := receiptspec.VerifyBundle(bundle, nil, nil) + if err != nil || !result.OK || !result.Complete || result.VerifiedCount != 0 || result.HeadSeq != uint64(count) { + t.Fatalf("result=%+v err=%v", result, err) + } + if count > 0 && result.HeadEntryHash != all[count-1].EntryHash { + t.Fatal("empty export did not preserve the anchor hash") + } + }) + } +} + +func TestExportHandler_RotationSnapshotsVerifyOffline(t *testing.T) { + ledger, store := newTestLedgerAndStore(t) + appendN(t, ledger, 2) + done := make(chan error, 1) + go func() { + for i := 0; i < 64; i++ { + w := httptest.NewRecorder() + ExportHandler(ledger.db, store)(w, httptest.NewRequest("GET", "/v1/receipts/export?from=1", nil)) + if w.Code != http.StatusOK { + done <- fmt.Errorf("export status=%d body=%s", w.Code, w.Body.String()) + return + } + bundle, err := receiptspec.ReadJSONL(strings.NewReader(w.Body.String())) + if err != nil { + done <- err + return + } + result, err := receiptspec.VerifyBundle(bundle, nil, nil) + if err != nil || !result.OK || !result.Complete { + done <- fmt.Errorf("result=%+v err=%v", result, err) + return + } + } + done <- nil + }() + for i := 0; i < 32; i++ { + _, priv, err := store.Rotate(uint64(i + 3)) + clear(priv) + if err != nil { + t.Error(err) + break + } + if _, err := ledger.Append(t.Context(), testDraft("github", "after-rotation")); err != nil { + t.Error(err) + break + } + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + func TestExportHandler_FullRangeVerifiesOffline(t *testing.T) { t.Parallel() ledger, store := newTestLedgerAndStore(t) diff --git a/pkg/receiptspec/manifest.go b/pkg/receiptspec/manifest.go index c919468..4ab3f86 100644 --- a/pkg/receiptspec/manifest.go +++ b/pkg/receiptspec/manifest.go @@ -32,6 +32,13 @@ var ErrManifestSignatureInvalid = errors.New("receipt: export manifest signature // manifest's signed keyset_digest. var ErrKeysetDigestMismatch = errors.New("receipt: export manifest keyset digest mismatch") +type manifestCheckError struct { + reason string + seq uint64 +} + +func (e *manifestCheckError) Error() string { return e.reason } + // ExportManifest is the first line of a signed bounded export. It binds the // requested and resolved range, the anchor before the export, the first and // last exported entry hashes, the producer's head at snapshot time, and a @@ -110,7 +117,8 @@ func SignManifest(m ExportManifest, kid string, priv ed25519.PrivateKey) ExportM } // VerifyManifest checks m's signature against trustedKeys and that its -// KeysetDigest matches embeddedKeys. Every failure here is a tamper finding: +// KeysetDigest matches embeddedKeys. It also checks the snapshot signer window +// and range metadata. Every failure here is a tamper finding: // the manifest is what carries trust for the export. func VerifyManifest(m ExportManifest, trustedKeys []TrustedKey, embeddedKeys []TrustedKey) error { var key TrustedKey @@ -134,6 +142,22 @@ func VerifyManifest(m ExportManifest, trustedKeys []TrustedKey, embeddedKeys []T if ComputeKeysetDigest(embeddedKeys) != m.KeysetDigest { return ErrKeysetDigestMismatch } + // Rotation activates the new key at head+1, before its first receipt exists. + // The exported range can predate that key, so the anchor is not a lower bound. + // Subtraction avoids overflowing head+1 at the maximum wire sequence. + if (key.ValidFromSeq > m.HeadSeq && key.ValidFromSeq-m.HeadSeq > 1) || + (key.ValidUntilSeq != nil && *key.ValidUntilSeq < m.HeadSeq) { + return &manifestCheckError{reason: ReasonManifestSignerInactiveAtHead, seq: m.HeadSeq} + } + if m.ResolvedTo > m.HeadSeq { + return &manifestCheckError{reason: ReasonManifestResolvedToBeyondHead, seq: m.ResolvedTo} + } + if m.Count < 0 || m.ResolvedTo < m.AnchorSeq || uint64(m.Count) != m.ResolvedTo-m.AnchorSeq { + return &manifestCheckError{reason: ReasonManifestCountMismatch, seq: m.ResolvedTo} + } + if m.ResolvedTo == m.HeadSeq && m.HeadHash != m.LastEntryHash { + return &manifestCheckError{reason: ReasonManifestHeadHashMismatch, seq: m.HeadSeq} + } return nil } diff --git a/pkg/receiptspec/manifest_test.go b/pkg/receiptspec/manifest_test.go new file mode 100644 index 0000000..b030883 --- /dev/null +++ b/pkg/receiptspec/manifest_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 Clawdlinux. +Licensed under the Apache License, Version 2.0. +*/ + +package receiptspec + +import ( + "strings" + "testing" +) + +func TestEmptyExportExpectedHead(t *testing.T) { + for _, name := range []string{"valid-empty-genesis", "valid-empty-anchor", "manifest-empty-endpoint-mismatch"} { + t.Run(name, func(t *testing.T) { + bundle, err := ReadJSONL(strings.NewReader(string(readVectorFile(t, name+".jsonl")))) + if err != nil { + t.Fatal(err) + } + for _, mismatch := range []string{"none", "seq", "hash"} { + t.Run(mismatch, func(t *testing.T) { + expected := ExpectedHead{Seq: bundle.Manifest.AnchorSeq, EntryHash: bundle.Manifest.AnchorHash} + switch mismatch { + case "seq": + expected.Seq++ + case "hash": + expected.EntryHash[0] ^= 1 + } + result, err := VerifyBundle(bundle, nil, &expected) + wantOK := name != "manifest-empty-endpoint-mismatch" && mismatch == "none" + if err != nil || result.OK != wantOK || result.Complete != wantOK { + t.Fatalf("result=%+v err=%v wantOK=%v", result, err, wantOK) + } + if !wantOK && result.Reason != ReasonExpectedHeadMismatch { + t.Fatalf("reason=%s", result.Reason) + } + }) + } + }) + } +} + +func TestManifestChecksCannotBeOverridden(t *testing.T) { + for _, name := range []string{ + "manifest-retired-signer-full-range", "manifest-signer-before-window", + "manifest-count-mismatch", "manifest-negative-count", "manifest-reversed-range", + "manifest-endpoint-mismatch", "manifest-head-hash-mismatch", "manifest-first-entry-hash-mismatch", + } { + t.Run(name, func(t *testing.T) { + bundle, err := ReadJSONL(strings.NewReader(string(readVectorFile(t, name+".jsonl")))) + if err != nil { + t.Fatal(err) + } + last := bundle.Receipts[len(bundle.Receipts)-1] + result, err := VerifyBundle(bundle, nil, &ExpectedHead{Seq: last.Seq, EntryHash: last.EntryHash}) + if err != nil || result.OK || result.Complete || !strings.HasPrefix(result.Reason, "manifest_") { + t.Fatalf("result=%+v err=%v", result, err) + } + if name != "manifest-first-entry-hash-mismatch" { + err := VerifyManifest(*bundle.Manifest, bundle.Keys, bundle.Keys) + if err == nil || err.Error() != result.Reason { + t.Fatalf("VerifyManifest error=%v, want reason=%s", err, result.Reason) + } + } + }) + } +} diff --git a/pkg/receiptspec/testdata/v1/encoding-lock.json b/pkg/receiptspec/testdata/v1/encoding-lock.json index ef32eb3..b9fe184 100644 --- a/pkg/receiptspec/testdata/v1/encoding-lock.json +++ b/pkg/receiptspec/testdata/v1/encoding-lock.json @@ -25,13 +25,41 @@ ], "manifest": "74a034547f23baccf6b021c8e44bbcc72bd16c032ae649393bbbbc6ef1c3dc40" }, - "valid-unchecked-manifest-count": { + "valid-empty-anchor": { + "receipts": null, + "manifest": "1d540dc0de53bbfe8b423f29a18ecbb79767214d8e2197439046196225063bb1" + }, + "valid-empty-genesis": { + "receipts": null, + "manifest": "6313c1644f1c2580d95ba716498ba37203b0d18d42c3bc122fff26739d6f9c35" + }, + "valid-historical-range": { + "receipts": [ + "b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138" + ], + "manifest": "b9fa57bd4c246753df6ab306c4bdcf759ef8c80a906b584a463bf493b74c95a6" + }, + "valid-retired-at-boundary": { + "receipts": [ + "b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138", + "b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2" + ], + "manifest": "40c283695b8eb1d8f19be04875d1d175735a77e854394aed7edc65252fc758c0" + }, + "valid-rotation-before-first-receipt": { + "receipts": [ + "b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138", + "b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2" + ], + "manifest": "fd2d1a0c4af7f20c6a32b96865e16f2a863a685b8ebafe85a8cd847a4fd8c844" + }, + "valid-rotation-straddle": { "receipts": [ "b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138", "b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2", "559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695" ], - "manifest": "965801b7579b6d543114d768ea871ced95867776f73f612bdb3133855b9597e8" + "manifest": "8e8fb7b9943680a29e9ed35e93ad243d59e2b6ddf3c95c003e2c678d99810155" }, "valid-unpinned-prefix": { "receipts": [ diff --git a/pkg/receiptspec/testdata/v1/gen/main.go b/pkg/receiptspec/testdata/v1/gen/main.go index 2ddd03c..00ddbbe 100644 --- a/pkg/receiptspec/testdata/v1/gen/main.go +++ b/pkg/receiptspec/testdata/v1/gen/main.go @@ -139,6 +139,22 @@ func generate(out string) { addCommon("valid-partial", receipts[1:], manifest(receipts[1:], receiptspec.Anchor{Seq: 1, EntryHash: first.EntryHash}, third), outcome{Class: "ok", VerifiedCount: 2, Complete: true}) addCommon("valid-unpinned-prefix", receipts[:2], nil, outcome{Class: "ok", VerifiedCount: 2}) + addCommon("valid-rotation-straddle", receipts, full, outcome{Class: "ok", VerifiedCount: 3, Complete: true}) + addCommon("valid-rotation-before-first-receipt", receipts[:2], manifest(receipts[:2], receiptspec.Anchor{}, second), + outcome{Class: "ok", VerifiedCount: 2, Complete: true}) + historical := manifest(receipts[:1], receiptspec.Anchor{}, third) + addCommon("valid-historical-range", receipts[:1], historical, outcome{Class: "ok", VerifiedCount: 1, Complete: true}) + retiredBoundary := receiptspec.SignManifest(*manifest(receipts[:2], receiptspec.Anchor{}, second), signerA.KID(), privA) + addCommon("valid-retired-at-boundary", receipts[:2], &retiredBoundary, outcome{Class: "ok", VerifiedCount: 2, Complete: true}) + empty := receiptspec.ExportManifest{ + FormatVersion: 1, RequestedFrom: 1, KeysetDigest: receiptspec.ComputeKeysetDigest(keys), + } + empty = receiptspec.SignManifest(empty, signerA.KID(), privA) + addCommon("valid-empty-genesis", nil, &empty, outcome{Class: "ok", Complete: true}) + empty.RequestedFrom, empty.ResolvedTo, empty.AnchorSeq, empty.HeadSeq = 4, 3, 3, 3 + empty.AnchorHash, empty.FirstEntryHash, empty.LastEntryHash, empty.HeadHash = third.EntryHash, third.EntryHash, third.EntryHash, third.EntryHash + empty = receiptspec.SignManifest(empty, signerB.KID(), privB) + addCommon("valid-empty-anchor", nil, &empty, outcome{Class: "ok", Complete: true}) maxFields := thirdFields maxFields.HumanPrincipal = strings.Repeat("é", 128) @@ -199,12 +215,45 @@ func generate(out string) { badManifest.ResolvedTo++ badManifest = receiptspec.SignManifest(badManifest, signerB.KID(), privB) addCommon("manifest-endpoint-mismatch", receipts, &badManifest, - outcome{Class: receiptspec.ReasonExpectedHeadMismatch, VerifiedCount: 3, FailedAtSeq: 4}) + outcome{Class: "manifest_resolved_to_beyond_head", FailedAtSeq: 4}) badManifest = *full badManifest.Count = 99 badManifest = receiptspec.SignManifest(badManifest, signerB.KID(), privB) addCommon("valid-unchecked-manifest-count", receipts, &badManifest, - outcome{Class: "ok", VerifiedCount: 3, Complete: true}) + outcome{Class: "manifest_count_mismatch", FailedAtSeq: 3}) + + retired := receiptspec.SignManifest(*full, signerA.KID(), privA) + addCommon("manifest-retired-signer-full-range", receipts, &retired, + outcome{Class: "manifest_signer_inactive_at_head", FailedAtSeq: 3}) + tooEarly := *historical + tooEarly.HeadSeq, tooEarly.HeadHash = first.Seq, first.EntryHash + tooEarly = receiptspec.SignManifest(tooEarly, signerB.KID(), privB) + addCommon("manifest-signer-before-window", receipts[:1], &tooEarly, + outcome{Class: "manifest_signer_inactive_at_head", FailedAtSeq: 1}) + addBadManifest := func(name string, change func(*receiptspec.ExportManifest), want outcome) { + m := *full + change(&m) + m = receiptspec.SignManifest(m, signerB.KID(), privB) + addCommon(name, receipts, &m, want) + } + addBadManifest("manifest-count-mismatch", func(m *receiptspec.ExportManifest) { m.Count-- }, + outcome{Class: "manifest_count_mismatch", FailedAtSeq: 3}) + addBadManifest("manifest-negative-count", func(m *receiptspec.ExportManifest) { + m.Count, m.ResolvedTo, m.HeadSeq = -1, ^uint64(0), ^uint64(0) + }, outcome{Class: "manifest_count_mismatch", FailedAtSeq: ^uint64(0)}) + addBadManifest("manifest-reversed-range", func(m *receiptspec.ExportManifest) { + m.AnchorSeq, m.ResolvedTo, m.Count = ^uint64(0), 0, 1 + }, outcome{Class: "manifest_count_mismatch"}) + addBadManifest("manifest-first-entry-hash-mismatch", func(m *receiptspec.ExportManifest) { m.FirstEntryHash[0] ^= 1 }, + outcome{Class: "manifest_first_entry_hash_mismatch", VerifiedCount: 3, FailedAtSeq: 1}) + addBadManifest("manifest-head-hash-mismatch", func(m *receiptspec.ExportManifest) { m.HeadHash[0] ^= 1 }, + outcome{Class: "manifest_head_hash_mismatch", FailedAtSeq: 3}) + badEmpty := empty + badEmpty.HeadSeq++ + badEmpty.LastEntryHash[0] ^= 1 + badEmpty = receiptspec.SignManifest(badEmpty, signerB.KID(), privB) + addCommon("manifest-empty-endpoint-mismatch", nil, &badEmpty, + outcome{Class: receiptspec.ReasonExpectedHeadMismatch, FailedAtSeq: 3}) preimage, err := receiptspec.CanonicalHashInput(first) check(err) diff --git a/pkg/receiptspec/testdata/v1/manifest-count-mismatch.jsonl b/pkg/receiptspec/testdata/v1/manifest-count-mismatch.jsonl new file mode 100644 index 0000000..09539ab --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-count-mismatch.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":3,"count":2,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"45596645e6481e4f10637136a2cfe1fbf4e02b2b7477041b22dcddf88eb3894bc367710a02ed9e0600d04ae079e6dcef09e1140d490a8c2e12c05f5b4b68620c"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-empty-endpoint-mismatch.jsonl b/pkg/receiptspec/testdata/v1/manifest-empty-endpoint-mismatch.jsonl new file mode 100644 index 0000000..71a53e8 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-empty-endpoint-mismatch.jsonl @@ -0,0 +1,3 @@ +{"type":"manifest","format_version":1,"requested_from":4,"resolved_to":3,"count":0,"anchor_seq":3,"anchor_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","first_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","last_entry_hash":"549b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":4,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"bddb501b970c20b750cfa2b0a2e7b8160ca4a07c6542de3a1bdbfdbb34d00aedf986290ef37c543bf69f4f465dc66129081cff5dcee873bf756ef69d60f37b01"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} diff --git a/pkg/receiptspec/testdata/v1/manifest-first-entry-hash-mismatch.jsonl b/pkg/receiptspec/testdata/v1/manifest-first-entry-hash-mismatch.jsonl new file mode 100644 index 0000000..6c083b5 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-first-entry-hash-mismatch.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":3,"count":3,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b8647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"298a924067b7a5e44a460f07bd46c07c676c380e5b8649cd4b78a85c3cff9466dcdb64007aaf2dc62a64ce530cc4bfded650a67694aa4a402b84e30095185207"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-head-hash-mismatch.jsonl b/pkg/receiptspec/testdata/v1/manifest-head-hash-mismatch.jsonl new file mode 100644 index 0000000..cc92dd3 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-head-hash-mismatch.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":3,"count":3,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"549b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"f7d573dd0873e2f897bb6c1d0bc489215cd779dc308bd773d4c172f775fd3b27b8a7069481c3d4688135d0a53ccef3ecb37540714efca76ce552a8aac7e6c206"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-negative-count.jsonl b/pkg/receiptspec/testdata/v1/manifest-negative-count.jsonl new file mode 100644 index 0000000..b4e7ef9 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-negative-count.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":18446744073709551615,"count":-1,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":18446744073709551615,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"6cb50774a2b34935120de3b50a2b8a8f0d817ab3e4234132125d96b865559bbd29602aec1564072dac63f9d7bb40f5be4a3fc2818fcea6d2723e4094cbbd610c"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-retired-signer-full-range.jsonl b/pkg/receiptspec/testdata/v1/manifest-retired-signer-full-range.jsonl new file mode 100644 index 0000000..b04b5b4 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-retired-signer-full-range.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":3,"count":3,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:56475aa75463474c","signature":"3d3f623f3f2164b2fd614333220e5c7f903f1f7b5a7c63227d10c968af698d8aa6e41e922e7cee61349d9d14b5d4a0034bbabaadeb874a19e75c8c0bc619d901"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-reversed-range.jsonl b/pkg/receiptspec/testdata/v1/manifest-reversed-range.jsonl new file mode 100644 index 0000000..1256077 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-reversed-range.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":0,"count":1,"anchor_seq":18446744073709551615,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"0ede995a4b1af1231174faf39b595713029325647f32fd92b727ddb24f551de192d79942e18c066344140da127151ee55f7935482ce35d04999b340884c83509"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/manifest-signer-before-window.jsonl b/pkg/receiptspec/testdata/v1/manifest-signer-before-window.jsonl new file mode 100644 index 0000000..5876410 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/manifest-signer-before-window.jsonl @@ -0,0 +1,4 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":1,"count":1,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","head_seq":1,"head_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"fc8c16d04accda48983cd40ae75a1a0d2f403580e99e5c9a69c3a24c717c67aa39e694b7a772378d9d14d0bb353a8674ec15885118b07890d6575037c8a7460a"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} diff --git a/pkg/receiptspec/testdata/v1/valid-empty-anchor.jsonl b/pkg/receiptspec/testdata/v1/valid-empty-anchor.jsonl new file mode 100644 index 0000000..2e58596 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-empty-anchor.jsonl @@ -0,0 +1,3 @@ +{"type":"manifest","format_version":1,"requested_from":4,"resolved_to":3,"count":0,"anchor_seq":3,"anchor_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","first_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"9a61039bf71628089b97675c8d8a415761b7f3013cf9fb44436cc9ca998418d9a66b36e5e5b5c1cd05fb9fba107982dd15701dcd00548c4d10fcbf6b89880509"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} diff --git a/pkg/receiptspec/testdata/v1/valid-empty-genesis.jsonl b/pkg/receiptspec/testdata/v1/valid-empty-genesis.jsonl new file mode 100644 index 0000000..b69a4d1 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-empty-genesis.jsonl @@ -0,0 +1,3 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":0,"count":0,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"0000000000000000000000000000000000000000000000000000000000000000","last_entry_hash":"0000000000000000000000000000000000000000000000000000000000000000","head_seq":0,"head_hash":"0000000000000000000000000000000000000000000000000000000000000000","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:56475aa75463474c","signature":"940574d7383ac910164739cc4128f85d8e021a608d97505986f9646409708f53a537bd45e2c3ed1e3bd9be5d68461183f819ed69ec908b54bb4b4398283f5508"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} diff --git a/pkg/receiptspec/testdata/v1/valid-historical-range.jsonl b/pkg/receiptspec/testdata/v1/valid-historical-range.jsonl new file mode 100644 index 0000000..b75d170 --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-historical-range.jsonl @@ -0,0 +1,4 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":1,"count":1,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"1cf7f60fd933ca04bcaaddf8c1ec4450125e163db7d97d242468c0521acc76d1aaac900226cf240a9a6bef9ae80e8967b131ea065f455c450c49a823ffac7f00"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} diff --git a/pkg/receiptspec/testdata/v1/valid-retired-at-boundary.jsonl b/pkg/receiptspec/testdata/v1/valid-retired-at-boundary.jsonl new file mode 100644 index 0000000..5d629df --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-retired-at-boundary.jsonl @@ -0,0 +1,5 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":2,"count":2,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","head_seq":2,"head_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:56475aa75463474c","signature":"986554ef27ca2f20dae488652ee4b2d461e88b524e77c8203c6ef62f57829d6a8cc8526e1a6e39ef9e8318f9d86ce22bbe72fd7a44289dc2c3ea24cdd3820702"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} diff --git a/pkg/receiptspec/testdata/v1/valid-rotation-before-first-receipt.jsonl b/pkg/receiptspec/testdata/v1/valid-rotation-before-first-receipt.jsonl new file mode 100644 index 0000000..2c7fe3b --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-rotation-before-first-receipt.jsonl @@ -0,0 +1,5 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":2,"count":2,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","head_seq":2,"head_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"df60b10a7b82e25069dc8e0af3bdd82f0b7b18103e588c21fcfe4930408f3fba9c13b0bcd447605d1644e4be92277a7cf0f3c46f67f9993265609f5a36e81208"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} diff --git a/pkg/receiptspec/testdata/v1/valid-rotation-straddle.jsonl b/pkg/receiptspec/testdata/v1/valid-rotation-straddle.jsonl new file mode 100644 index 0000000..d69419d --- /dev/null +++ b/pkg/receiptspec/testdata/v1/valid-rotation-straddle.jsonl @@ -0,0 +1,6 @@ +{"type":"manifest","format_version":1,"requested_from":1,"resolved_to":3,"count":3,"anchor_seq":0,"anchor_hash":"0000000000000000000000000000000000000000000000000000000000000000","first_entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","last_entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","head_seq":3,"head_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","keyset_digest":"9877fcf6988d2245cabc51a00b26fa546ef0c9209e17a5fd66c6cda80fe075eb","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"38a522420dd49b670936d9c003c596bbec6c7ea036856e1e6430f320da92de8225b45cd43af5b863e6b6889620bebbc42b8cb450660a7e2136654cd61c23a20e"} +{"type":"key","kid":"ed25519:56475aa75463474c","public_key_hex":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","valid_from_seq":1,"valid_until_seq":2} +{"type":"key","kid":"ed25519:24f6ed6acbfe1009","public_key_hex":"29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7","valid_from_seq":3,"valid_until_seq":null} +{"type":"receipt","format_version":1,"seq":1,"timestamp_unix_ns":1,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"allow","status_code":100,"latency_ms":0,"error":"","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","entry_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","signer_kid":"ed25519:56475aa75463474c","signature":"9b0b99df7d812a725ed1f810bbf913bf86b15de3152808cf97dc9ecdc45eb6e2f0481c80490d77d140cc48152c2d337871ebffa343dc69c5f18ee4d294ea1705"} +{"type":"receipt","format_version":1,"seq":2,"timestamp_unix_ns":1700000000000000001,"human_principal":"human-é/é/😀","agent_key_id":"agent-2","delegation_chain":["d2d8e9aea4f592e1ca94318065976dbe9147e57eaf6eb56e14ca9ae6004c6bae","482d488bd94eee647aa10471442b576f78c4646767b16616929cdef236d9eeb6"],"service":"operator","action":"decision:scale","params_sha256":"d110a9af75f1fb6bdecaf9590b2fa2b99df60f007e5ed4b37c72d57edbf11186","policy_decision":"deny","status_code":403,"latency_ms":17,"error":"scope_denied","prev_hash":"b9647a242fa29e0bbb32d5d3e1157165b4a5ebc24a1b04c71d6f4b4d03cba138","entry_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","signer_kid":"ed25519:56475aa75463474c","signature":"d070daaf2edf8115f713d5749e4d8894ed4dfd3191d74d0d0f1d13082247bdb54055fe1dd818607edd9f8b26f86f4eed16cbcac784f509c68b1dcf3f672fe60e"} +{"type":"receipt","format_version":1,"seq":3,"timestamp_unix_ns":18446744073709551615,"human_principal":"h","agent_key_id":"a","delegation_chain":null,"service":"s","action":"x","params_sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","policy_decision":"rate_limited","status_code":599,"latency_ms":9223372036854775807,"error":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","prev_hash":"b2a27707d518092a9fa6b069a0ef1907489ed66ad9ae3196d4bffafe3affcfe2","entry_hash":"559b87ae267e480b80e34aacf5111f94d200c94ef8fa111296965cad3d43b695","signer_kid":"ed25519:24f6ed6acbfe1009","signature":"628208d560ad848a6635343e2d0c0ab1b8f2bc07f2e6ff69c8f9f8bb11a54f5a70f8a26e42dafb797146ac252f6df58fa2d976d306fd17a9ce336bf84692f600"} diff --git a/pkg/receiptspec/testdata/v1/vectors.json b/pkg/receiptspec/testdata/v1/vectors.json index 9b99559..583eb4b 100644 --- a/pkg/receiptspec/testdata/v1/vectors.json +++ b/pkg/receiptspec/testdata/v1/vectors.json @@ -45,6 +45,72 @@ "complete": false } }, + { + "name": "valid-rotation-straddle", + "jsonl": "valid-rotation-straddle.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 3, + "failed_at_seq": 0, + "complete": true + } + }, + { + "name": "valid-rotation-before-first-receipt", + "jsonl": "valid-rotation-before-first-receipt.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 2, + "failed_at_seq": 0, + "complete": true + } + }, + { + "name": "valid-historical-range", + "jsonl": "valid-historical-range.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 1, + "failed_at_seq": 0, + "complete": true + } + }, + { + "name": "valid-retired-at-boundary", + "jsonl": "valid-retired-at-boundary.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 2, + "failed_at_seq": 0, + "complete": true + } + }, + { + "name": "valid-empty-genesis", + "jsonl": "valid-empty-genesis.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 0, + "failed_at_seq": 0, + "complete": true + } + }, + { + "name": "valid-empty-anchor", + "jsonl": "valid-empty-anchor.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "ok", + "verified_count": 0, + "failed_at_seq": 0, + "complete": true + } + }, { "name": "valid-boundary", "jsonl": "valid-boundary.jsonl", @@ -204,8 +270,8 @@ "jsonl": "manifest-endpoint-mismatch.jsonl", "keys": "public-keys.json", "expected": { - "class": "expected_head_mismatch", - "verified_count": 3, + "class": "manifest_resolved_to_beyond_head", + "verified_count": 0, "failed_at_seq": 4, "complete": false } @@ -215,10 +281,98 @@ "jsonl": "valid-unchecked-manifest-count.jsonl", "keys": "public-keys.json", "expected": { - "class": "ok", - "verified_count": 3, + "class": "manifest_count_mismatch", + "verified_count": 0, + "failed_at_seq": 3, + "complete": false + } + }, + { + "name": "manifest-retired-signer-full-range", + "jsonl": "manifest-retired-signer-full-range.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_signer_inactive_at_head", + "verified_count": 0, + "failed_at_seq": 3, + "complete": false + } + }, + { + "name": "manifest-signer-before-window", + "jsonl": "manifest-signer-before-window.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_signer_inactive_at_head", + "verified_count": 0, + "failed_at_seq": 1, + "complete": false + } + }, + { + "name": "manifest-count-mismatch", + "jsonl": "manifest-count-mismatch.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_count_mismatch", + "verified_count": 0, + "failed_at_seq": 3, + "complete": false + } + }, + { + "name": "manifest-negative-count", + "jsonl": "manifest-negative-count.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_count_mismatch", + "verified_count": 0, + "failed_at_seq": 18446744073709551615, + "complete": false + } + }, + { + "name": "manifest-reversed-range", + "jsonl": "manifest-reversed-range.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_count_mismatch", + "verified_count": 0, "failed_at_seq": 0, - "complete": true + "complete": false + } + }, + { + "name": "manifest-first-entry-hash-mismatch", + "jsonl": "manifest-first-entry-hash-mismatch.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_first_entry_hash_mismatch", + "verified_count": 3, + "failed_at_seq": 1, + "complete": false + } + }, + { + "name": "manifest-head-hash-mismatch", + "jsonl": "manifest-head-hash-mismatch.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "manifest_head_hash_mismatch", + "verified_count": 0, + "failed_at_seq": 3, + "complete": false + } + }, + { + "name": "manifest-empty-endpoint-mismatch", + "jsonl": "manifest-empty-endpoint-mismatch.jsonl", + "keys": "public-keys.json", + "expected": { + "class": "expected_head_mismatch", + "verified_count": 0, + "failed_at_seq": 3, + "complete": false } } ] diff --git a/pkg/receiptspec/verifier.go b/pkg/receiptspec/verifier.go index 675d2fc..4e9ccb6 100644 --- a/pkg/receiptspec/verifier.go +++ b/pkg/receiptspec/verifier.go @@ -18,13 +18,18 @@ import ( // Reason values are stable ASCII and never derived from a receipt's own // fields, so they are safe to print without leaking receipted data. const ( - ReasonSequenceGap = "sequence_gap_or_not_genesis_anchored" - ReasonPrevHashMismatch = "prev_hash_mismatch" - ReasonInvalidReceiptFields = "invalid_receipt_fields" - ReasonEntryHashMismatch = "entry_hash_mismatch" - ReasonSignatureInvalid = "signature_invalid" - ReasonSignerInactiveAtSeq = "signer_kid_inactive_at_seq" - ReasonExpectedHeadMismatch = "expected_head_mismatch" + ReasonSequenceGap = "sequence_gap_or_not_genesis_anchored" + ReasonPrevHashMismatch = "prev_hash_mismatch" + ReasonInvalidReceiptFields = "invalid_receipt_fields" + ReasonEntryHashMismatch = "entry_hash_mismatch" + ReasonSignatureInvalid = "signature_invalid" + ReasonSignerInactiveAtSeq = "signer_kid_inactive_at_seq" + ReasonExpectedHeadMismatch = "expected_head_mismatch" + ReasonManifestSignerInactiveAtHead = "manifest_signer_inactive_at_head" + ReasonManifestResolvedToBeyondHead = "manifest_resolved_to_beyond_head" + ReasonManifestCountMismatch = "manifest_count_mismatch" + ReasonManifestFirstEntryHashMismatch = "manifest_first_entry_hash_mismatch" + ReasonManifestHeadHashMismatch = "manifest_head_hash_mismatch" ) var ( @@ -130,12 +135,12 @@ type VerifyResult struct { OK bool TotalReceipts int VerifiedCount int - FailedAtSeq uint64 // 0 only when OK and no failure occurred + FailedAtSeq uint64 // 0 also identifies a manifest failure at genesis Reason string // one of the Reason* constants; empty when OK HeadSeq uint64 HeadEntryHash [32]byte // Complete is true only when an ExpectedHead was supplied and matched the - // final receipt. False means completeness was not claimed, not tampering. + // final receipt or an authenticated empty range's anchor. Complete bool } @@ -209,6 +214,11 @@ func VerifyChain(receipts []Receipt, trustedKeys []TrustedKey, anchor Anchor, ex } result.OK = true + return verifyExpectedHead(result, expectedHead), nil +} + +func verifyExpectedHead(result VerifyResult, expectedHead *ExpectedHead) VerifyResult { + result.Complete = false if expectedHead != nil { if result.HeadSeq == expectedHead.Seq && result.HeadEntryHash == expectedHead.EntryHash { result.Complete = true @@ -218,7 +228,7 @@ func VerifyChain(receipts []Receipt, trustedKeys []TrustedKey, anchor Anchor, ex result.Reason = ReasonExpectedHeadMismatch } } - return result, nil + return result } // ManifestError wraps a manifest verification failure from VerifyBundle. @@ -235,8 +245,9 @@ func (e *ManifestError) Unwrap() error { return e.Err } // VerifyBundle verifies a parsed source the way agentgate-verify does. // trusted overrides b.Keys when non-empty; otherwise the embedded keys are -// the trust set. A manifest, if present, must verify (else *ManifestError) -// and then supplies the anchor and, unless expected is set, the expected head. +// the trust set. Signature and trust failures return *ManifestError. +// A manifest supplies the anchor and, unless expected is set, the expected head. +// Manifest consistency failures return a failed result with a Reason constant. func VerifyBundle(b Bundle, trusted []TrustedKey, expected *ExpectedHead) (VerifyResult, error) { if len(trusted) == 0 { trusted = b.Keys @@ -244,12 +255,33 @@ func VerifyBundle(b Bundle, trusted []TrustedKey, expected *ExpectedHead) (Verif anchor := Anchor{} if b.Manifest != nil { if err := VerifyManifest(*b.Manifest, trusted, b.Keys); err != nil { + var failure *manifestCheckError + if errors.As(err, &failure) { + return VerifyResult{ + TotalReceipts: len(b.Receipts), FailedAtSeq: failure.seq, Reason: failure.reason, + }, nil + } return VerifyResult{}, &ManifestError{Err: err} } anchor = Anchor{Seq: b.Manifest.AnchorSeq, EntryHash: b.Manifest.AnchorHash} if expected == nil { expected = &ExpectedHead{Seq: b.Manifest.ResolvedTo, EntryHash: b.Manifest.LastEntryHash} } + if len(b.Receipts) == 0 && b.Manifest.Count == 0 { + result := VerifyResult{OK: true, HeadSeq: anchor.Seq, HeadEntryHash: anchor.EntryHash} + result = verifyExpectedHead(result, &ExpectedHead{Seq: b.Manifest.ResolvedTo, EntryHash: b.Manifest.LastEntryHash}) + if !result.OK { + return result, nil + } + return verifyExpectedHead(result, expected), nil + } + } + result, err := VerifyChain(b.Receipts, trusted, anchor, expected) + if err == nil && result.OK && b.Manifest != nil && b.Manifest.Count > 0 && + b.Manifest.FirstEntryHash != b.Receipts[0].EntryHash { + result.OK, result.Complete = false, false + result.FailedAtSeq = b.Receipts[0].Seq + result.Reason = ReasonManifestFirstEntryHashMismatch } - return VerifyChain(b.Receipts, trusted, anchor, expected) + return result, err } From b940eebb4f17a930e4f30e2dbe4c0e66b09d505e Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:36:55 +0530 Subject: [PATCH 2/2] fix(verify): distinguish manifest completeness Signed manifest endpoints prove only their selected range. Avoid claiming an independent head or freshness without --expected-head. Rename the rejected inflated-count vector without changing v1 locks. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 15 +++--- cmd/agentgate-verify/main.go | 18 ++++--- cmd/agentgate-verify/main_test.go | 51 +++++++++++++++++++ docs/spec/RECEIPT-v1.md | 2 +- pkg/receiptspec/testdata/v1/gen/main.go | 2 +- ...nt.jsonl => manifest-inflated-count.jsonl} | 0 pkg/receiptspec/testdata/v1/vectors.json | 4 +- 7 files changed, 74 insertions(+), 18 deletions(-) rename pkg/receiptspec/testdata/v1/{valid-unchecked-manifest-count.jsonl => manifest-inflated-count.jsonl} (100%) diff --git a/README.md b/README.md index efc4770..f1c2abf 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,14 @@ tar xzf agentgate___.tar.gz # PASS: 1 receipts verified, head seq=1 hash=... ``` -No network call happens during verification itself — `trust.json` is a -pinned local file, and `agentgate-verify` only reads the local SQLite file. -Pass `--expected-head :` (from a checkpoint recorded separately, -e.g. at handoff to an auditor) to also assert completeness, not just chain -integrity. For scripts, add `--format json` to receive one machine-readable -result object while keeping the same exit codes. Add `--quiet` (or `-q`) to -text output to print only the `PASS:` summary on successful verification. +Verification makes no network calls. `trust.json` is a pinned local file. +For SQLite sources, `agentgate-verify` only reads the local database. +A signed JSONL manifest proves completeness against its signed endpoint, not freshness. +Its `range: full` label refers to the signed manifest head, not an independently verified database head. +Pass `--expected-head :` from an independently recorded checkpoint to check completeness against that checkpoint. +Without that independent pin, a signed historical endpoint can still pass verification. +For scripts, add `--format json` for a machine-readable result with the same exit codes. +Add `--quiet` (or `-q`) to print only the `PASS:` summary on successful text verification. ### Verify receipts from other producers diff --git a/cmd/agentgate-verify/main.go b/cmd/agentgate-verify/main.go index 8341adf..41a6a78 100644 --- a/cmd/agentgate-verify/main.go +++ b/cmd/agentgate-verify/main.go @@ -18,9 +18,9 @@ Licensed under the Apache License, Version 2.0. // --trust-root points to a JSON file of trusted signer keys (the same // shape GET /v1/receipts/pubkey serves), saved once through a trusted // channel; required unless the jsonl source embeds its own keys. -// --expected-head SEQ:HEXHASH overrides any manifest-derived expected head -// and additionally proves the checked range is complete, not merely -// internally consistent. +// --expected-head SEQ:HEXHASH overrides any manifest-derived expected head. +// It checks completeness against an independently recorded checkpoint. +// A signed manifest endpoint alone does not prove freshness. // // Exit codes: 0 = all requested checks passed, 1 = chain, key, manifest, // or signature mismatch, 2 = I/O, syntax, or configuration error. @@ -59,7 +59,7 @@ func run(args []string, stdout, stderr io.Writer) int { fs.StringVar(&source, "source", "", "receipt source: sqlite | jsonl") fs.StringVar(&path, "path", "", "input path; '-' means stdin (jsonl source only)") fs.StringVar(&trustRoot, "trust-root", "", "path to a JSON trust file; optional if the jsonl source embeds its own keys") - fs.StringVar(&expectedHead, "expected-head", "", "optional SEQ:HEXHASH; overrides a manifest-derived expected head") + fs.StringVar(&expectedHead, "expected-head", "", "optional SEQ:HEXHASH; pin an independent checkpoint; a manifest alone does not prove freshness") fs.StringVar(&outputFormat, "format", "text", "output format: text | json") fs.BoolVar(&quiet, "quiet", false, "suppress successful verification details in text output") fs.BoolVar(&quiet, "q", false, "suppress successful verification details in text output") @@ -154,15 +154,19 @@ func run(args []string, stdout, stderr io.Writer) int { return 0 } if result.Complete { - fmt.Fprintln(stdout, "completeness: proven against the supplied expected head") + if explicitExpected != nil { + fmt.Fprintln(stdout, "completeness: proven against the supplied expected head") + } else { + fmt.Fprintln(stdout, "completeness: proven against the signed manifest endpoint; freshness not proven (pass --expected-head)") + } } else { fmt.Fprintln(stdout, "completeness: not claimed (no --expected-head supplied)") } if manifest != nil { if rangeKind == "full" { - fmt.Fprintln(stdout, "range: full (reaches the database's true head at export time)") + fmt.Fprintln(stdout, "range: full (reaches the signed manifest head)") } else { - fmt.Fprintf(stdout, "range: partial (head at export time was seq=%d)\n", manifest.HeadSeq) + fmt.Fprintf(stdout, "range: partial (signed manifest head was seq=%d)\n", manifest.HeadSeq) } } return 0 diff --git a/cmd/agentgate-verify/main_test.go b/cmd/agentgate-verify/main_test.go index b990f6b..001af41 100644 --- a/cmd/agentgate-verify/main_test.go +++ b/cmd/agentgate-verify/main_test.go @@ -10,6 +10,7 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -547,6 +548,56 @@ func TestRun_JSONLExport_FullRangeSelfContained(t *testing.T) { } } +func TestRun_JSONLExport_CompletenessProvenance(t *testing.T) { + jsonlPath, _ := buildTestExportJSONL(t, 5, "from=1") + bundle, err := readJSONL(jsonlPath) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + expectedHead string + completeness string + }{ + { + name: "manifest-endpoint", + completeness: "completeness: proven against the signed manifest endpoint; freshness not proven (pass --expected-head)", + }, + { + name: "explicit-expected-head", + expectedHead: fmt.Sprintf("%d:%x", bundle.Manifest.ResolvedTo, bundle.Manifest.LastEntryHash), + completeness: "completeness: proven against the supplied expected head", + }, + } { + t.Run(tc.name, func(t *testing.T) { + args := []string{"--source", "jsonl", "--path", jsonlPath} + if tc.expectedHead != "" { + args = append(args, "--expected-head", tc.expectedHead) + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, tc.completeness+"\n") { + t.Fatalf("missing completeness provenance: %s", output) + } + if !strings.Contains(output, "range: full (reaches the signed manifest head)\n") { + t.Fatalf("missing signed range provenance: %s", output) + } + if strings.Contains(output, "database's true head") { + t.Fatalf("unproven database head claim: %s", output) + } + if tc.expectedHead == "" && strings.Contains(output, "supplied expected head") { + t.Fatalf("unproven external head claim: %s", output) + } + if tc.expectedHead != "" && strings.Contains(output, "freshness not proven") { + t.Fatalf("unexpected manifest-only caveat: %s", output) + } + }) + } +} + // TestRun_JSONLExport_PartialRangeSelfContained covers EXPT-04 for a // bounded, non-genesis-anchored export: it must still verify with no // --trust-root and must report the range as partial. diff --git a/docs/spec/RECEIPT-v1.md b/docs/spec/RECEIPT-v1.md index 2fe47c9..74bb301 100644 --- a/docs/spec/RECEIPT-v1.md +++ b/docs/spec/RECEIPT-v1.md @@ -634,7 +634,7 @@ Tests exercise both explicit and embedded trust. | `valid-partial` | `ok`, signed non-genesis anchor | | `valid-boundary` | `ok`, maximum sequence and string sizes, 32 delegation elements | | `valid-unpinned-prefix` | `ok`, deleted suffix cannot be detected without an endpoint | -| `valid-unchecked-manifest-count` | `manifest_count_mismatch`, historical fixture now rejected | +| `manifest-inflated-count` | `manifest_count_mismatch`, inflated signed count | | `valid-rotation-straddle` | `ok`, old and new receipt keys, new manifest signer | | `valid-rotation-before-first-receipt` | `ok`, new signer starts at snapshot head plus 1 | | `valid-historical-range` | `ok`, range starts before the manifest signer's window | diff --git a/pkg/receiptspec/testdata/v1/gen/main.go b/pkg/receiptspec/testdata/v1/gen/main.go index 00ddbbe..8b00cde 100644 --- a/pkg/receiptspec/testdata/v1/gen/main.go +++ b/pkg/receiptspec/testdata/v1/gen/main.go @@ -219,7 +219,7 @@ func generate(out string) { badManifest = *full badManifest.Count = 99 badManifest = receiptspec.SignManifest(badManifest, signerB.KID(), privB) - addCommon("valid-unchecked-manifest-count", receipts, &badManifest, + addCommon("manifest-inflated-count", receipts, &badManifest, outcome{Class: "manifest_count_mismatch", FailedAtSeq: 3}) retired := receiptspec.SignManifest(*full, signerA.KID(), privA) diff --git a/pkg/receiptspec/testdata/v1/valid-unchecked-manifest-count.jsonl b/pkg/receiptspec/testdata/v1/manifest-inflated-count.jsonl similarity index 100% rename from pkg/receiptspec/testdata/v1/valid-unchecked-manifest-count.jsonl rename to pkg/receiptspec/testdata/v1/manifest-inflated-count.jsonl diff --git a/pkg/receiptspec/testdata/v1/vectors.json b/pkg/receiptspec/testdata/v1/vectors.json index 583eb4b..ed42dc6 100644 --- a/pkg/receiptspec/testdata/v1/vectors.json +++ b/pkg/receiptspec/testdata/v1/vectors.json @@ -277,8 +277,8 @@ } }, { - "name": "valid-unchecked-manifest-count", - "jsonl": "valid-unchecked-manifest-count.jsonl", + "name": "manifest-inflated-count", + "jsonl": "manifest-inflated-count.jsonl", "keys": "public-keys.json", "expected": { "class": "manifest_count_mismatch",