Skip to content

Commit d1b7219

Browse files
feat(receiptspec): extract public stdlib-only receipt protocol package (#34)
* feat(receiptspec): extract public stdlib-only receipt protocol package Move the receipt type, canonical hashing, chain and manifest verification, and JSONL encoding into pkg/receiptspec. internal/receipt now forwards to it with type aliases, so there is one implementation and the wire format is unchanged. Add a Chain builder for other producers, plus HashRecord and VerifyRecordBinding for decision records bound through params_sha256. agentgate-verify uses receiptspec for JSONL parsing and verification. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> * fix(receiptspec): address review (detach slices, guard key length, bound input) Seal and Chain.Next clone DelegationChain so returned receipts never alias caller slices. VerifyManifest and VerifyChain reject wrong-length trusted public keys instead of panicking in ed25519.Verify. CanonicalRecord bounds raw input at 1 MiB before decoding. Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> * test(receiptspec): use RawMessage for oversized-input case Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --------- Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com>
1 parent 82e1b8c commit d1b7219

23 files changed

Lines changed: 2132 additions & 879 deletions

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ integrity. For scripts, add `--format json` to receive one machine-readable
8585
result object while keeping the same exit codes. Add `--quiet` (or `-q`) to
8686
text output to print only the `PASS:` summary on successful verification.
8787

88+
### Verify receipts from other producers
89+
90+
The receipt protocol lives in a public, standard-library-only Go package:
91+
[`pkg/receiptspec`](pkg/receiptspec). It holds the receipt type, canonical
92+
hashing, Ed25519 signing, chain and export verification, and JSONL encoding.
93+
The gateway and `agentgate-verify` both use it, so there is one
94+
implementation. Another producer, such as the Clawdlinux operator, builds a
95+
chain with `receiptspec.NewChain` and any `agentgate-verify` checks it with
96+
`--source jsonl`. To record decisions instead of API calls, see
97+
[`pkg/receiptspec/DECISION.md`](pkg/receiptspec/DECISION.md).
98+
8899
### Development option
89100

90101
To build from source instead of pulling the release image — useful when

‎cmd/agentgate-verify/main.go‎

Lines changed: 30 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@ Licensed under the Apache License, Version 2.0.
2727
package main
2828

2929
import (
30-
"bufio"
31-
"bytes"
3230
"database/sql"
3331
"encoding/json"
32+
"errors"
3433
"flag"
3534
"fmt"
3635
"io"
@@ -39,6 +38,7 @@ import (
3938
_ "modernc.org/sqlite"
4039

4140
"github.com/Clawdlinux/agentgate/internal/receipt"
41+
"github.com/Clawdlinux/agentgate/pkg/receiptspec"
4242
)
4343

4444
func main() {
@@ -80,23 +80,23 @@ func run(args []string, stdout, stderr io.Writer) int {
8080
return 2
8181
}
8282

83-
var explicitTrust []receipt.TrustedKey
83+
var explicitTrust []receiptspec.TrustedKey
8484
if trustRoot != "" {
8585
trustData, err := os.ReadFile(trustRoot)
8686
if err != nil {
8787
fmt.Fprintf(stderr, "agentgate-verify: read trust root: %v\n", err)
8888
return 2
8989
}
90-
explicitTrust, err = receipt.LoadTrustedKeys(trustData)
90+
explicitTrust, err = receiptspec.LoadTrustedKeys(trustData)
9191
if err != nil {
9292
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
9393
return 2
9494
}
9595
}
9696

97-
var explicitExpected *receipt.ExpectedHead
97+
var explicitExpected *receiptspec.ExpectedHead
9898
if expectedHead != "" {
99-
eh, err := receipt.ParseExpectedHead(expectedHead)
99+
eh, err := receiptspec.ParseExpectedHead(expectedHead)
100100
if err != nil {
101101
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
102102
return 2
@@ -105,45 +105,31 @@ func run(args []string, stdout, stderr io.Writer) int {
105105
}
106106

107107
var (
108-
receipts []receipt.Receipt
109-
embeddedKeys []receipt.TrustedKey
110-
manifest *receipt.ExportManifest
111-
err error
108+
bundle receiptspec.Bundle
109+
err error
112110
)
113111
switch source {
114112
case "sqlite":
115-
receipts, err = readSQLite(path)
113+
bundle.Receipts, err = readSQLite(path)
116114
case "jsonl":
117-
receipts, embeddedKeys, manifest, err = readJSONL(path)
115+
bundle, err = readJSONL(path)
118116
}
119117
if err != nil {
120118
fmt.Fprintf(stderr, "agentgate-verify: read %s: %v\n", source, err)
121119
return 2
122120
}
123-
124-
trustedKeys := explicitTrust
125-
if len(trustedKeys) == 0 {
126-
if len(embeddedKeys) == 0 {
127-
fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)")
128-
return 2
129-
}
130-
trustedKeys = embeddedKeys
121+
if len(explicitTrust) == 0 && len(bundle.Keys) == 0 {
122+
fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)")
123+
return 2
131124
}
125+
manifest := bundle.Manifest
132126

133-
anchor := receipt.Anchor{}
134-
expected := explicitExpected
135-
if manifest != nil {
136-
if err := receipt.VerifyManifest(*manifest, trustedKeys, embeddedKeys); err != nil {
137-
fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", err)
138-
return 1
139-
}
140-
anchor = receipt.Anchor{Seq: manifest.AnchorSeq, EntryHash: manifest.AnchorHash}
141-
if expected == nil {
142-
expected = &receipt.ExpectedHead{Seq: manifest.ResolvedTo, EntryHash: manifest.LastEntryHash}
143-
}
127+
result, err := receiptspec.VerifyBundle(bundle, explicitTrust, explicitExpected)
128+
var manifestErr *receiptspec.ManifestError
129+
if errors.As(err, &manifestErr) {
130+
fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", manifestErr.Err)
131+
return 1
144132
}
145-
146-
result, err := receipt.VerifyChain(receipts, trustedKeys, anchor, expected)
147133
if err != nil {
148134
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
149135
return 2
@@ -203,7 +189,7 @@ type jsonResult struct {
203189
Range string `json:"range,omitempty"`
204190
}
205191

206-
func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind string) {
192+
func writeJSONResult(stdout io.Writer, result receiptspec.VerifyResult, rangeKind string) {
207193
output := jsonResult{
208194
OK: result.OK,
209195
TotalReceipts: result.TotalReceipts,
@@ -222,7 +208,7 @@ func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind st
222208

223209
// readSQLite reads every row of the receipts table, ordered by seq, from a
224210
// local file. It never writes to the database.
225-
func readSQLite(path string) ([]receipt.Receipt, error) {
211+
func readSQLite(path string) ([]receiptspec.Receipt, error) {
226212
db, err := sql.Open("sqlite", path)
227213
if err != nil {
228214
return nil, err
@@ -239,7 +225,7 @@ func readSQLite(path string) ([]receipt.Receipt, error) {
239225
}
240226
defer rows.Close()
241227

242-
var out []receipt.Receipt
228+
var out []receiptspec.Receipt
243229
for rows.Next() {
244230
r, err := receipt.ScanReceiptRow(rows)
245231
if err != nil {
@@ -250,57 +236,15 @@ func readSQLite(path string) ([]receipt.Receipt, error) {
250236
return out, rows.Err()
251237
}
252238

253-
// readJSONL reads path (or stdin when path is "-"), dispatching each
254-
// non-empty line by its "type" field: "key" lines accumulate into
255-
// embeddedKeys, a "manifest" line becomes manifest (at most one is
256-
// expected; a later one overwrites, since a well-formed export has
257-
// exactly one), and everything else ("receipt", or no "type" field at
258-
// all — Phase 5's original plain format) accumulates into receipts.
259-
func readJSONL(path string) (receipts []receipt.Receipt, embeddedKeys []receipt.TrustedKey, manifest *receipt.ExportManifest, err error) {
260-
var rdr io.Reader
239+
// readJSONL reads path (or stdin when path is "-") with receiptspec.ReadJSONL.
240+
func readJSONL(path string) (receiptspec.Bundle, error) {
261241
if path == "-" {
262-
rdr = os.Stdin
263-
} else {
264-
f, ferr := os.Open(path)
265-
if ferr != nil {
266-
return nil, nil, nil, ferr
267-
}
268-
defer f.Close()
269-
rdr = f
242+
return receiptspec.ReadJSONL(os.Stdin)
270243
}
271-
272-
scanner := bufio.NewScanner(rdr)
273-
scanner.Buffer(make([]byte, 1<<20), 1<<24)
274-
lineNum := 0
275-
for scanner.Scan() {
276-
lineNum++
277-
line := bytes.TrimSpace(scanner.Bytes())
278-
if len(line) == 0 {
279-
continue
280-
}
281-
switch receipt.DetectJSONLLineType(line) {
282-
case "manifest":
283-
m, perr := receipt.ParseManifestLine(line)
284-
if perr != nil {
285-
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
286-
}
287-
manifest = &m
288-
case "key":
289-
k, perr := receipt.ParseKeyLine(line)
290-
if perr != nil {
291-
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
292-
}
293-
embeddedKeys = append(embeddedKeys, k)
294-
default:
295-
r, perr := receipt.ParseJSONLReceipt(line)
296-
if perr != nil {
297-
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
298-
}
299-
receipts = append(receipts, r)
300-
}
301-
}
302-
if serr := scanner.Err(); serr != nil {
303-
return nil, nil, nil, serr
244+
f, err := os.Open(path)
245+
if err != nil {
246+
return receiptspec.Bundle{}, err
304247
}
305-
return receipts, embeddedKeys, manifest, nil
248+
defer f.Close()
249+
return receiptspec.ReadJSONL(f)
306250
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
Copyright 2026 Clawdlinux.
3+
Licensed under the Apache License, Version 2.0.
4+
*/
5+
6+
package main
7+
8+
import (
9+
"bytes"
10+
"crypto/ed25519"
11+
"encoding/hex"
12+
"encoding/json"
13+
"os"
14+
"path/filepath"
15+
"testing"
16+
17+
"github.com/Clawdlinux/agentgate/pkg/receiptspec"
18+
)
19+
20+
// A second producer that only uses pkg/receiptspec must verify with this command.
21+
func TestRunVerifiesReceiptspecChain(t *testing.T) {
22+
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize))
23+
s := receiptspec.NewEd25519Signer(priv)
24+
c := receiptspec.NewChain(s, 0, [32]byte{})
25+
record, err := receiptspec.HashRecord(map[string]any{"outcome": "require_approval", "record_version": 1})
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
var receipts []receiptspec.Receipt
30+
for i := 0; i < 5; i++ {
31+
r, err := c.Next(receiptspec.Fields{
32+
TimestampUnixNS: uint64(1_700_000_000_000_000_000 + i),
33+
HumanPrincipal: "operator@cluster",
34+
AgentKeyID: "operator",
35+
Service: "operator",
36+
Action: "decision:scale",
37+
ParamsSHA256: record,
38+
PolicyDecision: receiptspec.DecisionDeny,
39+
StatusCode: 202,
40+
})
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
receipts = append(receipts, r)
45+
}
46+
47+
dir := t.TempDir()
48+
var buf bytes.Buffer
49+
if err := receiptspec.WriteJSONL(&buf, receipts); err != nil {
50+
t.Fatal(err)
51+
}
52+
chainPath := filepath.Join(dir, "chain.jsonl")
53+
if err := os.WriteFile(chainPath, buf.Bytes(), 0o600); err != nil {
54+
t.Fatal(err)
55+
}
56+
trust, _ := json.Marshal(map[string]any{"keys": []map[string]any{{
57+
"kid": s.KID(), "public_key_hex": hex.EncodeToString(priv.Public().(ed25519.PublicKey)), "valid_from_seq": 1,
58+
}}})
59+
trustPath := filepath.Join(dir, "trust.json")
60+
if err := os.WriteFile(trustPath, trust, 0o600); err != nil {
61+
t.Fatal(err)
62+
}
63+
64+
head := receipts[4]
65+
var stdout, stderr bytes.Buffer
66+
code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath,
67+
"--expected-head", "5:" + hex.EncodeToString(head.EntryHash[:])}, &stdout, &stderr)
68+
if code != 0 {
69+
t.Fatalf("exit %d, stderr %s", code, stderr.String())
70+
}
71+
72+
tampered := bytes.Replace(buf.Bytes(), []byte(`"latency_ms":0`), []byte(`"latency_ms":1`), 1)
73+
if err := os.WriteFile(chainPath, tampered, 0o600); err != nil {
74+
t.Fatal(err)
75+
}
76+
stdout.Reset()
77+
stderr.Reset()
78+
if code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath}, &stdout, &stderr); code != 1 {
79+
t.Fatalf("tampered exit %d, want 1", code)
80+
}
81+
}

‎internal/receipt/encoding.go‎

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,54 +5,12 @@ Licensed under the Apache License, Version 2.0.
55

66
package receipt
77

8-
import (
9-
"crypto/sha256"
10-
"encoding/binary"
11-
)
12-
13-
// HashDomainV1 separates AgentGate receipt hashes from other protocols and versions.
14-
const HashDomainV1 = "agentgate.receipt.hash.v1"
15-
16-
// CanonicalHashInput returns the validated v1 entry-hash preimage.
17-
func CanonicalHashInput(receipt Receipt) ([]byte, error) {
18-
receipt = snapshot(receipt)
19-
if err := Validate(receipt); err != nil {
20-
return nil, err
21-
}
8+
import "github.com/Clawdlinux/agentgate/pkg/receiptspec"
229

23-
encoded := make([]byte, 0, 3110)
24-
encoded = append(encoded, HashDomainV1...)
25-
encoded = append(encoded, 0)
26-
encoded = binary.LittleEndian.AppendUint64(encoded, receipt.Seq)
27-
encoded = binary.LittleEndian.AppendUint64(encoded, receipt.TimestampUnixNS)
28-
encoded = appendLP(encoded, receipt.HumanPrincipal)
29-
encoded = appendLP(encoded, receipt.AgentKeyID)
30-
encoded = binary.LittleEndian.AppendUint32(encoded, uint32(len(receipt.DelegationChain)))
31-
for _, element := range receipt.DelegationChain {
32-
encoded = appendLP(encoded, element)
33-
}
34-
encoded = appendLP(encoded, receipt.Service)
35-
encoded = appendLP(encoded, receipt.Action)
36-
encoded = append(encoded, receipt.ParamsSHA256[:]...)
37-
encoded = appendLP(encoded, receipt.PolicyDecision)
38-
encoded = binary.LittleEndian.AppendUint32(encoded, uint32(int32(receipt.StatusCode)))
39-
encoded = binary.LittleEndian.AppendUint64(encoded, uint64(receipt.LatencyMS))
40-
encoded = appendLP(encoded, receipt.Error)
41-
encoded = append(encoded, receipt.PrevHash[:]...)
42-
encoded = appendLP(encoded, receipt.SignerKID)
43-
return encoded, nil
44-
}
10+
// HashDomainV1 is receiptspec.HashDomainV1.
11+
const HashDomainV1 = receiptspec.HashDomainV1
4512

46-
// ComputeEntryHash returns SHA-256 over the complete canonical hash input.
47-
func ComputeEntryHash(receipt Receipt) ([32]byte, error) {
48-
encoded, err := CanonicalHashInput(receipt)
49-
if err != nil {
50-
return [32]byte{}, err
51-
}
52-
return sha256.Sum256(encoded), nil
53-
}
54-
55-
func appendLP(destination []byte, value string) []byte {
56-
destination = binary.LittleEndian.AppendUint32(destination, uint32(len(value)))
57-
return append(destination, value...)
58-
}
13+
var (
14+
CanonicalHashInput = receiptspec.CanonicalHashInput
15+
ComputeEntryHash = receiptspec.ComputeEntryHash
16+
)

0 commit comments

Comments
 (0)