Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,14 @@ tar xzf agentgate_<version>_<os>_<arch>.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 <seq>:<hash>` (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 <seq>:<hash>` 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

Expand Down
18 changes: 11 additions & 7 deletions cmd/agentgate-verify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions cmd/agentgate-verify/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions cmd/agentgate-verify/receiptspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
84 changes: 68 additions & 16 deletions docs/spec/RECEIPT-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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 |
| `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 |
| `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` |
Expand All @@ -614,14 +654,24 @@ 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`.
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.

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading