@adastracomputing/ink 0.1.0-alpha.3 → 0.1.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +15 -3
  3. package/dist/audit/inclusion-receipt.d.ts +142 -0
  4. package/dist/audit/inclusion-receipt.js +496 -0
  5. package/dist/crypto/ink.d.ts +178 -0
  6. package/dist/crypto/ink.js +915 -0
  7. package/dist/crypto/keys.d.ts +42 -0
  8. package/dist/crypto/keys.js +179 -0
  9. package/dist/crypto/multi-key-verify.d.ts +29 -0
  10. package/dist/crypto/multi-key-verify.js +153 -0
  11. package/dist/crypto/sign.d.ts +17 -0
  12. package/dist/crypto/sign.js +152 -0
  13. package/dist/crypto/verify.js +1 -0
  14. package/dist/discovery/agent-card.d.ts +83 -0
  15. package/dist/discovery/agent-card.js +545 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.js +16 -0
  18. package/dist/ink/checkpoint.d.ts +19 -0
  19. package/dist/ink/checkpoint.js +69 -0
  20. package/dist/ink/discovery-gating.d.ts +247 -0
  21. package/dist/ink/discovery-gating.js +94 -0
  22. package/dist/ink/handshake-budget.d.ts +90 -0
  23. package/dist/ink/handshake-budget.js +397 -0
  24. package/dist/ink/receipts.d.ts +31 -0
  25. package/dist/ink/receipts.js +89 -0
  26. package/dist/ink/transport-auth.d.ts +47 -0
  27. package/dist/ink/transport-auth.js +77 -0
  28. package/dist/middleware/ink-auth.d.ts +68 -0
  29. package/dist/middleware/ink-auth.js +214 -0
  30. package/dist/models/agent-card.d.ts +170 -0
  31. package/dist/models/agent-card.js +107 -0
  32. package/dist/models/ink-audit.d.ts +344 -0
  33. package/dist/models/ink-audit.js +167 -0
  34. package/dist/models/ink-handshake.d.ts +129 -0
  35. package/dist/models/ink-handshake.js +89 -0
  36. package/dist/models/intent.d.ts +437 -0
  37. package/dist/models/intent.js +172 -0
  38. package/dist/models/key-entry.d.ts +60 -0
  39. package/dist/models/key-entry.js +13 -0
  40. package/dist/models/profile.d.ts +61 -0
  41. package/dist/models/profile.js +24 -0
  42. package/package.json +15 -11
  43. package/specs/ink-auditability.md +2 -2
  44. package/specs/ink-containment-phase1-implementation-spec.md +1 -1
  45. package/src/audit/inclusion-receipt.ts +0 -604
  46. package/src/crypto/ink.ts +0 -1046
  47. package/src/crypto/keys.ts +0 -210
  48. package/src/crypto/multi-key-verify.ts +0 -170
  49. package/src/crypto/sign.ts +0 -155
  50. package/src/discovery/agent-card.ts +0 -508
  51. package/src/index.ts +0 -73
  52. package/src/ink/checkpoint.ts +0 -75
  53. package/src/ink/discovery-gating.ts +0 -147
  54. package/src/ink/handshake-budget.ts +0 -413
  55. package/src/ink/receipts.ts +0 -114
  56. package/src/ink/transport-auth.ts +0 -96
  57. package/src/middleware/ink-auth.ts +0 -263
  58. package/src/models/agent-card.ts +0 -63
  59. package/src/models/ink-audit.ts +0 -205
  60. package/src/models/ink-handshake.ts +0 -123
  61. package/src/models/intent.ts +0 -201
  62. package/src/models/key-entry.ts +0 -52
  63. package/src/models/profile.ts +0 -31
  64. /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
@@ -0,0 +1,42 @@
1
+ export interface Keypair {
2
+ privateKey: Uint8Array;
3
+ publicKey: Uint8Array;
4
+ }
5
+ /** Generate a new Ed25519 keypair (signing). */
6
+ export declare function generateKeypair(): Promise<Keypair>;
7
+ /** Generate a new X25519 keypair (encryption). */
8
+ export declare function generateEncryptionKeypair(): Keypair;
9
+ /** Encode bytes as base58btc (no multibase prefix). */
10
+ export declare function encodeBase58(bytes: Uint8Array): string;
11
+ /** Decode base58btc string to bytes. */
12
+ export declare function decodeBase58(str: string): Uint8Array;
13
+ /**
14
+ * Encode a public key as a multibase base58btc string.
15
+ * Format: 'z' prefix + base58btc(multicodec_prefix + public_key)
16
+ */
17
+ export declare function encodePublicKeyMultibase(publicKey: Uint8Array): string;
18
+ /**
19
+ * Encode an X25519 public key as a multibase base58btc string.
20
+ * Format: 'z' prefix + base58btc(x25519_multicodec_prefix + public_key)
21
+ */
22
+ export declare function encodeEncryptionKeyMultibase(publicKey: Uint8Array): string;
23
+ /**
24
+ * Decode a multibase base58btc public key string.
25
+ * Returns the raw 32-byte public key.
26
+ */
27
+ export declare function decodePublicKeyMultibase(multibase: string): Uint8Array;
28
+ /**
29
+ * Decode a multibase base58btc X25519 public key string.
30
+ * Returns the raw 32-byte public key.
31
+ */
32
+ export declare function decodeEncryptionKeyMultibase(multibase: string): Uint8Array;
33
+ /**
34
+ * Derive agent ID from a public key.
35
+ * Format: tulpa:<multibase-encoded-public-key>
36
+ */
37
+ export declare function deriveAgentId(publicKey: Uint8Array): string;
38
+ /**
39
+ * Extract the public key from an agent ID.
40
+ * Only used for initial key exchange — after that, always resolve via identity store.
41
+ */
42
+ export declare function extractPublicKeyFromAgentId(agentId: string): Uint8Array;
@@ -0,0 +1,179 @@
1
+ import * as ed from "@noble/ed25519";
2
+ import { x25519 } from "@noble/curves/ed25519.js";
3
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4
+ /** Ed25519 multicodec prefix: 0xed01 */
5
+ const ED25519_MULTICODEC = new Uint8Array([0xed, 0x01]);
6
+ /** X25519 multicodec prefix: 0xec01 */
7
+ const X25519_MULTICODEC = new Uint8Array([0xec, 0x01]);
8
+ /** Generate a new Ed25519 keypair (signing). */
9
+ export async function generateKeypair() {
10
+ const { secretKey, publicKey } = await ed.keygenAsync();
11
+ return { privateKey: secretKey, publicKey };
12
+ }
13
+ /** Generate a new X25519 keypair (encryption). */
14
+ export function generateEncryptionKeypair() {
15
+ const privateKey = crypto.getRandomValues(new Uint8Array(32));
16
+ const publicKey = x25519.getPublicKey(privateKey);
17
+ return { privateKey, publicKey };
18
+ }
19
+ /** Encode bytes as base58btc (no multibase prefix). */
20
+ export function encodeBase58(bytes) {
21
+ if (bytes.length === 0)
22
+ return "";
23
+ // Count leading zeros
24
+ let zeros = 0;
25
+ for (const b of bytes) {
26
+ if (b !== 0)
27
+ break;
28
+ zeros++;
29
+ }
30
+ // Convert to bigint
31
+ let num = 0n;
32
+ for (const b of bytes) {
33
+ num = num * 256n + BigInt(b);
34
+ }
35
+ let result = "";
36
+ while (num > 0n) {
37
+ const remainder = Number(num % 58n);
38
+ num = num / 58n;
39
+ result = BASE58_ALPHABET[remainder] + result;
40
+ }
41
+ // Add leading '1's for zero bytes
42
+ return "1".repeat(zeros) + result;
43
+ }
44
+ /** Cap base58 input length BEFORE the BigInt accumulation loop. A poisoned
45
+ * Agent Card with a multi-KB `publicKeyMultibase` would otherwise force
46
+ * O(n^2) BigInt arithmetic — large `num * 58n + BigInt(idx)` per char — for
47
+ * every input byte before any trailing length check fires.
48
+ *
49
+ * 1024 is well above any legitimate multibase-encoded public key (Ed25519
50
+ * is ~50 chars, even multi-codec wrappers are well under 100).
51
+ */
52
+ const MAX_BASE58_INPUT_LEN = 1024;
53
+ /** Decode base58btc string to bytes. */
54
+ export function decodeBase58(str) {
55
+ if (str.length === 0)
56
+ return new Uint8Array(0);
57
+ if (str.length > MAX_BASE58_INPUT_LEN) {
58
+ throw new Error(`base58 input exceeds maximum length of ${MAX_BASE58_INPUT_LEN}`);
59
+ }
60
+ let num = 0n;
61
+ for (const ch of str) {
62
+ const idx = BASE58_ALPHABET.indexOf(ch);
63
+ if (idx === -1)
64
+ throw new Error(`Invalid base58 character: ${ch}`);
65
+ num = num * 58n + BigInt(idx);
66
+ }
67
+ // Convert bigint to bytes
68
+ const hex = num.toString(16).padStart(2, "0");
69
+ const padded = hex.length % 2 ? "0" + hex : hex;
70
+ const bytes = [];
71
+ for (let i = 0; i < padded.length; i += 2) {
72
+ bytes.push(parseInt(padded.slice(i, i + 2), 16));
73
+ }
74
+ // Add leading zero bytes
75
+ let zeros = 0;
76
+ for (const ch of str) {
77
+ if (ch !== "1")
78
+ break;
79
+ zeros++;
80
+ }
81
+ return new Uint8Array([...new Uint8Array(zeros), ...bytes]);
82
+ }
83
+ /**
84
+ * Encode a public key as a multibase base58btc string.
85
+ * Format: 'z' prefix + base58btc(multicodec_prefix + public_key)
86
+ */
87
+ export function encodePublicKeyMultibase(publicKey) {
88
+ if (!(publicKey instanceof Uint8Array)) {
89
+ throw new Error("publicKey must be a Uint8Array");
90
+ }
91
+ if (publicKey.length !== 32) {
92
+ throw new Error(`publicKey must be 32 bytes, got ${publicKey.length}`);
93
+ }
94
+ const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKey.length);
95
+ prefixed.set(ED25519_MULTICODEC);
96
+ prefixed.set(publicKey, ED25519_MULTICODEC.length);
97
+ return "z" + encodeBase58(prefixed);
98
+ }
99
+ /**
100
+ * Encode an X25519 public key as a multibase base58btc string.
101
+ * Format: 'z' prefix + base58btc(x25519_multicodec_prefix + public_key)
102
+ */
103
+ export function encodeEncryptionKeyMultibase(publicKey) {
104
+ if (!(publicKey instanceof Uint8Array)) {
105
+ throw new Error("publicKey must be a Uint8Array");
106
+ }
107
+ if (publicKey.length !== 32) {
108
+ throw new Error(`publicKey must be 32 bytes, got ${publicKey.length}`);
109
+ }
110
+ const prefixed = new Uint8Array(X25519_MULTICODEC.length + publicKey.length);
111
+ prefixed.set(X25519_MULTICODEC);
112
+ prefixed.set(publicKey, X25519_MULTICODEC.length);
113
+ return "z" + encodeBase58(prefixed);
114
+ }
115
+ /**
116
+ * Decode a multibase base58btc public key string.
117
+ * Returns the raw 32-byte public key.
118
+ */
119
+ export function decodePublicKeyMultibase(multibase) {
120
+ if (typeof multibase !== "string" || multibase.length === 0 || multibase.length > 1024) {
121
+ throw new Error("multibase must be a non-empty string under 1024 chars");
122
+ }
123
+ if (!multibase.startsWith("z")) {
124
+ throw new Error("Expected multibase base58btc prefix 'z'");
125
+ }
126
+ const decoded = decodeBase58(multibase.slice(1));
127
+ if (decoded[0] !== ED25519_MULTICODEC[0] ||
128
+ decoded[1] !== ED25519_MULTICODEC[1]) {
129
+ throw new Error("Invalid Ed25519 multicodec prefix");
130
+ }
131
+ const key = decoded.slice(2);
132
+ if (key.length !== 32) {
133
+ throw new Error(`Invalid Ed25519 public key length: expected 32, got ${key.length}`);
134
+ }
135
+ return key;
136
+ }
137
+ /**
138
+ * Decode a multibase base58btc X25519 public key string.
139
+ * Returns the raw 32-byte public key.
140
+ */
141
+ export function decodeEncryptionKeyMultibase(multibase) {
142
+ if (typeof multibase !== "string" || multibase.length === 0 || multibase.length > 1024) {
143
+ throw new Error("multibase must be a non-empty string under 1024 chars");
144
+ }
145
+ if (!multibase.startsWith("z")) {
146
+ throw new Error("Expected multibase base58btc prefix 'z'");
147
+ }
148
+ const decoded = decodeBase58(multibase.slice(1));
149
+ if (decoded[0] !== X25519_MULTICODEC[0] ||
150
+ decoded[1] !== X25519_MULTICODEC[1]) {
151
+ throw new Error("Invalid X25519 multicodec prefix");
152
+ }
153
+ const key = decoded.slice(2);
154
+ if (key.length !== 32) {
155
+ throw new Error(`Invalid X25519 public key length: expected 32, got ${key.length}`);
156
+ }
157
+ return key;
158
+ }
159
+ /**
160
+ * Derive agent ID from a public key.
161
+ * Format: tulpa:<multibase-encoded-public-key>
162
+ */
163
+ export function deriveAgentId(publicKey) {
164
+ return `tulpa:${encodePublicKeyMultibase(publicKey)}`;
165
+ }
166
+ /**
167
+ * Extract the public key from an agent ID.
168
+ * Only used for initial key exchange — after that, always resolve via identity store.
169
+ */
170
+ export function extractPublicKeyFromAgentId(agentId) {
171
+ if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
172
+ throw new Error("Invalid agent ID");
173
+ }
174
+ const prefix = "tulpa:";
175
+ if (!agentId.startsWith(prefix)) {
176
+ throw new Error("Invalid agent ID format");
177
+ }
178
+ return decodePublicKeyMultibase(agentId.slice(prefix.length));
179
+ }
@@ -0,0 +1,29 @@
1
+ import { type InkSignInput } from "./ink.js";
2
+ import type { CandidateKey, KeyStatus } from "../models/key-entry.js";
3
+ export interface MultiKeyVerifyResult {
4
+ verified: boolean;
5
+ keyId?: string;
6
+ /** Status of the key that verified the signature (for observability). */
7
+ keyStatus?: KeyStatus;
8
+ /** True when the signature was verified using a retired key. Callers should track this for key rotation observability. */
9
+ usedRetiredKey?: boolean;
10
+ }
11
+ /**
12
+ * Verify an INK signature against a set of candidate keys.
13
+ *
14
+ * Verification order per spec §6.4:
15
+ * 1. Hinted key (if provided and found) — optimization for keyId header
16
+ * 2. Active keys first
17
+ * 3. Retired keys second
18
+ * 4. Revoked keys are always skipped
19
+ *
20
+ * In all three cases the key's `[validFrom, validUntil]` window MUST
21
+ * contain the message timestamp. A key that has expired (validUntil in
22
+ * the past) or is not yet valid (validFrom in the future) is skipped
23
+ * even if its status would otherwise admit it. This closes the window
24
+ * where an attacker who steals an expired key — even one still listed
25
+ * as "retired" for historical verification — could sign fresh messages.
26
+ *
27
+ * Returns the matching keyId and keyStatus on success.
28
+ */
29
+ export declare function verifyInkSignatureWithKeys(input: InkSignInput, signature: string, keys: CandidateKey[], hintKeyId?: string): Promise<MultiKeyVerifyResult>;
@@ -0,0 +1,153 @@
1
+ import { verifyInkSignature } from "./ink.js";
2
+ /** Maximum number of candidate keys tried during multi-key verification.
3
+ * Prevents a poisoned Agent Card from forcing O(n) Ed25519 verifications. */
4
+ const MAX_CANDIDATE_KEYS = 20;
5
+ /**
6
+ * Check whether a candidate key's validity window contains a given
7
+ * message timestamp. Returns true when the key is usable. Both window
8
+ * endpoints are optional; missing endpoints are treated as open (so a
9
+ * key with no validFrom is usable arbitrarily far back, and a key with
10
+ * no validUntil is usable arbitrarily far forward — preserving the
11
+ * legacy behaviour for callers that don't track windows).
12
+ *
13
+ * Defense in depth at the verifier:
14
+ * - status === "revoked" is already filtered upstream; this function
15
+ * ALSO refuses any key whose `revokedAt` field is present, in case
16
+ * a caller forgot to set status.
17
+ * - Non-string OR empty-string window fields are treated as malformed
18
+ * and fail closed. An integrator that maps a NULL/blank database
19
+ * column to "" must not get the same behaviour as "field absent" —
20
+ * that would let an expired key slip through under the legacy
21
+ * "no window = open" rule. The matching boundary check lives in
22
+ * extractCandidateKeys; this guard catches custom resolveKeySet
23
+ * implementations that bypass that boundary.
24
+ * - Malformed timestamp strings (Date.parse returns NaN) also fail
25
+ * closed for the same reason.
26
+ */
27
+ function isKeyValidAtTime(key, messageMs) {
28
+ // Any field that is PRESENT but not a non-empty parseable datetime
29
+ // string is treated as malformed and fails closed. "Present" means
30
+ // !== undefined, so a `null`, number, object, or empty string here
31
+ // is a misuse — refusing it stops a custom resolveKeySet that maps a
32
+ // DB NULL to "" (or to literal null) from looking like "no window".
33
+ const isPresent = (x) => x !== undefined;
34
+ // Cap length BEFORE Date.parse — a multi-megabyte string would
35
+ // otherwise burn CPU in the date parser before the parse failure.
36
+ // 64 chars matches the cap used everywhere else in INK (ISO 8601
37
+ // with subsecond + timezone fits in ~30; 64 leaves headroom).
38
+ const isValidDatetimeString = (x) => typeof x === "string" && x.length > 0 && x.length <= 64 && Number.isFinite(Date.parse(x));
39
+ if (isPresent(key.revokedAt)) {
40
+ // revokedAt present at all is a "do not verify" signal regardless
41
+ // of whether the value parses. A revoked key with an unparseable
42
+ // revokedAt is still revoked.
43
+ return false;
44
+ }
45
+ if (isPresent(key.validFrom)) {
46
+ if (!isValidDatetimeString(key.validFrom))
47
+ return false;
48
+ if (messageMs < Date.parse(key.validFrom))
49
+ return false;
50
+ }
51
+ if (isPresent(key.validUntil)) {
52
+ if (!isValidDatetimeString(key.validUntil))
53
+ return false;
54
+ if (messageMs > Date.parse(key.validUntil))
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+ /**
60
+ * Verify an INK signature against a set of candidate keys.
61
+ *
62
+ * Verification order per spec §6.4:
63
+ * 1. Hinted key (if provided and found) — optimization for keyId header
64
+ * 2. Active keys first
65
+ * 3. Retired keys second
66
+ * 4. Revoked keys are always skipped
67
+ *
68
+ * In all three cases the key's `[validFrom, validUntil]` window MUST
69
+ * contain the message timestamp. A key that has expired (validUntil in
70
+ * the past) or is not yet valid (validFrom in the future) is skipped
71
+ * even if its status would otherwise admit it. This closes the window
72
+ * where an attacker who steals an expired key — even one still listed
73
+ * as "retired" for historical verification — could sign fresh messages.
74
+ *
75
+ * Returns the matching keyId and keyStatus on success.
76
+ */
77
+ export async function verifyInkSignatureWithKeys(input, signature, keys, hintKeyId) {
78
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
79
+ return { verified: false };
80
+ }
81
+ if (!Array.isArray(keys) || keys.length === 0) {
82
+ return { verified: false };
83
+ }
84
+ if (typeof signature !== "string") {
85
+ return { verified: false };
86
+ }
87
+ // Parse the message timestamp once so window checks are O(1) per key.
88
+ // verifyInkAuth caps timestamp length upstream, but this helper is
89
+ // exported, so guard locally too: a non-string, empty, oversized, or
90
+ // non-parseable timestamp all fail closed. The 64-char cap stops a
91
+ // multi-megabyte string from reaching Date.parse.
92
+ if (typeof input.timestamp !== "string" || input.timestamp.length === 0 || input.timestamp.length > 64) {
93
+ return { verified: false };
94
+ }
95
+ const messageMs = Date.parse(input.timestamp);
96
+ if (!Number.isFinite(messageMs)) {
97
+ return { verified: false };
98
+ }
99
+ // Enforce an upper bound on key set size to prevent DoS via poisoned Agent Cards
100
+ // that contain hundreds of keys, forcing that many Ed25519 operations per request.
101
+ const bounded = keys.slice(0, MAX_CANDIDATE_KEYS);
102
+ // Try hinted key first if provided.
103
+ // Allowlist of acceptable statuses. A deny-list (k.status !== "revoked") would
104
+ // accept entries with malformed/unrecognised status — e.g. case-mismatched
105
+ // "Revoked" or empty string would slip past here while being skipped by the
106
+ // active/retired partition iteration below.
107
+ if (hintKeyId) {
108
+ const hinted = bounded.find((k) => k.keyId === hintKeyId && (k.status === "active" || k.status === "retired"));
109
+ if (hinted && isKeyValidAtTime(hinted, messageMs)) {
110
+ try {
111
+ const valid = await verifyInkSignature(input, signature, hinted.publicKey);
112
+ if (valid)
113
+ return { verified: true, keyId: hinted.keyId, keyStatus: hinted.status, usedRetiredKey: hinted.status === "retired" };
114
+ }
115
+ catch {
116
+ // Fall through to normal iteration
117
+ }
118
+ }
119
+ }
120
+ // Partition by status: active first, then retired. Skip revoked.
121
+ // Drop any candidate whose validity window doesn't contain the
122
+ // message timestamp before reaching the verify loop.
123
+ const active = bounded.filter((k) => k.status === "active" && isKeyValidAtTime(k, messageMs));
124
+ const retired = bounded.filter((k) => k.status === "retired" && isKeyValidAtTime(k, messageMs));
125
+ // Try active keys first
126
+ for (const key of active) {
127
+ // Skip if already tried as hint
128
+ if (hintKeyId && key.keyId === hintKeyId)
129
+ continue;
130
+ try {
131
+ const valid = await verifyInkSignature(input, signature, key.publicKey);
132
+ if (valid)
133
+ return { verified: true, keyId: key.keyId, keyStatus: key.status, usedRetiredKey: false };
134
+ }
135
+ catch {
136
+ // Key failed verification, try next
137
+ }
138
+ }
139
+ // Try retired keys
140
+ for (const key of retired) {
141
+ if (hintKeyId && key.keyId === hintKeyId)
142
+ continue;
143
+ try {
144
+ const valid = await verifyInkSignature(input, signature, key.publicKey);
145
+ if (valid)
146
+ return { verified: true, keyId: key.keyId, keyStatus: key.status, usedRetiredKey: true };
147
+ }
148
+ catch {
149
+ // Key failed verification, try next
150
+ }
151
+ }
152
+ return { verified: false };
153
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sign a message object using Ed25519.
3
+ *
4
+ * 1. Remove `signature` field if present
5
+ * 2. JCS canonicalize (RFC 8785) via `canonicalize` library
6
+ * 3. Sign canonical bytes directly with Ed25519
7
+ * 4. Return base64url-encoded signature (no padding)
8
+ */
9
+ export declare function signMessage(message: Record<string, unknown>, privateKey: Uint8Array): Promise<string>;
10
+ /**
11
+ * Verify a message signature.
12
+ *
13
+ * 1. Extract and remove `signature`
14
+ * 2. JCS canonicalize the rest
15
+ * 3. Verify Ed25519 signature against canonical bytes
16
+ */
17
+ export declare function verifyMessage(message: Record<string, unknown>, publicKey: Uint8Array): Promise<boolean>;
@@ -0,0 +1,152 @@
1
+ import * as ed from "@noble/ed25519";
2
+ import canonicalize from "canonicalize";
3
+ /** Same bounds used by the ink.ts verify paths. Kept in sync so a peer
4
+ * cannot pick the "softer" sign.ts path to bypass the cap. */
5
+ const MAX_MESSAGE_NODES = 10_000;
6
+ const MAX_MESSAGE_DEPTH = 32;
7
+ const MAX_MESSAGE_CHARS = 1_200_000;
8
+ /** Upper limit on the canonicalized message length, matching
9
+ * MAX_SIGBASE_BODY_BYTES in ink.ts. Defense in depth alongside the node
10
+ * walk: a message can be small in node count but still expand to huge
11
+ * canonical bytes via long string values. */
12
+ const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
13
+ /**
14
+ * Cheap depth/node/byte walk over a value before it is handed to
15
+ * `canonicalize`. Bails before the recursive sort+serialize runs, so an
16
+ * attacker who supplies a syntactically valid-shape signature with a
17
+ * pathological message body cannot burn CPU/memory inside the verify
18
+ * path. Mirrors src/crypto/ink.ts:isWithinCanonicalizeBounds, including
19
+ * the byte counter that stops a single huge string from sneaking past
20
+ * the node check.
21
+ */
22
+ function isWithinBounds(value) {
23
+ let nodes = 0;
24
+ let chars = 0;
25
+ function walk(v, depth) {
26
+ if (depth > MAX_MESSAGE_DEPTH)
27
+ return false;
28
+ if (++nodes > MAX_MESSAGE_NODES)
29
+ return false;
30
+ if (v === null || typeof v !== "object") {
31
+ if (typeof v === "string") {
32
+ chars += v.length;
33
+ if (chars > MAX_MESSAGE_CHARS)
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ if (Array.isArray(v)) {
39
+ for (const item of v)
40
+ if (!walk(item, depth + 1))
41
+ return false;
42
+ return true;
43
+ }
44
+ for (const key of Object.keys(v)) {
45
+ if (++nodes > MAX_MESSAGE_NODES)
46
+ return false;
47
+ chars += key.length;
48
+ if (chars > MAX_MESSAGE_CHARS)
49
+ return false;
50
+ if (!walk(v[key], depth + 1))
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+ return walk(value, 0);
56
+ }
57
+ /**
58
+ * Sign a message object using Ed25519.
59
+ *
60
+ * 1. Remove `signature` field if present
61
+ * 2. JCS canonicalize (RFC 8785) via `canonicalize` library
62
+ * 3. Sign canonical bytes directly with Ed25519
63
+ * 4. Return base64url-encoded signature (no padding)
64
+ */
65
+ export async function signMessage(message, privateKey) {
66
+ if (message === null || typeof message !== "object" || Array.isArray(message)) {
67
+ throw new Error("message must be a non-null object");
68
+ }
69
+ if (!(privateKey instanceof Uint8Array) || privateKey.length !== 32) {
70
+ throw new Error("privateKey must be a 32-byte Uint8Array");
71
+ }
72
+ const { signature: _, ...unsigned } = message;
73
+ // Refuse oversized inputs at sign time so the sign side cannot mint
74
+ // signatures over payloads larger than any conformant verifier will
75
+ // accept. Mirrors the matching guard in verifyMessage().
76
+ if (!isWithinBounds(unsigned)) {
77
+ throw new Error("Message exceeds maximum allowed complexity");
78
+ }
79
+ const canonical = canonicalize(unsigned);
80
+ if (canonical === undefined) {
81
+ throw new Error("Failed to canonicalize message");
82
+ }
83
+ if (canonical.length > MAX_MESSAGE_CANONICAL_BYTES) {
84
+ throw new Error("Canonicalized message exceeds maximum allowed size");
85
+ }
86
+ // Domain-separated signing to prevent cross-protocol signature replay
87
+ const prefixed = `tulpa/sign\n${canonical}`;
88
+ const bytes = new TextEncoder().encode(prefixed);
89
+ const sig = await ed.signAsync(bytes, privateKey);
90
+ return base64urlEncode(sig);
91
+ }
92
+ /**
93
+ * Verify a message signature.
94
+ *
95
+ * 1. Extract and remove `signature`
96
+ * 2. JCS canonicalize the rest
97
+ * 3. Verify Ed25519 signature against canonical bytes
98
+ */
99
+ export async function verifyMessage(message, publicKey) {
100
+ if (message === null || typeof message !== "object" || Array.isArray(message))
101
+ return false;
102
+ if (!(publicKey instanceof Uint8Array))
103
+ return false;
104
+ const { signature, ...unsigned } = message;
105
+ if (typeof signature !== "string") {
106
+ return false;
107
+ }
108
+ // Ed25519 signatures are 64 bytes = 86 base64url chars (no padding).
109
+ // Strict format check before decoding rejects non-canonical encodings outright.
110
+ if (!/^[A-Za-z0-9_-]{86}$/.test(signature)) {
111
+ return false;
112
+ }
113
+ // Pre-canonicalize complexity cap: bail before `canonicalize()` walks
114
+ // an attacker-supplied object that would only be rejected later by
115
+ // signature verification. Mirrors the guard in verifyInkSignature so
116
+ // a peer can't pick whichever entrypoint is softer.
117
+ if (!isWithinBounds(unsigned)) {
118
+ return false;
119
+ }
120
+ const canonical = canonicalize(unsigned);
121
+ if (canonical === undefined) {
122
+ return false;
123
+ }
124
+ if (canonical.length > MAX_MESSAGE_CANONICAL_BYTES) {
125
+ return false;
126
+ }
127
+ // Domain-prefixed verification only — legacy unprefixed signatures are no longer accepted.
128
+ // signMessage() has always used `tulpa/sign\n` prefix; no callers produce unprefixed signatures.
129
+ const prefixed = `tulpa/sign\n${canonical}`;
130
+ const prefixedBytes = new TextEncoder().encode(prefixed);
131
+ try {
132
+ const sig = base64urlDecode(signature);
133
+ return await ed.verifyAsync(sig, prefixedBytes, publicKey);
134
+ }
135
+ catch {
136
+ // Malformed signature (invalid base64url, wrong byte length, bad key) — treat as invalid
137
+ return false;
138
+ }
139
+ }
140
+ /** Encode Uint8Array as base64url (no padding). */
141
+ function base64urlEncode(bytes) {
142
+ const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
143
+ const base64 = btoa(binString);
144
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
145
+ }
146
+ /** Decode base64url string to Uint8Array. */
147
+ function base64urlDecode(str) {
148
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
149
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
150
+ const binString = atob(padded);
151
+ return Uint8Array.from(binString, (c) => c.charCodeAt(0));
152
+ }
@@ -0,0 +1 @@
1
+ export { verifyMessage } from "./sign.js";
@@ -0,0 +1,83 @@
1
+ import type { AgentCard } from "../models/agent-card.js";
2
+ import type { CandidateKey } from "../models/key-entry.js";
3
+ /** Reject hostnames that resolve (statically) to loopback, private, or
4
+ * link-local addresses. This is an SSRF defense for integrators that may
5
+ * pass user-controlled baseUrl values. Returns true if the hostname is
6
+ * a literal IP in a reserved range, a loopback name, or an IPv6 unique
7
+ * local / link-local address.
8
+ *
9
+ * Note: this does NOT defend against DNS rebinding — a public hostname
10
+ * that resolves to 127.0.0.1 at fetch time will still hit loopback.
11
+ * That defense lives at the runtime / platform layer. */
12
+ export declare function isPrivateHostname(hostname: string): boolean;
13
+ export interface FetchAgentCardOptions {
14
+ /** Allow baseUrls whose hostname is a literal loopback / private /
15
+ * link-local / IANA special-use address. Off by default — flip on for
16
+ * unit tests or for an INK integrator running against an intentional
17
+ * intranet endpoint. */
18
+ allowPrivateHosts?: boolean;
19
+ /** Override the fetch implementation used to retrieve the card. This is
20
+ * the integrator's hook for connect-time SSRF defense (DNS rebinding):
21
+ * a public hostname can resolve to a private IP at fetch time and
22
+ * bypass the literal-hostname allowlist below. Wrap your platform's
23
+ * fetch with one that resolves + pins the IP and rejects private
24
+ * connect targets (e.g. undici with a custom dispatcher on Node, or
25
+ * `cf: { resolveOverride: validatedIp }` on Cloudflare Workers). */
26
+ fetch?: typeof fetch;
27
+ /** Strict mode: require that the caller supply `options.fetch`. When
28
+ * true, the default global `fetch` is refused for non-literal hostnames
29
+ * because the default cannot perform connect-time IP filtering and is
30
+ * therefore vulnerable to DNS rebinding. Off by default for backwards
31
+ * compatibility; on by default for any production integration where
32
+ * `baseUrl` is taken from untrusted input. Returns null without
33
+ * fetching when the condition fails. */
34
+ requireSafeFetch?: boolean;
35
+ }
36
+ /**
37
+ * Fetch an Agent Card from a remote INK endpoint.
38
+ * Convention: GET /ink/v1/:agentId/agent.json
39
+ *
40
+ * SECURITY: this function applies several SSRF defenses by default —
41
+ * https-only baseUrl, no userinfo, literal-hostname allowlist excluding
42
+ * loopback / private / link-local / IANA special-use blocks (both v4 and
43
+ * v4-mapped v6), no redirect following, body-size cap, identity binding.
44
+ *
45
+ * It does NOT defend against DNS rebinding: a public hostname that
46
+ * resolves to a private IP at fetch time will still be reached. The
47
+ * runtime-agnostic library cannot solve this on its own — pass
48
+ * `options.fetch` with a connect-time IP-filtering implementation
49
+ * (undici dispatcher on Node, `cf.resolveOverride` on Cloudflare
50
+ * Workers, an egress proxy, etc.) when the baseUrl is not fully trusted.
51
+ */
52
+ export declare function fetchAgentCard(agentId: string, baseUrl: string, options?: FetchAgentCardOptions): Promise<AgentCard | null>;
53
+ /**
54
+ * Extract candidate signing keys from an Agent Card.
55
+ *
56
+ * Authority rule: presence of `keys.signing` (even when empty) is
57
+ * authoritative. Callers MUST treat the returned set as the complete list
58
+ * of acceptable signers — including the empty set, which means "key set
59
+ * published, no usable keys" and forbids any legacy bootstrap fallback.
60
+ *
61
+ * - `keys.signing` absent → fall back to legacy `publicKeyMultibase`
62
+ * - `keys.signing: []` → return [] (authoritative empty)
63
+ * - `keys.signing: [k..]` → parse each entry independently; malformed
64
+ * entries are skipped so a single bad entry
65
+ * cannot collapse the whole set to "legacy"
66
+ * and let a rotated-away bootstrap key pass.
67
+ */
68
+ export declare function extractCandidateKeys(card: AgentCard): CandidateKey[];
69
+ /**
70
+ * Resolve a well-known discovery base URL for an agent handle.
71
+ *
72
+ * INK does not mandate a single discovery origin — handle → base URL
73
+ * resolution is integrator-specific. Implementations typically use one of:
74
+ *
75
+ * - DNS TXT record at `_ink.<handle>` (planned)
76
+ * - HTTPS .well-known lookup at `https://<handle>/.well-known/ink/agent.json`
77
+ * - A platform-specific registry maintained by a host service
78
+ *
79
+ * Pass a `resolveBase` callback at integration time. Returning null defers
80
+ * to the caller's fallback (e.g. an explicit endpoint in the Agent Card
81
+ * itself).
82
+ */
83
+ export declare function resolveBaseUrl(handle: string, resolveBase?: (handle: string) => string | null): string | null;