@adastracomputing/ink 0.1.0-alpha.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 +63 -0
- package/CODE_OF_CONDUCT.md +42 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +133 -0
- package/SECURITY.md +57 -0
- package/docs/key-rotation-rule.md +108 -0
- package/docs/logo.svg +8 -0
- package/docs/maturity.md +81 -0
- package/docs/threat-model.md +150 -0
- package/package.json +72 -0
- package/specs/ink-agent-containment-and-governance-extension-spec.md +508 -0
- package/specs/ink-auditability.md +652 -0
- package/specs/ink-authorization-chain.md +242 -0
- package/specs/ink-compatibility-policy.md +263 -0
- package/specs/ink-compliance-checklist.md +309 -0
- package/specs/ink-containment-phase1-implementation-spec.md +593 -0
- package/specs/ink-introduction-receipts-extension.md +501 -0
- package/specs/ink-key-rotation-spec.md +535 -0
- package/src/crypto/ink.ts +902 -0
- package/src/crypto/keys.ts +211 -0
- package/src/crypto/multi-key-verify.ts +170 -0
- package/src/crypto/sign.ts +155 -0
- package/src/crypto/verify.ts +1 -0
- package/src/discovery/agent-card.ts +508 -0
- package/src/index.ts +59 -0
- package/src/ink/checkpoint.ts +75 -0
- package/src/ink/discovery-gating.ts +147 -0
- package/src/ink/handshake-budget.ts +413 -0
- package/src/ink/receipts.ts +114 -0
- package/src/ink/transport-auth.ts +96 -0
- package/src/middleware/ink-auth.ts +263 -0
- package/src/models/agent-card.ts +63 -0
- package/src/models/ink-audit.ts +205 -0
- package/src/models/ink-handshake.ts +123 -0
- package/src/models/intent.ts +201 -0
- package/src/models/key-entry.ts +52 -0
- package/src/models/profile.ts +31 -0
- package/test-vectors/README.md +129 -0
- package/test-vectors/encryption.json +90 -0
- package/test-vectors/handshake.json +482 -0
- package/test-vectors/jcs.json +30 -0
- package/test-vectors/key-rotation.json +101 -0
- package/test-vectors/keys.json +32 -0
- package/test-vectors/receipts-and-audit.json +142 -0
- package/test-vectors/replay.json +88 -0
- package/test-vectors/signing.json +61 -0
- package/test-vectors/witness.json +394 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import * as ed from "@noble/ed25519";
|
|
2
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
3
|
+
import canonicalize from "canonicalize";
|
|
4
|
+
|
|
5
|
+
// ── Encoding helpers ──
|
|
6
|
+
|
|
7
|
+
const MAX_ENCODE_INPUT_BYTES = 2_000_000;
|
|
8
|
+
|
|
9
|
+
function base64urlEncode(bytes: Uint8Array): string {
|
|
10
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
11
|
+
throw new Error("base64urlEncode: input must be a Uint8Array");
|
|
12
|
+
}
|
|
13
|
+
if (bytes.length > MAX_ENCODE_INPUT_BYTES) {
|
|
14
|
+
throw new Error(`base64urlEncode: input exceeds maximum of ${MAX_ENCODE_INPUT_BYTES} bytes`);
|
|
15
|
+
}
|
|
16
|
+
const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
|
|
17
|
+
const base64 = btoa(binString);
|
|
18
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_BASE64URL_INPUT_LEN = 2_000_000;
|
|
22
|
+
|
|
23
|
+
function base64urlDecode(str: string): Uint8Array {
|
|
24
|
+
if (typeof str !== "string") {
|
|
25
|
+
throw new Error("base64urlDecode: input must be a string");
|
|
26
|
+
}
|
|
27
|
+
if (str.length > MAX_BASE64URL_INPUT_LEN) {
|
|
28
|
+
throw new Error(`base64urlDecode: input exceeds maximum length of ${MAX_BASE64URL_INPUT_LEN}`);
|
|
29
|
+
}
|
|
30
|
+
if (!/^[A-Za-z0-9_-]*$/.test(str)) {
|
|
31
|
+
throw new Error("base64urlDecode: invalid base64url character");
|
|
32
|
+
}
|
|
33
|
+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
34
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
35
|
+
const binString = atob(padded);
|
|
36
|
+
return Uint8Array.from(binString, (c) => c.charCodeAt(0));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Defense-in-depth cap on hex input length. The longest legitimate input
|
|
40
|
+
* the package decodes is a 64-byte hex string (Ed25519 keypair concat); the
|
|
41
|
+
* cap is set generously above that so an attacker-supplied multi-megabyte
|
|
42
|
+
* hex string can't drive an O(n) regex scan and a multi-megabyte
|
|
43
|
+
* Uint8Array allocation before the downstream length check fires. */
|
|
44
|
+
const MAX_HEX_INPUT_LEN = 4096;
|
|
45
|
+
|
|
46
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
47
|
+
if (typeof hex !== "string") {
|
|
48
|
+
throw new Error("hexToBytes: input must be a string");
|
|
49
|
+
}
|
|
50
|
+
if (hex.length > MAX_HEX_INPUT_LEN) {
|
|
51
|
+
throw new Error(`hex input exceeds maximum length of ${MAX_HEX_INPUT_LEN}`);
|
|
52
|
+
}
|
|
53
|
+
if (hex.length % 2 !== 0) throw new Error(`Invalid hex string length: ${hex.length}`);
|
|
54
|
+
if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("Invalid hex character in string");
|
|
55
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
56
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
57
|
+
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
58
|
+
}
|
|
59
|
+
return bytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
63
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
64
|
+
throw new Error("bytesToHex: input must be a Uint8Array");
|
|
65
|
+
}
|
|
66
|
+
if (bytes.length > MAX_ENCODE_INPUT_BYTES) {
|
|
67
|
+
throw new Error(`bytesToHex: input exceeds maximum of ${MAX_ENCODE_INPUT_BYTES} bytes`);
|
|
68
|
+
}
|
|
69
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── JCS Canonicalization (RFC 8785) ──
|
|
73
|
+
|
|
74
|
+
function jcsCanonicalize(obj: unknown): string {
|
|
75
|
+
if (!isWithinCanonicalizeBounds(obj)) {
|
|
76
|
+
throw new Error("Input exceeds maximum allowed complexity");
|
|
77
|
+
}
|
|
78
|
+
const result = canonicalize(obj);
|
|
79
|
+
if (result === undefined) throw new Error("Failed to canonicalize");
|
|
80
|
+
if (result.length > MAX_SIGBASE_BODY_BYTES) {
|
|
81
|
+
throw new Error("Canonical output exceeds maximum allowed size");
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── INK v0.1 Signing (§3.3) ──
|
|
87
|
+
|
|
88
|
+
export interface InkSignInput {
|
|
89
|
+
method: string;
|
|
90
|
+
path: string;
|
|
91
|
+
recipientDid: string;
|
|
92
|
+
body: Record<string, unknown>;
|
|
93
|
+
timestamp: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Construct the INK v0.1 signature base string per §3.3:
|
|
98
|
+
* ink/0.1\nMETHOD\nPATH\nrecipientDid\nJCS(body)\ntimestamp
|
|
99
|
+
*
|
|
100
|
+
* The protocol version prefix prevents cross-version signature replay.
|
|
101
|
+
*
|
|
102
|
+
* Newlines (CR or LF) are forbidden in all scalar fields. Because the base
|
|
103
|
+
* string is newline-delimited, a field containing \n could shift field
|
|
104
|
+
* boundaries and allow two distinct logical inputs to produce the same
|
|
105
|
+
* signed bytes (a signature-base collision).
|
|
106
|
+
*/
|
|
107
|
+
/** Defense-in-depth cap on the canonicalized body size used to build the
|
|
108
|
+
* INK signature base. Callers are expected to validate input size at the
|
|
109
|
+
* transport boundary (the hosting HTTP layer typically caps total request
|
|
110
|
+
* body size; INK-aware endpoints should additionally cap submit/query
|
|
111
|
+
* bodies). This is an internal upper limit in case any caller forgets —
|
|
112
|
+
* protects against canonicalize-then-encode burning CPU/memory on unbounded
|
|
113
|
+
* `input.body`. 1 MB is well above any realistic signed payload. */
|
|
114
|
+
const MAX_SIGBASE_BODY_BYTES = 1_048_576;
|
|
115
|
+
|
|
116
|
+
/** Hard caps for the cheap pre-canonicalize bound walk. These are well above
|
|
117
|
+
* any realistic INK body (signing payloads are typically <50 keys and ≤6
|
|
118
|
+
* levels deep) but small enough that the walk itself remains O(n) on tiny
|
|
119
|
+
* structures and bails fast on adversarial ones. The shape of the limits
|
|
120
|
+
* mirrors what jcsCanonicalize would have to traverse anyway, so an attacker
|
|
121
|
+
* cannot get past the pre-check and then explode inside canonicalize.
|
|
122
|
+
*
|
|
123
|
+
* MAX_PRECHECK_CHARS bounds aggregate string content (keys + string values)
|
|
124
|
+
* so a single huge string can't slip past the node-count cap. Set slightly
|
|
125
|
+
* above MAX_SIGBASE_BODY_BYTES so the post-canonicalize byte cap stays the
|
|
126
|
+
* authoritative reject, but the pre-check stops `JSON.stringify` / the
|
|
127
|
+
* recursive `canonicalize` from ever allocating that much in the first
|
|
128
|
+
* place. The aggregate counter is approximate (counts JS string length not
|
|
129
|
+
* UTF-8 bytes) but is intentionally a cheap upper-bound — the precise byte
|
|
130
|
+
* count happens after canonicalize. */
|
|
131
|
+
const MAX_PRECHECK_NODES = 10_000;
|
|
132
|
+
const MAX_PRECHECK_DEPTH = 32;
|
|
133
|
+
const MAX_PRECHECK_CHARS = 1_200_000;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cheap depth/node-count/byte walk over a value before it is handed to
|
|
137
|
+
* jcsCanonicalize. Returns true if the value is within bounds. The goal is
|
|
138
|
+
* NOT to validate the value; it is to bail BEFORE canonicalize() does its
|
|
139
|
+
* recursive sort+serialize on something that should be rejected anyway.
|
|
140
|
+
* Non-throwing — the caller decides what to do with `false`.
|
|
141
|
+
*
|
|
142
|
+
* The byte counter accumulates every string value and every object key.
|
|
143
|
+
* Without it, an attacker can pass the node check with a single value
|
|
144
|
+
* like `{data: "x".repeat(100_000_000)}` (1 node, gigabytes of memory).
|
|
145
|
+
*/
|
|
146
|
+
function isWithinCanonicalizeBounds(value: unknown): boolean {
|
|
147
|
+
let nodes = 0;
|
|
148
|
+
let chars = 0;
|
|
149
|
+
function walk(v: unknown, depth: number): boolean {
|
|
150
|
+
if (depth > MAX_PRECHECK_DEPTH) return false;
|
|
151
|
+
if (++nodes > MAX_PRECHECK_NODES) return false;
|
|
152
|
+
if (v === null || typeof v !== "object") {
|
|
153
|
+
if (typeof v === "string") {
|
|
154
|
+
chars += v.length;
|
|
155
|
+
if (chars > MAX_PRECHECK_CHARS) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(v)) {
|
|
160
|
+
for (const item of v) {
|
|
161
|
+
if (!walk(item, depth + 1)) return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
for (const key of Object.keys(v as Record<string, unknown>)) {
|
|
166
|
+
if (++nodes > MAX_PRECHECK_NODES) return false;
|
|
167
|
+
chars += key.length;
|
|
168
|
+
if (chars > MAX_PRECHECK_CHARS) return false;
|
|
169
|
+
if (!walk((v as Record<string, unknown>)[key], depth + 1)) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return walk(value, 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
export function buildSignatureBase(input: InkSignInput): string {
|
|
178
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
179
|
+
throw new Error("Invalid signature-base input");
|
|
180
|
+
}
|
|
181
|
+
// Validate scalar shape FIRST: each field is a non-empty string within
|
|
182
|
+
// a reasonable cap. An attacker who reaches this with a 100 MB path or
|
|
183
|
+
// recipientDid would otherwise force large TextEncoder allocations and
|
|
184
|
+
// a worst-case regex scan before signature failure.
|
|
185
|
+
// Caps:
|
|
186
|
+
// method: 16 chars (HTTP verb)
|
|
187
|
+
// path: 2048 chars (URI Section 3.3 practical bound)
|
|
188
|
+
// recipientDid: 256 chars (same as middleware senderDid cap)
|
|
189
|
+
// timestamp: 64 chars (ISO 8601 with subsecond + timezone)
|
|
190
|
+
const isScalar = (x: unknown, max: number): x is string =>
|
|
191
|
+
typeof x === "string" && x.length > 0 && x.length <= max;
|
|
192
|
+
if (!isScalar(input.method, 16)) throw new Error("Invalid signature-base method");
|
|
193
|
+
if (!isScalar(input.path, 2048)) throw new Error("Invalid signature-base path");
|
|
194
|
+
if (!isScalar(input.recipientDid, 256)) throw new Error("Invalid signature-base recipientDid");
|
|
195
|
+
if (!isScalar(input.timestamp, 64)) throw new Error("Invalid signature-base timestamp");
|
|
196
|
+
|
|
197
|
+
// Guard against newline injection in each scalar field.
|
|
198
|
+
// CR (\r) is included because \r\n is a common line-ending and would
|
|
199
|
+
// produce the same boundary-shift as \n alone.
|
|
200
|
+
const crlf = /[\r\n]/;
|
|
201
|
+
if (crlf.test(input.method)) throw new Error("Invalid character in method: newline or CR not allowed");
|
|
202
|
+
if (crlf.test(input.path)) throw new Error("Invalid character in path: newline or CR not allowed");
|
|
203
|
+
if (crlf.test(input.recipientDid)) throw new Error("Invalid character in recipientDid: newline or CR not allowed");
|
|
204
|
+
if (crlf.test(input.timestamp)) throw new Error("Invalid character in timestamp: newline or CR not allowed");
|
|
205
|
+
|
|
206
|
+
// Bound the cost of the canonicalize step BEFORE invoking it. Without
|
|
207
|
+
// this, an attacker can submit a syntactically valid body that bloats
|
|
208
|
+
// the recursive sort+serialize work inside jcsCanonicalize and then
|
|
209
|
+
// gets rejected by the size cap below — burning CPU/memory pre-reject.
|
|
210
|
+
if (!isWithinCanonicalizeBounds(input.body)) {
|
|
211
|
+
throw new Error("Signature base body exceeds maximum allowed complexity");
|
|
212
|
+
}
|
|
213
|
+
const canonical = jcsCanonicalize(input.body);
|
|
214
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
|
|
215
|
+
throw new Error("Signature base body exceeds maximum allowed size");
|
|
216
|
+
}
|
|
217
|
+
return `ink/0.1\n${input.method}\n${input.path}\n${input.recipientDid}\n${canonical}\n${input.timestamp}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Sign an INK message. Returns the base64url-encoded Ed25519 signature.
|
|
222
|
+
*/
|
|
223
|
+
export async function signInkMessage(
|
|
224
|
+
input: InkSignInput,
|
|
225
|
+
privateKey: Uint8Array,
|
|
226
|
+
): Promise<string> {
|
|
227
|
+
const sigBase = buildSignatureBase(input);
|
|
228
|
+
const bytes = new TextEncoder().encode(sigBase);
|
|
229
|
+
const sig = await ed.signAsync(bytes, privateKey);
|
|
230
|
+
return base64urlEncode(sig);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Verify an INK message signature.
|
|
235
|
+
* Returns false (never throws) for malformed or wrong-length signatures.
|
|
236
|
+
*/
|
|
237
|
+
export async function verifyInkSignature(
|
|
238
|
+
input: InkSignInput,
|
|
239
|
+
signatureBase64url: string,
|
|
240
|
+
publicKey: Uint8Array,
|
|
241
|
+
): Promise<boolean> {
|
|
242
|
+
// Reject obviously-malformed signatures BEFORE canonicalizing the body.
|
|
243
|
+
// canonicalize() walks the entire body to sort keys; doing that work
|
|
244
|
+
// for a request with a junk signature lets attackers burn CPU/memory
|
|
245
|
+
// on the verifier without ever supplying a valid signature.
|
|
246
|
+
if (!/^[A-Za-z0-9_-]{86}$/.test(signatureBase64url)) return false;
|
|
247
|
+
let sigBase: string;
|
|
248
|
+
try {
|
|
249
|
+
sigBase = buildSignatureBase(input);
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const bytes = new TextEncoder().encode(sigBase);
|
|
254
|
+
try {
|
|
255
|
+
const sig = base64urlDecode(signatureBase64url);
|
|
256
|
+
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build the Authorization header value for an INK request.
|
|
264
|
+
* Optionally includes keyId for key-rotation-aware verification.
|
|
265
|
+
*
|
|
266
|
+
* Both values are validated against the same grammar the receiver uses so that
|
|
267
|
+
* invalid characters (including CR/LF that could cause header injection) are
|
|
268
|
+
* rejected before they reach the HTTP layer.
|
|
269
|
+
*/
|
|
270
|
+
export function buildAuthHeader(signatureBase64url: string, keyId?: string): string {
|
|
271
|
+
// Ed25519 signatures are exactly 64 bytes which encode to exactly 86 unpadded base64url chars.
|
|
272
|
+
// Reject any other length at the builder so callers get an early error rather than sending
|
|
273
|
+
// a syntactically-valid but semantically-wrong Authorization header.
|
|
274
|
+
if (!/^[A-Za-z0-9_-]{86}$/.test(signatureBase64url)) {
|
|
275
|
+
throw new Error("Invalid signature for Authorization header: must be exactly 86 base64url characters (Ed25519)");
|
|
276
|
+
}
|
|
277
|
+
if (keyId !== undefined) {
|
|
278
|
+
// keyId must match the verifier's grammar — alphanumeric plus safe punctuation, no CR/LF or spaces.
|
|
279
|
+
if (!/^[A-Za-z0-9_:.-]{1,128}$/.test(keyId)) {
|
|
280
|
+
throw new Error("Invalid keyId for Authorization header: must be 1-128 chars [A-Za-z0-9_:.-]");
|
|
281
|
+
}
|
|
282
|
+
return `INK-Ed25519 ${signatureBase64url} keyId=${keyId}`;
|
|
283
|
+
}
|
|
284
|
+
return `INK-Ed25519 ${signatureBase64url}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── INK v0.1 Encryption (§3.4 — ECIES) ──
|
|
288
|
+
|
|
289
|
+
export interface InkEncryptedEnvelope {
|
|
290
|
+
protocol: "ink/0.1";
|
|
291
|
+
type: "network.tulpa.encrypted";
|
|
292
|
+
from: string;
|
|
293
|
+
ephemeralKey: string;
|
|
294
|
+
nonce: string;
|
|
295
|
+
ciphertext: string;
|
|
296
|
+
timestamp: string;
|
|
297
|
+
messageNonce: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface InkEncryptResult {
|
|
301
|
+
envelope: InkEncryptedEnvelope;
|
|
302
|
+
ephemeralPublicKey: Uint8Array;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Encrypt an INK message payload using ECIES:
|
|
307
|
+
* 1. Generate ephemeral X25519 keypair (or accept one for deterministic tests)
|
|
308
|
+
* 2. ECDH with recipient's X25519 public key
|
|
309
|
+
* 3. HKDF-SHA256(sharedSecret, salt="ink/0.1", info="ink/0.1/encrypt") → 32-byte AES key
|
|
310
|
+
* 4. AES-256-GCM encrypt the JSON-serialized plaintext
|
|
311
|
+
* 5. Pack into outer envelope
|
|
312
|
+
*/
|
|
313
|
+
export async function encryptInkPayload(
|
|
314
|
+
plaintext: Record<string, unknown>,
|
|
315
|
+
senderDid: string,
|
|
316
|
+
recipientEncryptionKeyHex: string,
|
|
317
|
+
timestamp: string,
|
|
318
|
+
messageNonce: string,
|
|
319
|
+
options?: {
|
|
320
|
+
ephemeralPrivateKey?: Uint8Array;
|
|
321
|
+
aesNonce?: Uint8Array;
|
|
322
|
+
},
|
|
323
|
+
): Promise<InkEncryptResult> {
|
|
324
|
+
// Pre-AAD scalar caps. AAD is canonicalized and TextEncoder-allocated;
|
|
325
|
+
// unbounded sender DID / timestamp / messageNonce values would force
|
|
326
|
+
// the encrypt path to spend CPU/memory before any GCM work. These
|
|
327
|
+
// caps mirror the decrypt-side guards so encrypt cannot mint AAD that
|
|
328
|
+
// a conformant decrypter would refuse.
|
|
329
|
+
if (typeof senderDid !== "string" || senderDid.length === 0 || senderDid.length > 512) {
|
|
330
|
+
throw new Error("Invalid senderDid");
|
|
331
|
+
}
|
|
332
|
+
if (typeof timestamp !== "string" || timestamp.length === 0 || timestamp.length > 64) {
|
|
333
|
+
throw new Error("Invalid timestamp");
|
|
334
|
+
}
|
|
335
|
+
if (typeof messageNonce !== "string" || messageNonce.length === 0 || messageNonce.length > 256) {
|
|
336
|
+
throw new Error("Invalid messageNonce");
|
|
337
|
+
}
|
|
338
|
+
// 1. Ephemeral X25519 keypair.
|
|
339
|
+
// Test-supplied overrides must be the right length to produce a clean
|
|
340
|
+
// error instead of an opaque crypto exception.
|
|
341
|
+
if (options?.ephemeralPrivateKey && options.ephemeralPrivateKey.length !== 32) {
|
|
342
|
+
throw new Error("ephemeralPrivateKey must be exactly 32 bytes");
|
|
343
|
+
}
|
|
344
|
+
const ephPriv = options?.ephemeralPrivateKey ?? crypto.getRandomValues(new Uint8Array(32));
|
|
345
|
+
const ephPub = x25519.getPublicKey(ephPriv);
|
|
346
|
+
|
|
347
|
+
// 2. ECDH shared secret. Explicit 32-byte length check on the decoded
|
|
348
|
+
// recipient public key so we surface a clean error rather than an
|
|
349
|
+
// opaque noble-curves exception (matches the ephemeralPrivateKey path
|
|
350
|
+
// guard above).
|
|
351
|
+
const recipientPub = hexToBytes(recipientEncryptionKeyHex);
|
|
352
|
+
if (recipientPub.length !== 32) {
|
|
353
|
+
throw new Error("recipientEncryptionKeyHex must decode to exactly 32 bytes");
|
|
354
|
+
}
|
|
355
|
+
const sharedSecret = x25519.getSharedSecret(ephPriv, recipientPub);
|
|
356
|
+
|
|
357
|
+
// Refuse all-zero shared secrets. A low-order recipient public key (a
|
|
358
|
+
// 32-byte value in the small subgroup) forces every X25519 ECDH to
|
|
359
|
+
// produce an all-zero shared secret. Without this check, the encrypt
|
|
360
|
+
// path would derive a deterministic, publicly-known AES key from HKDF,
|
|
361
|
+
// making the ciphertext decryptable by anyone. The decrypt path has the
|
|
362
|
+
// mirrored guard at the all-zeros check below.
|
|
363
|
+
if (sharedSecret.every((b) => b === 0)) {
|
|
364
|
+
throw new Error("Invalid recipient public key: ECDH shared secret is all zeros");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 3. HKDF-SHA256 → AES key
|
|
368
|
+
const hkdfKey = await crypto.subtle.importKey(
|
|
369
|
+
"raw", sharedSecret, "HKDF", false, ["deriveBits"],
|
|
370
|
+
);
|
|
371
|
+
const symmetricBits = await crypto.subtle.deriveBits(
|
|
372
|
+
{ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("ink/0.1"), info: new TextEncoder().encode("ink/0.1/encrypt") },
|
|
373
|
+
hkdfKey, 256,
|
|
374
|
+
);
|
|
375
|
+
const symmetricKey = new Uint8Array(symmetricBits);
|
|
376
|
+
|
|
377
|
+
// 4. AES-256-GCM
|
|
378
|
+
if (options?.aesNonce && options.aesNonce.length !== 12) {
|
|
379
|
+
throw new Error("aesNonce must be exactly 12 bytes");
|
|
380
|
+
}
|
|
381
|
+
const aesNonce = options?.aesNonce ?? crypto.getRandomValues(new Uint8Array(12));
|
|
382
|
+
|
|
383
|
+
// Bound the plaintext BEFORE JSON.stringify and TextEncoder.encode so
|
|
384
|
+
// a caller asked to encrypt attacker-supplied data can't be forced
|
|
385
|
+
// into large allocations. Decrypt already caps the resulting
|
|
386
|
+
// ciphertext; we mirror that here so encrypt cannot mint envelopes a
|
|
387
|
+
// conformant decryptor would refuse. Cheap node walk first, then
|
|
388
|
+
// string-length cap on the encoded bytes.
|
|
389
|
+
if (!isWithinCanonicalizeBounds(plaintext)) {
|
|
390
|
+
throw new Error("Plaintext exceeds maximum allowed complexity");
|
|
391
|
+
}
|
|
392
|
+
const plaintextJson = JSON.stringify(plaintext);
|
|
393
|
+
if (plaintextJson.length > MAX_SIGBASE_BODY_BYTES) {
|
|
394
|
+
throw new Error("Plaintext exceeds maximum allowed size");
|
|
395
|
+
}
|
|
396
|
+
const plaintextBytes = new TextEncoder().encode(plaintextJson);
|
|
397
|
+
|
|
398
|
+
const aesKey = await crypto.subtle.importKey("raw", symmetricKey, "AES-GCM", false, ["encrypt"]);
|
|
399
|
+
// AAD binds the ciphertext to all security-relevant outer envelope fields using
|
|
400
|
+
// an unambiguous JSON-canonical representation. This prevents an attacker from
|
|
401
|
+
// replaying the same ciphertext with modified outer metadata (timestamp, nonce, etc.)
|
|
402
|
+
// or reattributing the ciphertext to a different sender.
|
|
403
|
+
// Fields bound: protocol, type, from (sender), ephemeralKey, AES nonce (base64url),
|
|
404
|
+
// timestamp, messageNonce. Including protocol and type prevents type-confusion attacks
|
|
405
|
+
// where an attacker reinterprets a valid encrypted envelope as a different message type.
|
|
406
|
+
const aadObject = {
|
|
407
|
+
protocol: "ink/0.1",
|
|
408
|
+
type: "network.tulpa.encrypted",
|
|
409
|
+
from: senderDid,
|
|
410
|
+
ephemeralKey: base64urlEncode(ephPub),
|
|
411
|
+
nonce: base64urlEncode(aesNonce),
|
|
412
|
+
timestamp,
|
|
413
|
+
messageNonce,
|
|
414
|
+
};
|
|
415
|
+
const aadString = `ink/0.1:envelope\n${jcsCanonicalize(aadObject)}`;
|
|
416
|
+
const aad = new TextEncoder().encode(aadString);
|
|
417
|
+
const ciphertextWithTag = new Uint8Array(
|
|
418
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv: aesNonce, additionalData: aad }, aesKey, plaintextBytes),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// 5. Outer envelope
|
|
422
|
+
const envelope: InkEncryptedEnvelope = {
|
|
423
|
+
protocol: "ink/0.1",
|
|
424
|
+
type: "network.tulpa.encrypted",
|
|
425
|
+
from: senderDid,
|
|
426
|
+
ephemeralKey: base64urlEncode(ephPub),
|
|
427
|
+
nonce: base64urlEncode(aesNonce),
|
|
428
|
+
ciphertext: base64urlEncode(ciphertextWithTag),
|
|
429
|
+
timestamp,
|
|
430
|
+
messageNonce,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return { envelope, ephemeralPublicKey: ephPub };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Decrypt an INK encrypted envelope using the recipient's X25519 private key.
|
|
438
|
+
* Returns the decrypted inner envelope and verifies inner/outer consistency.
|
|
439
|
+
*/
|
|
440
|
+
export async function decryptInkPayload(
|
|
441
|
+
envelope: InkEncryptedEnvelope,
|
|
442
|
+
recipientEncryptionPrivateKeyHex: string,
|
|
443
|
+
recipientDid?: string,
|
|
444
|
+
): Promise<Record<string, unknown>> {
|
|
445
|
+
if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
446
|
+
throw new Error("envelope must be a non-null object");
|
|
447
|
+
}
|
|
448
|
+
if (envelope.protocol !== "ink/0.1") {
|
|
449
|
+
throw new Error("Unsupported protocol version");
|
|
450
|
+
}
|
|
451
|
+
if (envelope.type !== "network.tulpa.encrypted") {
|
|
452
|
+
throw new Error("Invalid encrypted envelope type");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Pre-auth length caps on AAD fields. These all flow into JCS canonicalize
|
|
456
|
+
// + TextEncoder allocation before the AES-GCM tag check, so unbounded
|
|
457
|
+
// attacker-supplied strings would burn CPU/memory pre-verification.
|
|
458
|
+
// Non-empty check mirrors encryptInkPayload's input validation so
|
|
459
|
+
// encrypt and decrypt accept exactly the same scalar set — without
|
|
460
|
+
// the matching `length === 0` reject, decrypt would accept an
|
|
461
|
+
// envelope that encrypt could never have produced.
|
|
462
|
+
if (
|
|
463
|
+
typeof envelope.from !== "string" ||
|
|
464
|
+
envelope.from.length === 0 ||
|
|
465
|
+
envelope.from.length > 512
|
|
466
|
+
) {
|
|
467
|
+
throw new Error("Invalid envelope from");
|
|
468
|
+
}
|
|
469
|
+
if (
|
|
470
|
+
typeof envelope.timestamp !== "string" ||
|
|
471
|
+
envelope.timestamp.length === 0 ||
|
|
472
|
+
envelope.timestamp.length > 64
|
|
473
|
+
) {
|
|
474
|
+
throw new Error("Invalid envelope timestamp");
|
|
475
|
+
}
|
|
476
|
+
if (
|
|
477
|
+
typeof envelope.messageNonce !== "string" ||
|
|
478
|
+
envelope.messageNonce.length === 0 ||
|
|
479
|
+
envelope.messageNonce.length > 256
|
|
480
|
+
) {
|
|
481
|
+
throw new Error("Invalid envelope messageNonce");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 1. Decode and validate ephemeral public key from envelope.
|
|
485
|
+
// X25519 public keys are exactly 32 bytes = 43 unpadded base64url chars.
|
|
486
|
+
// Pre-check the encoded length BEFORE decoding so a 100 MB ephemeralKey
|
|
487
|
+
// field doesn't get fully decoded into a ~75 MB Uint8Array before the
|
|
488
|
+
// length === 32 check fires — same memory-exhaustion class the
|
|
489
|
+
// ciphertext cap below defends against.
|
|
490
|
+
if (typeof envelope.ephemeralKey !== "string" || envelope.ephemeralKey.length > 64) {
|
|
491
|
+
throw new Error("Invalid ephemeral key");
|
|
492
|
+
}
|
|
493
|
+
const ephPub = base64urlDecode(envelope.ephemeralKey);
|
|
494
|
+
if (ephPub.length !== 32) {
|
|
495
|
+
throw new Error("Invalid ephemeral key length");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 2. ECDH shared secret. Explicit 32-byte length check on the decoded
|
|
499
|
+
// recipient private key (matches the encrypt path).
|
|
500
|
+
const recipientPriv = hexToBytes(recipientEncryptionPrivateKeyHex);
|
|
501
|
+
if (recipientPriv.length !== 32) {
|
|
502
|
+
throw new Error("recipientEncryptionPrivateKeyHex must decode to exactly 32 bytes");
|
|
503
|
+
}
|
|
504
|
+
const sharedSecret = x25519.getSharedSecret(recipientPriv, ephPub);
|
|
505
|
+
|
|
506
|
+
// Reject low-order / malicious ephemeral keys that produce an all-zero shared secret.
|
|
507
|
+
// An all-zero ECDH output is cryptographically invalid and would allow an attacker
|
|
508
|
+
// to construct ciphertexts decryptable by any recipient.
|
|
509
|
+
if (sharedSecret.every((b) => b === 0)) {
|
|
510
|
+
throw new Error("Invalid ephemeral key: ECDH shared secret is all zeros");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 3. HKDF-SHA256 → AES key
|
|
514
|
+
const hkdfKey = await crypto.subtle.importKey(
|
|
515
|
+
"raw", sharedSecret, "HKDF", false, ["deriveBits"],
|
|
516
|
+
);
|
|
517
|
+
const symmetricBits = await crypto.subtle.deriveBits(
|
|
518
|
+
{ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("ink/0.1"), info: new TextEncoder().encode("ink/0.1/encrypt") },
|
|
519
|
+
hkdfKey, 256,
|
|
520
|
+
);
|
|
521
|
+
const symmetricKey = new Uint8Array(symmetricBits);
|
|
522
|
+
|
|
523
|
+
// 4. AES-256-GCM decrypt.
|
|
524
|
+
// AES-GCM nonce is exactly 12 bytes = 16 unpadded base64url chars.
|
|
525
|
+
// Pre-check the encoded length BEFORE decoding to avoid allocating a
|
|
526
|
+
// large Uint8Array for an attacker-supplied oversized nonce field.
|
|
527
|
+
if (typeof envelope.nonce !== "string" || envelope.nonce.length > 32) {
|
|
528
|
+
throw new Error("Invalid AES-GCM nonce");
|
|
529
|
+
}
|
|
530
|
+
const aesNonce = base64urlDecode(envelope.nonce);
|
|
531
|
+
// AES-GCM requires a 12-byte IV. Reject any other length explicitly so callers
|
|
532
|
+
// get a clean error rather than an opaque WebCrypto exception.
|
|
533
|
+
if (aesNonce.length !== 12) {
|
|
534
|
+
throw new Error(`Invalid AES-GCM nonce length: expected 12 bytes, got ${aesNonce.length}`);
|
|
535
|
+
}
|
|
536
|
+
// Cap ciphertext size before base64url decode + AES-GCM allocation. Without
|
|
537
|
+
// this, a ~100 MB ciphertext would be decoded into ~75 MB Uint8Array and
|
|
538
|
+
// sent through GCM before the auth tag rejects it. 1 MB easily fits any
|
|
539
|
+
// realistic INK message payload while bounding memory under adversarial load.
|
|
540
|
+
const MAX_CIPHERTEXT_B64URL = 1_400_000;
|
|
541
|
+
if (typeof envelope.ciphertext !== "string" || envelope.ciphertext.length > MAX_CIPHERTEXT_B64URL) {
|
|
542
|
+
throw new Error("Ciphertext exceeds maximum allowed size");
|
|
543
|
+
}
|
|
544
|
+
const ciphertextWithTag = base64urlDecode(envelope.ciphertext);
|
|
545
|
+
|
|
546
|
+
const aesKey = await crypto.subtle.importKey("raw", symmetricKey, "AES-GCM", false, ["decrypt"]);
|
|
547
|
+
// AAD must match what was used during encryption — same unambiguous JSON-canonical format.
|
|
548
|
+
// protocol and type are now included to bind the ciphertext to this specific envelope type.
|
|
549
|
+
const aadObject = {
|
|
550
|
+
protocol: "ink/0.1",
|
|
551
|
+
type: "network.tulpa.encrypted",
|
|
552
|
+
from: envelope.from,
|
|
553
|
+
ephemeralKey: envelope.ephemeralKey,
|
|
554
|
+
nonce: envelope.nonce,
|
|
555
|
+
timestamp: envelope.timestamp,
|
|
556
|
+
messageNonce: envelope.messageNonce,
|
|
557
|
+
};
|
|
558
|
+
const aadString = `ink/0.1:envelope\n${jcsCanonicalize(aadObject)}`;
|
|
559
|
+
const aad = new TextEncoder().encode(aadString);
|
|
560
|
+
const plaintextBytes = new Uint8Array(
|
|
561
|
+
await crypto.subtle.decrypt({ name: "AES-GCM", iv: aesNonce, additionalData: aad }, aesKey, ciphertextWithTag),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// Plaintext is now AES-GCM-authenticated, so any well-formed JSON object
|
|
565
|
+
// here came from the sender. Still type-check before property access so
|
|
566
|
+
// a sender posting `null`/array/scalar payloads gets a clean validation
|
|
567
|
+
// error instead of a TypeError on `.from`.
|
|
568
|
+
const decryptedRaw = JSON.parse(new TextDecoder().decode(plaintextBytes));
|
|
569
|
+
if (decryptedRaw === null || typeof decryptedRaw !== "object" || Array.isArray(decryptedRaw)) {
|
|
570
|
+
throw new Error("Inner envelope must be a JSON object");
|
|
571
|
+
}
|
|
572
|
+
const decrypted = decryptedRaw as Record<string, unknown>;
|
|
573
|
+
|
|
574
|
+
// 5. Verify inner/outer consistency
|
|
575
|
+
if (decrypted.from !== envelope.from) {
|
|
576
|
+
throw new Error("Inner envelope 'from' does not match outer envelope");
|
|
577
|
+
}
|
|
578
|
+
// recipientDid is optional, but if the caller supplies it we MUST
|
|
579
|
+
// bind. Using `recipientDid &&` would silently skip the check on an
|
|
580
|
+
// empty string — an integrator passing `process.env.AGENT_DID ?? ""`
|
|
581
|
+
// would think they were binding and not be. Be explicit:
|
|
582
|
+
if (recipientDid !== undefined) {
|
|
583
|
+
if (typeof recipientDid !== "string" || recipientDid.length === 0) {
|
|
584
|
+
throw new Error("recipientDid must be a non-empty string when provided");
|
|
585
|
+
}
|
|
586
|
+
if (decrypted.to !== recipientDid) {
|
|
587
|
+
throw new Error("Inner envelope 'to' does not match recipient DID");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return decrypted;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── INK v0.1 Replay Protection (§3.5) ──
|
|
595
|
+
|
|
596
|
+
export interface ReplayCheckInput {
|
|
597
|
+
messageTimestamp: string;
|
|
598
|
+
receiverClock: string;
|
|
599
|
+
nonce: string;
|
|
600
|
+
previouslySeenNonces: string[];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface ReplayCheckResult {
|
|
604
|
+
accepted: boolean;
|
|
605
|
+
errorCode?: "expired_message" | "duplicate_nonce";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
609
|
+
export const MAX_FUTURE_TIMESTAMP_MS = 30 * 1000; // 30 seconds
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Check whether an INK message should be accepted or rejected
|
|
613
|
+
* based on timestamp freshness and nonce deduplication (§3.5).
|
|
614
|
+
*/
|
|
615
|
+
export function checkReplay(input: ReplayCheckInput): ReplayCheckResult {
|
|
616
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
617
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
618
|
+
}
|
|
619
|
+
if (
|
|
620
|
+
typeof input.nonce !== "string" ||
|
|
621
|
+
input.nonce.length < 16 ||
|
|
622
|
+
input.nonce.length > 256 ||
|
|
623
|
+
!/^[A-Za-z0-9_-]+$/.test(input.nonce)
|
|
624
|
+
) {
|
|
625
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
626
|
+
}
|
|
627
|
+
if (!Array.isArray(input.previouslySeenNonces) || input.previouslySeenNonces.length > 10_000) {
|
|
628
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Length-cap both timestamp strings before handing them to Date()
|
|
632
|
+
// so a multi-megabyte value can't burn CPU in the engine's date
|
|
633
|
+
// parser before the finite-time check rejects. 64 chars matches the
|
|
634
|
+
// cap used elsewhere in INK (ISO 8601 fits in ~30 chars).
|
|
635
|
+
if (
|
|
636
|
+
typeof input.messageTimestamp !== "string" ||
|
|
637
|
+
input.messageTimestamp.length === 0 ||
|
|
638
|
+
input.messageTimestamp.length > 64 ||
|
|
639
|
+
typeof input.receiverClock !== "string" ||
|
|
640
|
+
input.receiverClock.length === 0 ||
|
|
641
|
+
input.receiverClock.length > 64
|
|
642
|
+
) {
|
|
643
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
644
|
+
}
|
|
645
|
+
// Parse timestamps — NaN values would cause all drift comparisons to return
|
|
646
|
+
// false (NaN > x and NaN < x are both false), allowing any timestamp to pass.
|
|
647
|
+
// Explicitly reject non-finite results.
|
|
648
|
+
const msgTime = new Date(input.messageTimestamp).getTime();
|
|
649
|
+
const recvTime = new Date(input.receiverClock).getTime();
|
|
650
|
+
if (!Number.isFinite(msgTime) || !Number.isFinite(recvTime)) {
|
|
651
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const drift = msgTime - recvTime;
|
|
655
|
+
|
|
656
|
+
// Reject if timestamp is too far in the future
|
|
657
|
+
if (drift > MAX_FUTURE_TIMESTAMP_MS) {
|
|
658
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Reject if timestamp is too old
|
|
662
|
+
if (-drift > MAX_TIMESTAMP_AGE_MS) {
|
|
663
|
+
return { accepted: false, errorCode: "expired_message" };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Reject if nonce was already seen
|
|
667
|
+
if (input.previouslySeenNonces.includes(input.nonce)) {
|
|
668
|
+
return { accepted: false, errorCode: "duplicate_nonce" };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return { accepted: true };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── INK Audit Crypto (Auditability §2) ──
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Compute SHA-256 hash of JCS-canonicalized body. Returns hex string.
|
|
678
|
+
* Used for messageHash in receipts and previousEventHash in audit chains.
|
|
679
|
+
*/
|
|
680
|
+
export async function computeMessageHash(body: Record<string, unknown>): Promise<string> {
|
|
681
|
+
// Mirrors the sign/verify-side guards. messageHash is bound into
|
|
682
|
+
// receipts; a poisoned receipt body would otherwise burn CPU inside
|
|
683
|
+
// canonicalize before the receipt verifier ever rejects it.
|
|
684
|
+
if (!isWithinCanonicalizeBounds(body)) {
|
|
685
|
+
throw new Error("Message body exceeds maximum allowed complexity");
|
|
686
|
+
}
|
|
687
|
+
const canonical = jcsCanonicalize(body);
|
|
688
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
|
|
689
|
+
throw new Error("Message body exceeds maximum allowed size");
|
|
690
|
+
}
|
|
691
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
692
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
693
|
+
return bytesToHex(new Uint8Array(digest));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Sign an INK audit event. Returns base64url-encoded Ed25519 signature.
|
|
698
|
+
* Signs the JCS-canonicalized event with the agentSignature field excluded.
|
|
699
|
+
*/
|
|
700
|
+
export async function signAuditEvent(
|
|
701
|
+
event: Record<string, unknown>,
|
|
702
|
+
privateKey: Uint8Array,
|
|
703
|
+
): Promise<string> {
|
|
704
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
705
|
+
throw new Error("event must be a non-null object");
|
|
706
|
+
}
|
|
707
|
+
// Remove agentSignature before canonicalizing
|
|
708
|
+
const { agentSignature: _, ...eventWithoutSig } = event;
|
|
709
|
+
// Mirror the verify-side guards: refuse pathological events at sign
|
|
710
|
+
// time so a service can't be coerced into burning CPU/memory minting
|
|
711
|
+
// a signature over an event no verifier would accept.
|
|
712
|
+
if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
|
|
713
|
+
throw new Error("Audit event exceeds maximum allowed complexity");
|
|
714
|
+
}
|
|
715
|
+
const canonical = jcsCanonicalize(eventWithoutSig);
|
|
716
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
|
|
717
|
+
throw new Error("Audit event exceeds maximum allowed size");
|
|
718
|
+
}
|
|
719
|
+
// Domain separation: prefix prevents cross-protocol signature replay
|
|
720
|
+
const prefixed = `ink/audit-event\n${canonical}`;
|
|
721
|
+
const bytes = new TextEncoder().encode(prefixed);
|
|
722
|
+
const sig = await ed.signAsync(bytes, privateKey);
|
|
723
|
+
return base64urlEncode(sig);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Verify an INK audit event signature.
|
|
728
|
+
* Returns false (never throws) for malformed or wrong-length signatures.
|
|
729
|
+
*/
|
|
730
|
+
export async function verifyAuditEventSignature(
|
|
731
|
+
event: Record<string, unknown>,
|
|
732
|
+
publicKey: Uint8Array,
|
|
733
|
+
): Promise<boolean> {
|
|
734
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
|
|
735
|
+
const signature = event.agentSignature as string;
|
|
736
|
+
if (typeof signature !== "string") return false;
|
|
737
|
+
// Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
|
|
738
|
+
if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
|
|
739
|
+
const { agentSignature: _, ...eventWithoutSig } = event;
|
|
740
|
+
// Pre-canonicalize complexity cap: bail before jcsCanonicalize walks an
|
|
741
|
+
// attacker-supplied object that would only get rejected by the size cap
|
|
742
|
+
// below. Cheap enough that it adds no cost for real events.
|
|
743
|
+
if (!isWithinCanonicalizeBounds(eventWithoutSig)) return false;
|
|
744
|
+
const canonical = jcsCanonicalize(eventWithoutSig);
|
|
745
|
+
// Defense-in-depth: cap canonicalized body size to bound pre-verify work.
|
|
746
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
|
|
747
|
+
// Domain separation: must match signAuditEvent prefix
|
|
748
|
+
const prefixed = `ink/audit-event\n${canonical}`;
|
|
749
|
+
const bytes = new TextEncoder().encode(prefixed);
|
|
750
|
+
try {
|
|
751
|
+
const sig = base64urlDecode(signature);
|
|
752
|
+
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
753
|
+
} catch {
|
|
754
|
+
// Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Compute SHA-256 hash of JCS-canonicalized audit event (excluding agentSignature).
|
|
761
|
+
* Used for previousEventHash chain linkage.
|
|
762
|
+
*/
|
|
763
|
+
export async function computeEventHash(event: Record<string, unknown>): Promise<string> {
|
|
764
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
765
|
+
throw new Error("event must be a non-null object");
|
|
766
|
+
}
|
|
767
|
+
const { agentSignature: _, ...eventWithoutSig } = event;
|
|
768
|
+
// Mirrors the sign/verify-side guards: previousEventHash flows from
|
|
769
|
+
// this function into hash-chained audit logs, so a poisoned event
|
|
770
|
+
// could otherwise burn CPU/memory inside canonicalize before the
|
|
771
|
+
// chain insertion path notices the size.
|
|
772
|
+
if (!isWithinCanonicalizeBounds(eventWithoutSig)) {
|
|
773
|
+
throw new Error("Audit event exceeds maximum allowed complexity");
|
|
774
|
+
}
|
|
775
|
+
const canonical = jcsCanonicalize(eventWithoutSig);
|
|
776
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
|
|
777
|
+
throw new Error("Audit event exceeds maximum allowed size");
|
|
778
|
+
}
|
|
779
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
780
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
781
|
+
return bytesToHex(new Uint8Array(digest));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Sign an INK audit response. Returns base64url-encoded Ed25519 signature.
|
|
786
|
+
* Domain-separated: signs "ink/audit-response\n" + JCS(events) to prevent
|
|
787
|
+
* cross-protocol signature replay.
|
|
788
|
+
*/
|
|
789
|
+
export async function signAuditResponse(
|
|
790
|
+
events: unknown[],
|
|
791
|
+
privateKey: Uint8Array,
|
|
792
|
+
): Promise<string> {
|
|
793
|
+
// Pre-canonicalize complexity cap — mirrors verifyAuditResponseSignature
|
|
794
|
+
// so a peer requesting an audit response cannot make the responder
|
|
795
|
+
// burn CPU/memory inside jcsCanonicalize before the length cap below.
|
|
796
|
+
if (!isWithinCanonicalizeBounds(events)) {
|
|
797
|
+
throw new Error("Audit response events exceed maximum allowed complexity");
|
|
798
|
+
}
|
|
799
|
+
const canonical = jcsCanonicalize(events);
|
|
800
|
+
// Cap canonicalized body size — mirrors the verify path's guard so the
|
|
801
|
+
// sign side can't be used to mint signatures over payloads larger than
|
|
802
|
+
// any conformant verifier would accept.
|
|
803
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) {
|
|
804
|
+
throw new Error("Audit response events exceed maximum allowed size");
|
|
805
|
+
}
|
|
806
|
+
const prefixed = `ink/audit-response\n${canonical}`;
|
|
807
|
+
const bytes = new TextEncoder().encode(prefixed);
|
|
808
|
+
const sig = await ed.signAsync(bytes, privateKey);
|
|
809
|
+
return base64urlEncode(sig);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Verify an INK audit response signature.
|
|
814
|
+
* Expects the domain-separated format: "ink/audit-response\n" + JCS(events).
|
|
815
|
+
* Returns false (never throws) for malformed or wrong-length signatures.
|
|
816
|
+
*/
|
|
817
|
+
export async function verifyAuditResponseSignature(
|
|
818
|
+
events: unknown[],
|
|
819
|
+
signature: string,
|
|
820
|
+
publicKey: Uint8Array,
|
|
821
|
+
): Promise<boolean> {
|
|
822
|
+
if (!Array.isArray(events)) return false;
|
|
823
|
+
if (typeof signature !== "string") return false;
|
|
824
|
+
// Ed25519 signatures are exactly 64 bytes = 86 unpadded base64url chars.
|
|
825
|
+
if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
|
|
826
|
+
// Pre-canonicalize complexity cap (see verifyAuditEventSignature).
|
|
827
|
+
if (!isWithinCanonicalizeBounds(events)) return false;
|
|
828
|
+
const canonical = jcsCanonicalize(events);
|
|
829
|
+
// Defense-in-depth: cap canonicalized body size to bound pre-verify work.
|
|
830
|
+
if (canonical.length > MAX_SIGBASE_BODY_BYTES) return false;
|
|
831
|
+
const prefixed = `ink/audit-response\n${canonical}`;
|
|
832
|
+
const bytes = new TextEncoder().encode(prefixed);
|
|
833
|
+
try {
|
|
834
|
+
const sig = base64urlDecode(signature);
|
|
835
|
+
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
836
|
+
} catch {
|
|
837
|
+
// Malformed signature (wrong length, invalid chars, bad key) — treat as invalid
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Validate the internal continuity of an audit event chain. Distinct
|
|
844
|
+
* from verifyAuditResponseSignature, which only verifies the response
|
|
845
|
+
* wrapper signature. Callers fetching audit responses MUST call both:
|
|
846
|
+
* the signature gate proves the witness/agent attested to this slice,
|
|
847
|
+
* this gate proves the slice itself is contiguous and fork-free.
|
|
848
|
+
*
|
|
849
|
+
* Rules enforced:
|
|
850
|
+
* - input must be an array of non-null plain objects
|
|
851
|
+
* - each event must have integer sequence and string-or-null previousEventHash
|
|
852
|
+
* - sequences within the response must be strictly increasing by 1
|
|
853
|
+
* (a partial-window response anchored elsewhere is fine, but no internal gaps)
|
|
854
|
+
* - duplicate sequence numbers within the response are a fork
|
|
855
|
+
* - events[i].previousEventHash MUST equal computeEventHash(events[i-1]) for i >= 1
|
|
856
|
+
* - events[0].previousEventHash is NOT verified against any external
|
|
857
|
+
* anchor; callers that have one (a prior pinned event hash) must
|
|
858
|
+
* verify the boundary themselves
|
|
859
|
+
*/
|
|
860
|
+
export async function verifyAuditEventChain(
|
|
861
|
+
events: unknown,
|
|
862
|
+
): Promise<
|
|
863
|
+
| { valid: true }
|
|
864
|
+
| { valid: false; error: "invalid_input" | "invalid_event" | "sequence_gap" | "sequence_fork" | "previous_hash_mismatch" }
|
|
865
|
+
> {
|
|
866
|
+
if (!Array.isArray(events)) return { valid: false, error: "invalid_input" };
|
|
867
|
+
if (events.length === 0) return { valid: true };
|
|
868
|
+
|
|
869
|
+
let lastSeq: number | null = null;
|
|
870
|
+
let lastHash: string | null = null;
|
|
871
|
+
for (let i = 0; i < events.length; i++) {
|
|
872
|
+
const ev = events[i];
|
|
873
|
+
if (ev === null || typeof ev !== "object" || Array.isArray(ev)) {
|
|
874
|
+
return { valid: false, error: "invalid_event" };
|
|
875
|
+
}
|
|
876
|
+
const seq = (ev as Record<string, unknown>).sequence;
|
|
877
|
+
const prev = (ev as Record<string, unknown>).previousEventHash;
|
|
878
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
|
|
879
|
+
return { valid: false, error: "invalid_event" };
|
|
880
|
+
}
|
|
881
|
+
if (prev !== null && typeof prev !== "string") {
|
|
882
|
+
return { valid: false, error: "invalid_event" };
|
|
883
|
+
}
|
|
884
|
+
if (i > 0) {
|
|
885
|
+
if (seq === lastSeq) return { valid: false, error: "sequence_fork" };
|
|
886
|
+
if (seq !== (lastSeq as number) + 1) return { valid: false, error: "sequence_gap" };
|
|
887
|
+
if (prev !== lastHash) return { valid: false, error: "previous_hash_mismatch" };
|
|
888
|
+
}
|
|
889
|
+
let thisHash: string;
|
|
890
|
+
try {
|
|
891
|
+
thisHash = await computeEventHash(ev as Record<string, unknown>);
|
|
892
|
+
} catch {
|
|
893
|
+
return { valid: false, error: "invalid_event" };
|
|
894
|
+
}
|
|
895
|
+
lastSeq = seq;
|
|
896
|
+
lastHash = thisHash;
|
|
897
|
+
}
|
|
898
|
+
return { valid: true };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Re-export encoding helpers for test use
|
|
902
|
+
export { base64urlEncode, base64urlDecode, hexToBytes, bytesToHex, jcsCanonicalize };
|