@postcept/receipt 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/SPEC.md +83 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/receipt.d.ts +17 -4
- package/dist/receipt.js +77 -20
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# @postcept/receipt
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
A Postcept Receipt is a signed proof that an AI agent's high-risk action (a refund,
|
|
4
|
+
a cancellation, a credit) was checked against the system of record, and of what that
|
|
5
|
+
check found: verified, incomplete, duplicated, mismatched, or policy-failed. This
|
|
6
|
+
is the reference library for verifying one.
|
|
7
|
+
|
|
8
|
+
It runs anywhere JavaScript runs (browser, Node, Deno, edge), and verifying a
|
|
9
|
+
receipt needs nothing from Postcept except the published public key. No API call,
|
|
10
|
+
and no taking the agent's word for it. A valid signature proves the receipt is
|
|
11
|
+
authentic and unmodified since issue. See the spec for what that does and does not
|
|
12
|
+
cover.
|
|
7
13
|
|
|
8
14
|
The format and signing scheme are written up in [SPEC.md](./SPEC.md). This is the
|
|
9
15
|
canonical JS implementation of that spec. The Postcept API signs with a
|
|
@@ -51,6 +57,24 @@ One source of truth for the verification logic. Postcept's own site and audit to
|
|
|
51
57
|
import this same package, and so can you. The "you don't have to trust us" claim
|
|
52
58
|
only holds if the reference implementation is open and stands on its own.
|
|
53
59
|
|
|
60
|
+
## Verifying a release
|
|
61
|
+
|
|
62
|
+
Every published version carries a signed npm provenance statement linking the
|
|
63
|
+
tarball to the commit and CI workflow that built it, plus a CycloneDX SBOM
|
|
64
|
+
attached to the GitHub release.
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
# Check the installed package's provenance and registry signatures:
|
|
68
|
+
npm audit signatures
|
|
69
|
+
|
|
70
|
+
# Inspect what shipped (attached to the GitHub release):
|
|
71
|
+
# sbom.cyclonedx.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The receipt format is verifiable on its own with the public key, as the spec
|
|
75
|
+
describes. Release provenance is a separate check that the code you installed is
|
|
76
|
+
the code this repo built.
|
|
77
|
+
|
|
54
78
|
## License
|
|
55
79
|
|
|
56
80
|
MIT.
|
package/SPEC.md
CHANGED
|
@@ -5,10 +5,22 @@
|
|
|
5
5
|
A Postcept Receipt is a signed, tamper-evident proof that a high-risk AI-agent
|
|
6
6
|
action (a refund, a cancellation, a ticket resolution) was checked against the
|
|
7
7
|
system of record and classified. This document specifies the receipt format and
|
|
8
|
-
how to verify it without going through Postcept: no API call
|
|
8
|
+
how to verify it without going through Postcept: no API call required.
|
|
9
9
|
|
|
10
|
-
The point is
|
|
11
|
-
receipt with nothing but the public key and the rules below.
|
|
10
|
+
The point is independent verifiability. A customer, an auditor, or a counterparty
|
|
11
|
+
can check a receipt with nothing but the public key and the rules below.
|
|
12
|
+
|
|
13
|
+
## What a receipt proves, and what it doesn't
|
|
14
|
+
|
|
15
|
+
A valid signature proves the receipt was issued by the holder of the signing key
|
|
16
|
+
and has not been altered since. The named postconditions were evaluated against
|
|
17
|
+
the named system of record, classified as recorded, at the recorded time, for the
|
|
18
|
+
recorded tenant, in test or live mode as flagged. It does not on its own prove
|
|
19
|
+
that the source system answered truthfully, that the state stayed the same after
|
|
20
|
+
`valid_as_of` (a later receipt can supersede this one when re-verification finds
|
|
21
|
+
the truth changed), or that the action was appropriate in the first place. Trust
|
|
22
|
+
in the receipt is trust in the signing key. Verify against the published key
|
|
23
|
+
registry, and pin it if your risk model calls for that.
|
|
12
24
|
|
|
13
25
|
## 1. Signature scheme
|
|
14
26
|
|
|
@@ -17,6 +29,9 @@ receipt with nothing but the public key and the rules below.
|
|
|
17
29
|
field.
|
|
18
30
|
- **Public key:** base64-encoded raw 32-byte Ed25519 public key. Fetch the current
|
|
19
31
|
key from `GET /v1/signing-key`, which returns `{ algorithm, key_id, public_key }`.
|
|
32
|
+
`GET /v1/signing-keys` returns the full registry, the active key plus retired
|
|
33
|
+
keys kept published so receipts signed before a rotation stay verifiable. Pick
|
|
34
|
+
the entry whose `key_id` matches the receipt's `signing_key_id`.
|
|
20
35
|
- **Key id:** `ed25519:<first-16-chars-of-url-safe-base64(public_key)>`, in
|
|
21
36
|
`signing_key_id`. A receipt names the key that signed it, so old receipts stay
|
|
22
37
|
verifiable after a key rotation.
|
|
@@ -37,6 +52,27 @@ receipt JSON as transmitted. To reproduce the signed bytes:
|
|
|
37
52
|
This is what Python's `json.dumps(body, sort_keys=True, separators=(",", ":"))`
|
|
38
53
|
produces.
|
|
39
54
|
|
|
55
|
+
### Constraints and rejection
|
|
56
|
+
|
|
57
|
+
The scheme is injective over the value domain the signing bodies use, given these
|
|
58
|
+
constraints. A compliant verifier MUST reject anything outside them rather than
|
|
59
|
+
guess.
|
|
60
|
+
|
|
61
|
+
- **Numbers** are finite integers or decimals. `NaN`, `Infinity`, and `-Infinity`
|
|
62
|
+
are not valid JSON and MUST be rejected, not serialized.
|
|
63
|
+
- **Strings** are valid UTF-8. Every code point outside printable ASCII is
|
|
64
|
+
`\uXXXX`-escaped (surrogate pairs for astral characters), so the signed bytes
|
|
65
|
+
are pure ASCII regardless of transport encoding.
|
|
66
|
+
- **Malformed input**, such as a non-base64 signature or public key, a
|
|
67
|
+
wrong-length key, or a missing signed field, MUST cause verification to return
|
|
68
|
+
`false` rather than throw. Both reference implementations do this.
|
|
69
|
+
|
|
70
|
+
Cross-language parity is checked from both sides against one fixed source of
|
|
71
|
+
truth. The golden vectors in `test/vectors/` carry a public key and a signature
|
|
72
|
+
over specific canonical bytes. The TypeScript suite verifies them and the Python
|
|
73
|
+
implementation verifies byte-identical copies, so a canonicalization drift in
|
|
74
|
+
either language fails its own vector test, not only the other's.
|
|
75
|
+
|
|
40
76
|
### Timestamps
|
|
41
77
|
|
|
42
78
|
Timestamps are ISO-8601 UTC. The signer emits a trailing `Z`
|
|
@@ -95,6 +131,50 @@ field is treated as version 1.
|
|
|
95
131
|
}
|
|
96
132
|
```
|
|
97
133
|
|
|
134
|
+
### Version 3 (dual-signature)
|
|
135
|
+
|
|
136
|
+
v3 adds the fields that were server-asserted metadata in v2 to the signed body,
|
|
137
|
+
so they become tamper-evident, and adds an optional observation role. A v3 receipt
|
|
138
|
+
has two signatures that verify independently.
|
|
139
|
+
|
|
140
|
+
- **Evaluation role.** Postcept's signature (the receipt's `signature` field) over
|
|
141
|
+
the v3 signing body. This is what `verifyReceipt` checks.
|
|
142
|
+
- **Observation role.** Present only when a customer-side relay produced the source
|
|
143
|
+
observation. The relay signs its observation envelope, and the receipt binds
|
|
144
|
+
`{relay_id, key_id, digest, signature}` where `digest = "sha256:" + hex(sha256(
|
|
145
|
+
canonicalize(envelope_signing_body)))` and `signature` is the relay's signature
|
|
146
|
+
over that envelope. `verifyObservation(receipt, envelopeSigningBody, relayKey)`
|
|
147
|
+
confirms the digest matches and the relay signature verifies. Neither signer can
|
|
148
|
+
forge the other's statement. The relay attests it observed the source, and
|
|
149
|
+
Postcept attests it evaluated that observation.
|
|
150
|
+
|
|
151
|
+
The v3 signing body is the v2 body with `version: "3"` plus:
|
|
152
|
+
|
|
153
|
+
```jsonc
|
|
154
|
+
{
|
|
155
|
+
// ...all v2 fields...
|
|
156
|
+
"version": "3",
|
|
157
|
+
"algorithm": "ed25519", // the signature scheme, now signed
|
|
158
|
+
"signing_key_id": "ed25519:...", // the key that signed, now signed
|
|
159
|
+
"canonicalization": "postcept-canonical-json-v1", // the scheme in §2, now signed
|
|
160
|
+
"supersedes": "<receipt_id | null>",
|
|
161
|
+
"contract_digest": "sha256:... | null",
|
|
162
|
+
"lifecycle": "finalized | pending_finality | ... | null",
|
|
163
|
+
"safe_to_claim_complete": true, // or false / null
|
|
164
|
+
"correlation_strength": "deterministic | heuristic | ... | null",
|
|
165
|
+
"observation": { // null when no relay produced the source
|
|
166
|
+
"relay_id": "rly_...",
|
|
167
|
+
"key_id": "ed25519:...",
|
|
168
|
+
"digest": "sha256:...",
|
|
169
|
+
"signature": "<base64 relay signature over the envelope>"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
v3 issuance is gated on the server. v1 and v2 receipts remain valid forever, and
|
|
175
|
+
the verifier dispatches on `version`. A verifier MAY require the observation role
|
|
176
|
+
(for high-assurance, relay-only acceptance) or accept evaluation-only receipts.
|
|
177
|
+
|
|
98
178
|
## 4. Verification algorithm
|
|
99
179
|
|
|
100
180
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { asciiJson, base64ToBytes, canonicalize, TIMESTAMP_SPELLINGS } from "./canonical.js";
|
|
2
|
-
export { type Postcondition, type Receipt, receiptSigningBody, verifyReceipt } from "./receipt.js";
|
|
2
|
+
export { CANONICALIZATION_SCHEME, type Observation, type Postcondition, type Receipt, receiptSigningBody, verifyObservation, verifyReceipt, } from "./receipt.js";
|
|
3
3
|
export { type AuditBadge, badgeSigningBody, verifyBadge } from "./badge.js";
|
|
4
4
|
export { type ConsistencyProof, type InclusionProof, type SignedTreeHead, receiptLeafHash, verifyConsistency, verifyInclusion, verifyReceiptInLog, verifySignedTreeHead, } from "./transparency.js";
|
|
5
5
|
export { type EvidenceExportLike, type EvidenceManifest, evidenceContentDigest, verifyEvidenceExport, verifyEvidenceManifest, } from "./evidence.js";
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// and VCR-audit badges. Works in the browser, Node, Deno, and edge runtimes. See
|
|
3
3
|
// SPEC.md for the format and signing scheme.
|
|
4
4
|
export { asciiJson, base64ToBytes, canonicalize, TIMESTAMP_SPELLINGS } from "./canonical.js";
|
|
5
|
-
export { receiptSigningBody, verifyReceipt } from "./receipt.js";
|
|
5
|
+
export { CANONICALIZATION_SCHEME, receiptSigningBody, verifyObservation, verifyReceipt, } from "./receipt.js";
|
|
6
6
|
export { badgeSigningBody, verifyBadge } from "./badge.js";
|
|
7
7
|
export { receiptLeafHash, verifyConsistency, verifyInclusion, verifyReceiptInLog, verifySignedTreeHead, } from "./transparency.js";
|
|
8
8
|
export { evidenceContentDigest, verifyEvidenceExport, verifyEvidenceManifest, } from "./evidence.js";
|
package/dist/receipt.d.ts
CHANGED
|
@@ -22,11 +22,23 @@ export interface Receipt {
|
|
|
22
22
|
algorithm?: string;
|
|
23
23
|
signing_key_id?: string;
|
|
24
24
|
signature: string;
|
|
25
|
+
supersedes?: string | null;
|
|
26
|
+
contract_digest?: string | null;
|
|
27
|
+
lifecycle?: string | null;
|
|
28
|
+
safe_to_claim_complete?: boolean | null;
|
|
29
|
+
correlation_strength?: string | null;
|
|
30
|
+
observation_relay_id?: string | null;
|
|
31
|
+
observation_key_id?: string | null;
|
|
32
|
+
observation_digest?: string | null;
|
|
33
|
+
observation_signature?: string | null;
|
|
25
34
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
export interface Observation {
|
|
36
|
+
relay_id: string | null;
|
|
37
|
+
key_id: string | null;
|
|
38
|
+
digest: string | null;
|
|
39
|
+
signature: string | null;
|
|
40
|
+
}
|
|
41
|
+
export declare const CANONICALIZATION_SCHEME = "postcept-canonical-json-v1";
|
|
30
42
|
export declare function receiptSigningBody(r: Receipt, ts: (s: string) => string): Record<string, unknown>;
|
|
31
43
|
/**
|
|
32
44
|
* Verify a receipt's Ed25519 signature against a published public key (base64 raw
|
|
@@ -34,3 +46,4 @@ export declare function receiptSigningBody(r: Receipt, ts: (s: string) => string
|
|
|
34
46
|
* input instead of throwing.
|
|
35
47
|
*/
|
|
36
48
|
export declare function verifyReceipt(receipt: Receipt, publicKeyB64: string): Promise<boolean>;
|
|
49
|
+
export declare function verifyObservation(receipt: Receipt, envelopeSigningBody: Record<string, unknown>, relayPublicKeyB64: string): Promise<boolean>;
|
package/dist/receipt.js
CHANGED
|
@@ -1,34 +1,68 @@
|
|
|
1
1
|
// The signed receipt object and its verification. Reference implementation of the
|
|
2
2
|
// open standard in SPEC.md.
|
|
3
3
|
import * as ed from "@noble/ed25519";
|
|
4
|
-
import { base64ToBytes, canonicalize, TIMESTAMP_SPELLINGS } from "./canonical.js";
|
|
4
|
+
import { base64ToBytes, canonicalize, sha256Hex, TIMESTAMP_SPELLINGS } from "./canonical.js";
|
|
5
|
+
// Stable identifier for the canonical-JSON scheme (sorted keys, no whitespace,
|
|
6
|
+
// non-ASCII and control characters escaped). Bound into the v3 signing body so the
|
|
7
|
+
// signature covers which scheme produced the bytes. Matches CANONICALIZATION_SCHEME
|
|
8
|
+
// in the control plane. Bump both together if the scheme ever changes.
|
|
9
|
+
export const CANONICALIZATION_SCHEME = "postcept-canonical-json-v1";
|
|
5
10
|
/**
|
|
6
11
|
* The exact subset of a receipt the signature covers, by version. `ts` applies a
|
|
7
12
|
* timestamp spelling so the caller can retry "Z" vs "+00:00" (see verifyReceipt).
|
|
8
13
|
*/
|
|
14
|
+
function receiptSigningBodyV2(r, ts) {
|
|
15
|
+
return {
|
|
16
|
+
version: "2",
|
|
17
|
+
id: r.id,
|
|
18
|
+
org_id: r.org_id ?? null,
|
|
19
|
+
operation_id: r.operation_id,
|
|
20
|
+
agent_id: r.agent_id,
|
|
21
|
+
action: r.action,
|
|
22
|
+
connectors_checked: r.connectors_checked,
|
|
23
|
+
test: r.test ?? false,
|
|
24
|
+
postconditions: (r.postconditions ?? []).map((p) => ({
|
|
25
|
+
name: p.name,
|
|
26
|
+
category: p.category ?? null,
|
|
27
|
+
status: p.status,
|
|
28
|
+
expected: p.expected ?? null,
|
|
29
|
+
actual: p.actual ?? null,
|
|
30
|
+
})),
|
|
31
|
+
result: r.result,
|
|
32
|
+
issued_at: ts(r.issued_at),
|
|
33
|
+
valid_as_of: r.valid_as_of ? ts(r.valid_as_of) : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
9
36
|
export function receiptSigningBody(r, ts) {
|
|
10
|
-
if (r.version === "
|
|
37
|
+
if (r.version === "3") {
|
|
38
|
+
// v3 extends the v2 body with the fields that were server-asserted metadata
|
|
39
|
+
// in v2, plus the observation role when a relay produced the source.
|
|
40
|
+
const v2 = receiptSigningBodyV2(r, ts);
|
|
11
41
|
return {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
...v2,
|
|
43
|
+
version: "3",
|
|
44
|
+
// Bind the signature metadata into the protected content (see CANONICALIZATION_SCHEME).
|
|
45
|
+
algorithm: r.algorithm ?? "ed25519",
|
|
46
|
+
signing_key_id: r.signing_key_id ?? null,
|
|
47
|
+
canonicalization: CANONICALIZATION_SCHEME,
|
|
48
|
+
supersedes: r.supersedes ?? null,
|
|
49
|
+
contract_digest: r.contract_digest ?? null,
|
|
50
|
+
lifecycle: r.lifecycle ?? null,
|
|
51
|
+
safe_to_claim_complete: r.safe_to_claim_complete ?? null,
|
|
52
|
+
correlation_strength: r.correlation_strength ?? null,
|
|
53
|
+
observation: r.observation_signature != null
|
|
54
|
+
? {
|
|
55
|
+
relay_id: r.observation_relay_id ?? null,
|
|
56
|
+
key_id: r.observation_key_id ?? null,
|
|
57
|
+
digest: r.observation_digest ?? null,
|
|
58
|
+
signature: r.observation_signature,
|
|
59
|
+
}
|
|
60
|
+
: null,
|
|
30
61
|
};
|
|
31
62
|
}
|
|
63
|
+
if (r.version === "2") {
|
|
64
|
+
return receiptSigningBodyV2(r, ts);
|
|
65
|
+
}
|
|
32
66
|
// Legacy v1 body (name + status only).
|
|
33
67
|
return {
|
|
34
68
|
id: r.id,
|
|
@@ -62,3 +96,26 @@ export async function verifyReceipt(receipt, publicKeyB64) {
|
|
|
62
96
|
return false;
|
|
63
97
|
}
|
|
64
98
|
}
|
|
99
|
+
// The observation role of a v3 receipt. `verifyReceipt` checks the evaluation
|
|
100
|
+
// role, where Postcept signed the result. This checks the source observation:
|
|
101
|
+
// given the original relay envelope's signing body, that it hashes to the digest
|
|
102
|
+
// in the receipt and that the relay's signature verifies against the relay's
|
|
103
|
+
// public key. The two roles together show that the relay observed the source and
|
|
104
|
+
// Postcept evaluated it, with neither able to forge the other's statement.
|
|
105
|
+
// Returns false, never throws, if there is no observation role or anything fails.
|
|
106
|
+
export async function verifyObservation(receipt, envelopeSigningBody, relayPublicKeyB64) {
|
|
107
|
+
try {
|
|
108
|
+
if (receipt.observation_signature == null || receipt.observation_digest == null)
|
|
109
|
+
return false;
|
|
110
|
+
const canonical = new TextEncoder().encode(canonicalize(envelopeSigningBody));
|
|
111
|
+
const digest = `sha256:${await sha256Hex(canonical)}`;
|
|
112
|
+
if (digest !== receipt.observation_digest)
|
|
113
|
+
return false;
|
|
114
|
+
const sig = base64ToBytes(receipt.observation_signature);
|
|
115
|
+
const pub = base64ToBytes(relayPublicKeyB64);
|
|
116
|
+
return await ed.verifyAsync(sig, canonical, pub);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postcept/receipt",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Independently verify a Postcept Receipt, the signed proof that an AI agent's high-risk action actually happened in the system of record. Runs anywhere, needs only the public key.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Postcept",
|
|
7
7
|
"type": "module",
|