@adastracomputing/ink 0.3.0 → 0.5.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/CHANGELOG.md +75 -0
- package/README.md +14 -1
- package/bin/verify-inclusion-impl.mjs +171 -23
- package/dist/audit/inclusion-receipt.d.ts +33 -8
- package/dist/audit/inclusion-receipt.js +125 -5
- package/dist/crypto/ink.js +12 -4
- package/dist/crypto/keys.d.ts +19 -0
- package/dist/crypto/keys.js +39 -0
- package/dist/crypto/sign.d.ts +21 -0
- package/dist/crypto/sign.js +26 -2
- package/dist/discovery/agent-card.d.ts +9 -7
- package/dist/index.d.ts +4 -4
- package/dist/index.js +6 -6
- package/dist/ink/checkpoint.d.ts +21 -0
- package/dist/ink/checkpoint.js +79 -0
- package/dist/ink/discovery-gating.js +4 -4
- package/dist/ink/receipts.d.ts +33 -1
- package/dist/ink/receipts.js +45 -1
- package/dist/middleware/ink-auth.d.ts +1 -0
- package/dist/middleware/ink-auth.js +7 -4
- package/dist/models/agent-card.js +22 -22
- package/dist/models/ink-audit.js +40 -36
- package/dist/models/ink-handshake.js +13 -13
- package/dist/models/intent.d.ts +2 -2
- package/dist/models/intent.js +9 -0
- package/docs/maturity.md +7 -1
- package/package.json +8 -7
package/dist/crypto/keys.d.ts
CHANGED
|
@@ -53,3 +53,22 @@ export declare function deriveAgentId(publicKey: Uint8Array): string;
|
|
|
53
53
|
* same way for both, so a malformed tail is rejected identically.
|
|
54
54
|
*/
|
|
55
55
|
export declare function extractPublicKeyFromAgentId(agentId: string): Uint8Array;
|
|
56
|
+
/**
|
|
57
|
+
* Collapse an agent ID to a single, prefix-independent principal string that
|
|
58
|
+
* per-sender security state (block lists, rate limits, duplicate-payload
|
|
59
|
+
* checks, cached verification keys, connection identity) MUST key on.
|
|
60
|
+
*
|
|
61
|
+
* The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
|
|
62
|
+
* key and are therefore the same actor; this maps both — and any non-canonical
|
|
63
|
+
* multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
|
|
64
|
+
* cannot switch prefix or re-encode to dodge a block or split a rate-limit
|
|
65
|
+
* window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
|
|
66
|
+
* input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
|
|
67
|
+
* cannot forge a collision with a canonicalized key principal.
|
|
68
|
+
*
|
|
69
|
+
* Not idempotent: call exactly once, at the storage boundary, on the raw
|
|
70
|
+
* agent ID. Total over well-formed string input (it never throws on a
|
|
71
|
+
* malformed key body — that is escaped to `raw:…` so a principal is always
|
|
72
|
+
* derivable); throws only on a non-string, empty, or over-length argument.
|
|
73
|
+
*/
|
|
74
|
+
export declare function canonicalAgentPrincipal(agentId: string): string;
|
package/dist/crypto/keys.js
CHANGED
|
@@ -190,3 +190,42 @@ export function extractPublicKeyFromAgentId(agentId) {
|
|
|
190
190
|
}
|
|
191
191
|
return decodePublicKeyMultibase(agentId.slice(prefix.length));
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Collapse an agent ID to a single, prefix-independent principal string that
|
|
195
|
+
* per-sender security state (block lists, rate limits, duplicate-payload
|
|
196
|
+
* checks, cached verification keys, connection identity) MUST key on.
|
|
197
|
+
*
|
|
198
|
+
* The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
|
|
199
|
+
* key and are therefore the same actor; this maps both — and any non-canonical
|
|
200
|
+
* multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
|
|
201
|
+
* cannot switch prefix or re-encode to dodge a block or split a rate-limit
|
|
202
|
+
* window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
|
|
203
|
+
* input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
|
|
204
|
+
* cannot forge a collision with a canonicalized key principal.
|
|
205
|
+
*
|
|
206
|
+
* Not idempotent: call exactly once, at the storage boundary, on the raw
|
|
207
|
+
* agent ID. Total over well-formed string input (it never throws on a
|
|
208
|
+
* malformed key body — that is escaped to `raw:…` so a principal is always
|
|
209
|
+
* derivable); throws only on a non-string, empty, or over-length argument.
|
|
210
|
+
*/
|
|
211
|
+
export function canonicalAgentPrincipal(agentId) {
|
|
212
|
+
if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
|
|
213
|
+
throw new Error("Invalid agent ID");
|
|
214
|
+
}
|
|
215
|
+
const prefix = AGENT_ID_KEY_PREFIXES.find((p) => agentId.startsWith(p));
|
|
216
|
+
if (prefix) {
|
|
217
|
+
try {
|
|
218
|
+
return "key:" + encodePublicKeyMultibase(decodePublicKeyMultibase(agentId.slice(prefix.length)));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Malformed multibase body: keep the function total by treating it as an
|
|
222
|
+
// opaque identifier. Such an ID cannot authenticate via the bootstrap
|
|
223
|
+
// path anyway, so it never collides with a real key principal.
|
|
224
|
+
return "raw:" + agentId;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (agentId.startsWith("key:")) {
|
|
228
|
+
return "raw:" + agentId;
|
|
229
|
+
}
|
|
230
|
+
return agentId;
|
|
231
|
+
}
|
package/dist/crypto/sign.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number is safe for canonical JSON only if every conforming canonicalizer
|
|
3
|
+
* serializes it identically. We reject non-finite values (not valid JSON),
|
|
4
|
+
* negative zero (serializes as `0`, losing the sign), and any value whose
|
|
5
|
+
* shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
|
|
6
|
+
* forms are exactly where JSON serializers and strict RFC 8785 disagree.
|
|
7
|
+
* Rejecting them keeps the signed-byte representation unambiguous across
|
|
8
|
+
* implementations (the reference and a future second implementation), without
|
|
9
|
+
* affecting the small integers and plain decimals INK payloads actually carry.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isJcsSafeNumber(n: number): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Cheap depth/node/byte walk over a value before it is handed to
|
|
14
|
+
* `canonicalize`. Bails before the recursive sort+serialize runs, so an
|
|
15
|
+
* attacker who supplies a syntactically valid-shape signature with a
|
|
16
|
+
* pathological message body cannot burn CPU/memory inside the verify
|
|
17
|
+
* path. Mirrors src/crypto/ink.ts:isWithinCanonicalizeBounds, including
|
|
18
|
+
* the byte counter that stops a single huge string from sneaking past
|
|
19
|
+
* the node check.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isWithinBounds(value: unknown): boolean;
|
|
1
22
|
/**
|
|
2
23
|
* Sign a message object using Ed25519.
|
|
3
24
|
*
|
package/dist/crypto/sign.js
CHANGED
|
@@ -10,6 +10,23 @@ const MAX_MESSAGE_CHARS = 1_200_000;
|
|
|
10
10
|
* walk: a message can be small in node count but still expand to huge
|
|
11
11
|
* canonical bytes via long string values. */
|
|
12
12
|
const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
|
|
13
|
+
/**
|
|
14
|
+
* A number is safe for canonical JSON only if every conforming canonicalizer
|
|
15
|
+
* serializes it identically. We reject non-finite values (not valid JSON),
|
|
16
|
+
* negative zero (serializes as `0`, losing the sign), and any value whose
|
|
17
|
+
* shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
|
|
18
|
+
* forms are exactly where JSON serializers and strict RFC 8785 disagree.
|
|
19
|
+
* Rejecting them keeps the signed-byte representation unambiguous across
|
|
20
|
+
* implementations (the reference and a future second implementation), without
|
|
21
|
+
* affecting the small integers and plain decimals INK payloads actually carry.
|
|
22
|
+
*/
|
|
23
|
+
export function isJcsSafeNumber(n) {
|
|
24
|
+
if (!Number.isFinite(n))
|
|
25
|
+
return false;
|
|
26
|
+
if (Object.is(n, -0))
|
|
27
|
+
return false;
|
|
28
|
+
return !/[eE]/.test(String(n));
|
|
29
|
+
}
|
|
13
30
|
/**
|
|
14
31
|
* Cheap depth/node/byte walk over a value before it is handed to
|
|
15
32
|
* `canonicalize`. Bails before the recursive sort+serialize runs, so an
|
|
@@ -19,7 +36,7 @@ const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
|
|
|
19
36
|
* the byte counter that stops a single huge string from sneaking past
|
|
20
37
|
* the node check.
|
|
21
38
|
*/
|
|
22
|
-
function isWithinBounds(value) {
|
|
39
|
+
export function isWithinBounds(value) {
|
|
23
40
|
let nodes = 0;
|
|
24
41
|
let chars = 0;
|
|
25
42
|
function walk(v, depth) {
|
|
@@ -33,6 +50,9 @@ function isWithinBounds(value) {
|
|
|
33
50
|
if (chars > MAX_MESSAGE_CHARS)
|
|
34
51
|
return false;
|
|
35
52
|
}
|
|
53
|
+
else if (typeof v === "number" && !isJcsSafeNumber(v)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
36
56
|
return true;
|
|
37
57
|
}
|
|
38
58
|
if (Array.isArray(v)) {
|
|
@@ -162,7 +182,11 @@ export async function verifyMessage(message, publicKey) {
|
|
|
162
182
|
const prefixedBytes = new TextEncoder().encode(prefixed);
|
|
163
183
|
try {
|
|
164
184
|
const sig = base64urlDecode(signature);
|
|
165
|
-
|
|
185
|
+
// RFC 8032 strict verification, not the library default ZIP-215 mode:
|
|
186
|
+
// reject small-order public keys and non-canonical point encodings so a
|
|
187
|
+
// signature binds to exactly one (key, message). Identity is the embedded
|
|
188
|
+
// public key and signatures feed the audit log, so strictness is required.
|
|
189
|
+
return await ed.verifyAsync(sig, prefixedBytes, publicKey, { zip215: false });
|
|
166
190
|
}
|
|
167
191
|
catch {
|
|
168
192
|
// Malformed signature (invalid base64url, wrong byte length, bad key) — treat as invalid
|
|
@@ -24,13 +24,15 @@ export interface FetchAgentCardOptions {
|
|
|
24
24
|
* connect targets (e.g. undici with a custom dispatcher on Node, or
|
|
25
25
|
* `cf: { resolveOverride: validatedIp }` on Cloudflare Workers). */
|
|
26
26
|
fetch?: typeof fetch;
|
|
27
|
-
/** Strict mode: require that the caller supply `options.fetch
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
/** Strict mode: require that the caller supply `options.fetch`, returning
|
|
28
|
+
* null (without fetching) if it is absent. This only guarantees that *some*
|
|
29
|
+
* fetch override was provided; it does NOT and cannot verify that the
|
|
30
|
+
* override pins connect-time IPs, so passing `requireSafeFetch: true` with
|
|
31
|
+
* the plain global `fetch` does not close the DNS-rebinding window. The
|
|
32
|
+
* literal-private-IP allowlist this module applies to `baseUrl` does not stop
|
|
33
|
+
* a public hostname that resolves to a private address at fetch time; only a
|
|
34
|
+
* connect-time-IP-pinning `options.fetch` (for example a custom undici
|
|
35
|
+
* dispatcher) does. Off by default for backwards compatibility. */
|
|
34
36
|
requireSafeFetch?: boolean;
|
|
35
37
|
}
|
|
36
38
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export { signInkMessage, verifyInkSignature, buildSignatureBase, buildAuthHeader, computeMessageHash, computeEventHash, computeAuditMerkleLeafHash, signAuditEvent, verifyAuditEventSignature, signAuditResponse, verifyAuditResponseSignature, verifyAuditEventChain, signAuditQueryResponse, verifyAuditQueryResponseSignature, encryptInkPayload, decryptInkPayload, checkReplay, base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS, } from "./crypto/ink.js";
|
|
2
2
|
export { signMessage, verifyMessage } from "./crypto/sign.js";
|
|
3
3
|
export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
|
|
4
|
-
export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
|
|
4
|
+
export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, canonicalAgentPrincipal, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
|
|
5
5
|
export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
|
|
6
6
|
export { verifyInkAuth, type NonceStore } from "./middleware/ink-auth.js";
|
|
7
|
-
export { verifyInclusionReceipt, verifyAuditQueryResponse, type InclusionReceipt, type InclusionReceiptVerifyResult, type AuditQueryResponse, type AuditQueryResponseVerifyResult, type VerifyStep, } from "./audit/inclusion-receipt.js";
|
|
7
|
+
export { verifyInclusionReceipt, verifyConsistencyProof, verifyAuditQueryResponse, type InclusionReceipt, type InclusionReceiptVerifyResult, type AuditQueryResponse, type AuditQueryResponseVerifyResult, type VerifyStep, } from "./audit/inclusion-receipt.js";
|
|
8
8
|
export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
|
|
9
|
-
export { buildReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
|
|
9
|
+
export { buildReceipt, verifyReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
|
|
10
10
|
export { resolveEffectiveTransports, checkTransportAllowed, } from "./ink/transport-auth.js";
|
|
11
11
|
export { buildRedactedCard, shouldRedactOnGet, AgentCardQuerySchema, } from "./ink/discovery-gating.js";
|
|
12
|
-
export { parseCheckpoint, formatCheckpoint, } from "./ink/checkpoint.js";
|
|
12
|
+
export { parseCheckpoint, formatCheckpoint, verifyCheckpoint, } from "./ink/checkpoint.js";
|
|
13
13
|
export type { CheckpointData } from "./ink/checkpoint.js";
|
|
14
14
|
export { InkAuditEventTypeSchema, InkAuditEventSchema, InkAuditInclusionSchema, InkReceiptSchema, InkAuditQuerySchema, InkIntroductionReceiptSchema, } from "./models/ink-audit.js";
|
|
15
15
|
export type { InkAuditEventType, InkAuditEvent, InkAuditInclusion, InkReceipt, InkAuditQuery, InkAuditResponse, InkIntroductionReceiptStatus, } from "./models/ink-audit.js";
|
package/dist/index.js
CHANGED
|
@@ -4,23 +4,23 @@
|
|
|
4
4
|
export { signInkMessage, verifyInkSignature, buildSignatureBase, buildAuthHeader, computeMessageHash, computeEventHash, computeAuditMerkleLeafHash, signAuditEvent, verifyAuditEventSignature, signAuditResponse, verifyAuditResponseSignature, verifyAuditEventChain, signAuditQueryResponse, verifyAuditQueryResponseSignature, encryptInkPayload, decryptInkPayload, checkReplay, base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS, } from "./crypto/ink.js";
|
|
5
5
|
export { signMessage, verifyMessage } from "./crypto/sign.js";
|
|
6
6
|
export { verifyInkSignatureWithKeys } from "./crypto/multi-key-verify.js";
|
|
7
|
-
export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
|
|
7
|
+
export { generateKeypair, generateEncryptionKeypair, deriveAgentId, encodePublicKeyMultibase, encodeEncryptionKeyMultibase, decodePublicKeyMultibase, decodeEncryptionKeyMultibase, extractPublicKeyFromAgentId, canonicalAgentPrincipal, AGENT_ID_KEY_PREFIXES, } from "./crypto/keys.js";
|
|
8
8
|
// Discovery: Agent Card fetch + candidate-key extraction
|
|
9
9
|
export { fetchAgentCard, extractCandidateKeys, resolveBaseUrl, } from "./discovery/agent-card.js";
|
|
10
10
|
// Middleware: transport-level INK auth
|
|
11
11
|
export { verifyInkAuth } from "./middleware/ink-auth.js";
|
|
12
12
|
// Audit: inclusion-receipt + audit-query-response verification
|
|
13
|
-
export { verifyInclusionReceipt, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
|
|
13
|
+
export { verifyInclusionReceipt, verifyConsistencyProof, verifyAuditQueryResponse, } from "./audit/inclusion-receipt.js";
|
|
14
14
|
// Optional containment / governance primitives
|
|
15
15
|
export { HandshakeBudgetTracker } from "./ink/handshake-budget.js";
|
|
16
|
-
// Receipts: build and send INK delivery receipts
|
|
17
|
-
export { buildReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
|
|
16
|
+
// Receipts: build, verify, and send INK delivery receipts
|
|
17
|
+
export { buildReceipt, verifyReceipt, shouldSendReceipt, sendReceiptFireAndForget, } from "./ink/receipts.js";
|
|
18
18
|
// Transport-auth: token-level transport allowlist for extension tokens
|
|
19
19
|
export { resolveEffectiveTransports, checkTransportAllowed, } from "./ink/transport-auth.js";
|
|
20
20
|
// Discovery-gating: visibility-aware Agent Card redaction
|
|
21
21
|
export { buildRedactedCard, shouldRedactOnGet, AgentCardQuerySchema, } from "./ink/discovery-gating.js";
|
|
22
|
-
// Checkpoint parsing for transparency-log
|
|
23
|
-
export { parseCheckpoint, formatCheckpoint, } from "./ink/checkpoint.js";
|
|
22
|
+
// Checkpoint parsing and signature verification for transparency-log checkpoints
|
|
23
|
+
export { parseCheckpoint, formatCheckpoint, verifyCheckpoint, } from "./ink/checkpoint.js";
|
|
24
24
|
// Audit event schemas + types for receipts, query, inclusion proofs
|
|
25
25
|
export { InkAuditEventTypeSchema, InkAuditEventSchema, InkAuditInclusionSchema, InkReceiptSchema, InkAuditQuerySchema, InkIntroductionReceiptSchema, } from "./models/ink-audit.js";
|
|
26
26
|
// Handshake message schemas
|
package/dist/ink/checkpoint.d.ts
CHANGED
|
@@ -17,3 +17,24 @@ export interface CheckpointData {
|
|
|
17
17
|
export declare function formatCheckpoint(data: CheckpointData): string;
|
|
18
18
|
/** Parse a checkpoint body. Returns null if invalid. */
|
|
19
19
|
export declare function parseCheckpoint(body: string): CheckpointData | null;
|
|
20
|
+
/**
|
|
21
|
+
* Verify a signed checkpoint and return its parsed body, or `null` if the
|
|
22
|
+
* signature, origin, or format is invalid.
|
|
23
|
+
*
|
|
24
|
+
* The signed form is the C2SP-style note used by the INK witness:
|
|
25
|
+
*
|
|
26
|
+
* <origin>\n<treeSize>\n<rootHash>\n\n-- <origin> <base64url(sig)>\n
|
|
27
|
+
*
|
|
28
|
+
* The Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
|
|
29
|
+
* exactly (no trailing newline), so the `origin` first line is the domain
|
|
30
|
+
* separator binding the signed bytes to this log. Verification REQUIRES the
|
|
31
|
+
* caller's `expectedOrigin`: a checkpoint whose body origin, or whose matching
|
|
32
|
+
* signature-line origin, is not `expectedOrigin` is rejected, so a witness that
|
|
33
|
+
* operates several logs (or an attacker replaying another log's signed
|
|
34
|
+
* checkpoint) cannot substitute a different tree. This is the authenticated
|
|
35
|
+
* input that anti-rollback / freshness checks MUST consume; an unverified
|
|
36
|
+
* checkpoint body provides no security.
|
|
37
|
+
*
|
|
38
|
+
* Verification uses RFC 8032 strict mode (small-order keys rejected).
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyCheckpoint(signed: string, witnessPublicKey: Uint8Array, expectedOrigin: string): Promise<CheckpointData | null>;
|
package/dist/ink/checkpoint.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* INK Checkpoint formatting (C2SP tlog-checkpoint compatible).
|
|
3
3
|
* Used for the public checkpoint endpoint (INK Auditability §7.7).
|
|
4
4
|
*/
|
|
5
|
+
import * as ed from "@noble/ed25519";
|
|
6
|
+
import { base64urlDecode } from "../crypto/ink.js";
|
|
5
7
|
/**
|
|
6
8
|
* Format a checkpoint body per C2SP tlog-checkpoint spec:
|
|
7
9
|
* line 1: origin (log identity)
|
|
@@ -67,3 +69,80 @@ export function parseCheckpoint(body) {
|
|
|
67
69
|
return null;
|
|
68
70
|
return { origin, treeSize, rootHash };
|
|
69
71
|
}
|
|
72
|
+
/** A signed checkpoint is the 3-line body, a blank line, then one or more
|
|
73
|
+
* signature lines, plus a trailing newline. Bound the whole thing so an
|
|
74
|
+
* attacker-supplied blob cannot drive large scans before rejection. */
|
|
75
|
+
const MAX_SIGNED_CHECKPOINT_BODY = 4096;
|
|
76
|
+
/** Cap the number of cosignature lines a verifier will scan. */
|
|
77
|
+
const MAX_CHECKPOINT_SIGNATURES = 8;
|
|
78
|
+
/**
|
|
79
|
+
* Verify a signed checkpoint and return its parsed body, or `null` if the
|
|
80
|
+
* signature, origin, or format is invalid.
|
|
81
|
+
*
|
|
82
|
+
* The signed form is the C2SP-style note used by the INK witness:
|
|
83
|
+
*
|
|
84
|
+
* <origin>\n<treeSize>\n<rootHash>\n\n-- <origin> <base64url(sig)>\n
|
|
85
|
+
*
|
|
86
|
+
* The Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
|
|
87
|
+
* exactly (no trailing newline), so the `origin` first line is the domain
|
|
88
|
+
* separator binding the signed bytes to this log. Verification REQUIRES the
|
|
89
|
+
* caller's `expectedOrigin`: a checkpoint whose body origin, or whose matching
|
|
90
|
+
* signature-line origin, is not `expectedOrigin` is rejected, so a witness that
|
|
91
|
+
* operates several logs (or an attacker replaying another log's signed
|
|
92
|
+
* checkpoint) cannot substitute a different tree. This is the authenticated
|
|
93
|
+
* input that anti-rollback / freshness checks MUST consume; an unverified
|
|
94
|
+
* checkpoint body provides no security.
|
|
95
|
+
*
|
|
96
|
+
* Verification uses RFC 8032 strict mode (small-order keys rejected).
|
|
97
|
+
*/
|
|
98
|
+
export async function verifyCheckpoint(signed, witnessPublicKey, expectedOrigin) {
|
|
99
|
+
if (typeof signed !== "string" || signed.length === 0 || signed.length > MAX_SIGNED_CHECKPOINT_BODY) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!(witnessPublicKey instanceof Uint8Array) || witnessPublicKey.length !== 32) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0 || expectedOrigin.length > MAX_CHECKPOINT_LINE) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const SEP = "\n\n-- ";
|
|
109
|
+
const idx = signed.indexOf(SEP);
|
|
110
|
+
if (idx === -1)
|
|
111
|
+
return null;
|
|
112
|
+
const body = signed.slice(0, idx); // <origin>\n<treeSize>\n<rootHash>
|
|
113
|
+
const data = parseCheckpoint(body + "\n");
|
|
114
|
+
if (!data)
|
|
115
|
+
return null;
|
|
116
|
+
// Bind the body's own origin to the caller's expectation before any crypto.
|
|
117
|
+
if (data.origin !== expectedOrigin)
|
|
118
|
+
return null;
|
|
119
|
+
// Signature block starts at the "-- " that began the separator.
|
|
120
|
+
const sigBlock = signed.slice(idx + 2);
|
|
121
|
+
const sigLines = sigBlock.split("\n").filter((l) => l.length > 0);
|
|
122
|
+
if (sigLines.length === 0 || sigLines.length > MAX_CHECKPOINT_SIGNATURES)
|
|
123
|
+
return null;
|
|
124
|
+
const bodyBytes = new TextEncoder().encode(body);
|
|
125
|
+
for (const line of sigLines) {
|
|
126
|
+
if (!line.startsWith("-- "))
|
|
127
|
+
return null; // any malformed signature line is fatal
|
|
128
|
+
const rest = line.slice(3);
|
|
129
|
+
const sp = rest.indexOf(" ");
|
|
130
|
+
if (sp === -1)
|
|
131
|
+
return null;
|
|
132
|
+
const lineOrigin = rest.slice(0, sp);
|
|
133
|
+
const sigB64 = rest.slice(sp + 1);
|
|
134
|
+
if (lineOrigin !== expectedOrigin)
|
|
135
|
+
continue; // a cosigner whose key we were not given
|
|
136
|
+
try {
|
|
137
|
+
const sig = base64urlDecode(sigB64);
|
|
138
|
+
if (sig.length !== 64)
|
|
139
|
+
return null;
|
|
140
|
+
const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
|
|
141
|
+
return ok ? data : null; // a matching-origin signature that fails verification is fatal
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null; // no signature line for the expected origin
|
|
148
|
+
}
|
|
@@ -66,16 +66,16 @@ export function buildRedactedCard(card) {
|
|
|
66
66
|
export const AgentCardQuerySchema = z.object({
|
|
67
67
|
protocol: z.literal("ink/0.1"),
|
|
68
68
|
type: z.literal("network.tulpa.agent_card_query"),
|
|
69
|
-
from: z.string(),
|
|
70
|
-
nonce: z.string(),
|
|
69
|
+
from: z.string().max(512),
|
|
70
|
+
nonce: z.string().max(256),
|
|
71
71
|
timestamp: z.string().datetime(),
|
|
72
|
-
requestedFields: z.array(z.string()).optional(),
|
|
72
|
+
requestedFields: z.array(z.string().max(64)).max(32).optional(),
|
|
73
73
|
});
|
|
74
74
|
export const AgentCardResponseSchema = z.object({
|
|
75
75
|
protocol: z.literal("ink/0.1"),
|
|
76
76
|
type: z.literal("network.tulpa.agent_card_response"),
|
|
77
77
|
card: AgentCardSchema,
|
|
78
|
-
grantedFields: z.array(z.string()),
|
|
78
|
+
grantedFields: z.array(z.string().max(64)).max(32),
|
|
79
79
|
timestamp: z.string().datetime(),
|
|
80
80
|
});
|
|
81
81
|
export const AgentCardDeniedSchema = z.object({
|
package/dist/ink/receipts.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InkReceipt } from "../models/ink-audit.js";
|
|
2
2
|
export interface BuildReceiptInput {
|
|
3
3
|
from: string;
|
|
4
4
|
to: string;
|
|
@@ -10,6 +10,38 @@ export interface BuildReceiptInput {
|
|
|
10
10
|
}
|
|
11
11
|
/** Build a signed INK receipt envelope. */
|
|
12
12
|
export declare function buildReceipt(input: BuildReceiptInput): Promise<InkReceipt>;
|
|
13
|
+
export interface VerifyReceiptResult {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
/** Machine-readable reason when `valid` is false. */
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Verify an INK receipt against the message it claims to acknowledge.
|
|
20
|
+
*
|
|
21
|
+
* Checks all the bindings a hand-rolled verifier commonly forgets: the
|
|
22
|
+
* receipt's Ed25519 signature against the issuer's (`from`) key, that `from`,
|
|
23
|
+
* `to` and `messageId` equal the expected values, and that `messageHash`
|
|
24
|
+
* equals the hash of the exact message body that was sent (recomputed here,
|
|
25
|
+
* not trusted from the receipt). A receipt that passes proves the named
|
|
26
|
+
* counterparty acknowledged that specific message; nothing weaker should be
|
|
27
|
+
* treated as proof of delivery.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyReceipt(opts: {
|
|
30
|
+
receipt: unknown;
|
|
31
|
+
/** Raw 32-byte Ed25519 public key of the issuer (the receipt's `from`). */
|
|
32
|
+
senderPublicKey: Uint8Array;
|
|
33
|
+
expected: {
|
|
34
|
+
from: string;
|
|
35
|
+
to: string;
|
|
36
|
+
messageId: string;
|
|
37
|
+
messageBody: Record<string, unknown>;
|
|
38
|
+
/** When set, require the receipt to acknowledge this specific disposition
|
|
39
|
+
* (e.g. "delivered"). The disposition is covered by the signature, but
|
|
40
|
+
* without this a signed receipt for a different state would still pass, so
|
|
41
|
+
* callers proving a specific delivery state MUST pin it. */
|
|
42
|
+
disposition?: InkReceipt["disposition"];
|
|
43
|
+
};
|
|
44
|
+
}): Promise<VerifyReceiptResult>;
|
|
13
45
|
export declare function shouldSendReceipt(intentOrType: string): boolean;
|
|
14
46
|
export interface SendReceiptOptions {
|
|
15
47
|
/** Allow endpoints whose hostname is loopback / private / link-local /
|
package/dist/ink/receipts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computeMessageHash, signInkMessage, buildAuthHeader } from "../crypto/ink.js";
|
|
2
|
-
import { signMessage } from "../crypto/sign.js";
|
|
2
|
+
import { signMessage, verifyMessage } from "../crypto/sign.js";
|
|
3
3
|
import { isPrivateHostname } from "../discovery/agent-card.js";
|
|
4
|
+
import { InkReceiptSchema } from "../models/ink-audit.js";
|
|
4
5
|
/** Build a signed INK receipt envelope. */
|
|
5
6
|
export async function buildReceipt(input) {
|
|
6
7
|
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
@@ -28,6 +29,49 @@ export async function buildReceipt(input) {
|
|
|
28
29
|
const signature = await signMessage(unsigned, input.privateKey);
|
|
29
30
|
return { ...unsigned, signature };
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Verify an INK receipt against the message it claims to acknowledge.
|
|
34
|
+
*
|
|
35
|
+
* Checks all the bindings a hand-rolled verifier commonly forgets: the
|
|
36
|
+
* receipt's Ed25519 signature against the issuer's (`from`) key, that `from`,
|
|
37
|
+
* `to` and `messageId` equal the expected values, and that `messageHash`
|
|
38
|
+
* equals the hash of the exact message body that was sent (recomputed here,
|
|
39
|
+
* not trusted from the receipt). A receipt that passes proves the named
|
|
40
|
+
* counterparty acknowledged that specific message; nothing weaker should be
|
|
41
|
+
* treated as proof of delivery.
|
|
42
|
+
*/
|
|
43
|
+
export async function verifyReceipt(opts) {
|
|
44
|
+
const parsed = InkReceiptSchema.safeParse(opts.receipt);
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
return { valid: false, reason: "malformed_receipt" };
|
|
47
|
+
const receipt = parsed.data;
|
|
48
|
+
const { senderPublicKey, expected } = opts;
|
|
49
|
+
if (!(senderPublicKey instanceof Uint8Array) || senderPublicKey.length !== 32) {
|
|
50
|
+
return { valid: false, reason: "invalid_public_key" };
|
|
51
|
+
}
|
|
52
|
+
if (receipt.from !== expected.from)
|
|
53
|
+
return { valid: false, reason: "from_mismatch" };
|
|
54
|
+
if (receipt.to !== expected.to)
|
|
55
|
+
return { valid: false, reason: "to_mismatch" };
|
|
56
|
+
if (receipt.messageId !== expected.messageId)
|
|
57
|
+
return { valid: false, reason: "message_id_mismatch" };
|
|
58
|
+
if (expected.disposition !== undefined && receipt.disposition !== expected.disposition) {
|
|
59
|
+
return { valid: false, reason: "disposition_mismatch" };
|
|
60
|
+
}
|
|
61
|
+
let expectedHash;
|
|
62
|
+
try {
|
|
63
|
+
expectedHash = await computeMessageHash(expected.messageBody);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { valid: false, reason: "message_hash_error" };
|
|
67
|
+
}
|
|
68
|
+
if (receipt.messageHash !== expectedHash)
|
|
69
|
+
return { valid: false, reason: "message_hash_mismatch" };
|
|
70
|
+
const sigOk = await verifyMessage(receipt, senderPublicKey);
|
|
71
|
+
if (!sigOk)
|
|
72
|
+
return { valid: false, reason: "invalid_signature" };
|
|
73
|
+
return { valid: true };
|
|
74
|
+
}
|
|
31
75
|
/** Loop prevention: don't send receipts for receipts or audit messages. */
|
|
32
76
|
const NO_RECEIPT_TYPES = new Set([
|
|
33
77
|
"network.tulpa.receipt",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { verifyInkSignature, MAX_TIMESTAMP_AGE_MS, MAX_FUTURE_TIMESTAMP_MS } from "../crypto/ink.js";
|
|
2
|
-
import { extractPublicKeyFromAgentId } from "../crypto/keys.js";
|
|
2
|
+
import { extractPublicKeyFromAgentId, canonicalAgentPrincipal } from "../crypto/keys.js";
|
|
3
3
|
import { verifyInkSignatureWithKeys } from "../crypto/multi-key-verify.js";
|
|
4
4
|
/**
|
|
5
5
|
* Parse and verify an INK-Ed25519 Authorization header.
|
|
@@ -33,8 +33,10 @@ export async function verifyInkAuth(opts) {
|
|
|
33
33
|
}
|
|
34
34
|
// Ed25519 signatures are exactly 86 base64url chars — tighten the regex to
|
|
35
35
|
// {86} so clearly-wrong lengths get rejected up front, rather than burning
|
|
36
|
-
// CPU on verifyInkSignature for a malformed value.
|
|
37
|
-
|
|
36
|
+
// CPU on verifyInkSignature for a malformed value. Single literal spaces
|
|
37
|
+
// match the spec grammar and buildAuthHeader's output; `\s` would let
|
|
38
|
+
// CR/LF/TAB into a parsed header value.
|
|
39
|
+
const match = opts.authHeader.match(/^INK-Ed25519 ([A-Za-z0-9_-]{86})(?: keyId=([A-Za-z0-9_:.-]{1,128}))?$/);
|
|
38
40
|
if (!match) {
|
|
39
41
|
return { valid: false, error: "invalid_auth_scheme" };
|
|
40
42
|
}
|
|
@@ -172,6 +174,7 @@ export async function verifyInkAuth(opts) {
|
|
|
172
174
|
return {
|
|
173
175
|
valid: true,
|
|
174
176
|
senderAgentId: senderDid,
|
|
177
|
+
principal: canonicalAgentPrincipal(senderDid),
|
|
175
178
|
keyId: result.keyId,
|
|
176
179
|
keyStatus: result.keyStatus,
|
|
177
180
|
};
|
|
@@ -206,7 +209,7 @@ export async function verifyInkAuth(opts) {
|
|
|
206
209
|
const noncePass = await recordNonce();
|
|
207
210
|
if (!noncePass.ok)
|
|
208
211
|
return { valid: false, error: noncePass.error };
|
|
209
|
-
return { valid: true, senderAgentId: senderDid };
|
|
212
|
+
return { valid: true, senderAgentId: senderDid, principal: canonicalAgentPrincipal(senderDid) };
|
|
210
213
|
}
|
|
211
214
|
catch {
|
|
212
215
|
return { valid: false, error: "signature_verification_failed" };
|
|
@@ -5,17 +5,17 @@ import { ProfileSnapshotSchema } from "./profile.js";
|
|
|
5
5
|
import { KeyEntrySchema } from "./key-entry.js";
|
|
6
6
|
import { InkTransportSchema, AgentCardVisibilitySchema } from "./ink-handshake.js";
|
|
7
7
|
export const ThirdPartyAuditServiceSchema = z.object({
|
|
8
|
-
endpoint: z.string().url(),
|
|
9
|
-
did: z.string(),
|
|
10
|
-
publicKey: z.string(),
|
|
8
|
+
endpoint: z.string().max(2048).url(),
|
|
9
|
+
did: z.string().max(512),
|
|
10
|
+
publicKey: z.string().max(256),
|
|
11
11
|
});
|
|
12
12
|
export const AgentCardSchema = z.object({
|
|
13
13
|
protocol: z.literal("ink/0.1"),
|
|
14
|
-
agentId: z.string(),
|
|
15
|
-
ownerDid: z.string().optional(),
|
|
16
|
-
ownerHandle: z.string().optional(),
|
|
17
|
-
atprotoRecordUri: z.string().optional(),
|
|
18
|
-
handle: z.string(),
|
|
14
|
+
agentId: z.string().max(512),
|
|
15
|
+
ownerDid: z.string().max(512).optional(),
|
|
16
|
+
ownerHandle: z.string().max(256).optional(),
|
|
17
|
+
atprotoRecordUri: z.string().max(2048).optional(),
|
|
18
|
+
handle: z.string().max(256),
|
|
19
19
|
displayName: z.string().max(200),
|
|
20
20
|
/**
|
|
21
21
|
* Inbound message endpoint URL. Required.
|
|
@@ -27,36 +27,36 @@ export const AgentCardSchema = z.object({
|
|
|
27
27
|
* MAY emit `inboxEndpoint` alongside it. The runtime helper
|
|
28
28
|
* `resolveAgentInbox(card)` returns whichever value is present.
|
|
29
29
|
*/
|
|
30
|
-
endpoint: z.string().url(),
|
|
31
|
-
inboxEndpoint: z.string().url().optional(),
|
|
32
|
-
publicKeyMultibase: z.string().startsWith("z"),
|
|
30
|
+
endpoint: z.string().max(2048).url(),
|
|
31
|
+
inboxEndpoint: z.string().max(2048).url().optional(),
|
|
32
|
+
publicKeyMultibase: z.string().startsWith("z").max(128),
|
|
33
33
|
// (other fields below; the `inboxEndpoint === endpoint` invariant
|
|
34
34
|
// is enforced by the .superRefine() at the bottom of this schema.)
|
|
35
35
|
profileSnapshot: ProfileSnapshotSchema.optional(),
|
|
36
36
|
capabilities: z.object({
|
|
37
|
-
intentsAccepted: z.array(IntentTypeSchema),
|
|
38
|
-
intentsSent: z.array(IntentTypeSchema),
|
|
37
|
+
intentsAccepted: z.array(IntentTypeSchema).max(32),
|
|
38
|
+
intentsSent: z.array(IntentTypeSchema).max(32),
|
|
39
39
|
receipts: z.object({
|
|
40
40
|
send: z.boolean(),
|
|
41
|
-
dispositions: z.array(InkReceiptDispositionSchema),
|
|
41
|
+
dispositions: z.array(InkReceiptDispositionSchema).max(16),
|
|
42
42
|
}).optional(),
|
|
43
43
|
auditExchange: z.boolean().optional(),
|
|
44
44
|
thirdPartyAudit: z.object({
|
|
45
|
-
services: z.array(ThirdPartyAuditServiceSchema),
|
|
45
|
+
services: z.array(ThirdPartyAuditServiceSchema).max(16),
|
|
46
46
|
submitPolicy: z.enum(["all", "high_value", "none"]),
|
|
47
47
|
}).optional(),
|
|
48
48
|
}),
|
|
49
49
|
availability: z.object({
|
|
50
|
-
timezone: z.string(),
|
|
51
|
-
meetingHours: z.string().optional(),
|
|
52
|
-
responseSla: z.string().optional(),
|
|
50
|
+
timezone: z.string().max(64),
|
|
51
|
+
meetingHours: z.string().max(200).optional(),
|
|
52
|
+
responseSla: z.string().max(200).optional(),
|
|
53
53
|
}),
|
|
54
54
|
keys: z.object({
|
|
55
|
-
signing: z.array(KeyEntrySchema),
|
|
56
|
-
encryption: z.array(KeyEntrySchema),
|
|
55
|
+
signing: z.array(KeyEntrySchema).max(32),
|
|
56
|
+
encryption: z.array(KeyEntrySchema).max(32),
|
|
57
57
|
}).optional(),
|
|
58
|
-
currentSigningKeyId: z.string().optional(),
|
|
59
|
-
currentEncryptionKeyId: z.string().optional(),
|
|
58
|
+
currentSigningKeyId: z.string().max(128).optional(),
|
|
59
|
+
currentEncryptionKeyId: z.string().max(128).optional(),
|
|
60
60
|
keySetVersion: z.number().int().positive().optional(),
|
|
61
61
|
// Message protocol versions this agent's receiver can verify on the
|
|
62
62
|
// body signature. When absent, assume ink/0.1 only. A sender MUST NOT
|