@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
@@ -1,210 +0,0 @@
1
- import * as ed from "@noble/ed25519";
2
- import { x25519 } from "@noble/curves/ed25519.js";
3
-
4
- const BASE58_ALPHABET =
5
- "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
6
-
7
- /** Ed25519 multicodec prefix: 0xed01 */
8
- const ED25519_MULTICODEC = new Uint8Array([0xed, 0x01]);
9
-
10
- /** X25519 multicodec prefix: 0xec01 */
11
- const X25519_MULTICODEC = new Uint8Array([0xec, 0x01]);
12
-
13
- export interface Keypair {
14
- privateKey: Uint8Array; // 32 bytes
15
- publicKey: Uint8Array; // 32 bytes
16
- }
17
-
18
- /** Generate a new Ed25519 keypair (signing). */
19
- export async function generateKeypair(): Promise<Keypair> {
20
- const { secretKey, publicKey } = await ed.keygenAsync();
21
- return { privateKey: secretKey, publicKey };
22
- }
23
-
24
- /** Generate a new X25519 keypair (encryption). */
25
- export function generateEncryptionKeypair(): Keypair {
26
- const privateKey = crypto.getRandomValues(new Uint8Array(32));
27
- const publicKey = x25519.getPublicKey(privateKey);
28
- return { privateKey, publicKey };
29
- }
30
-
31
- /** Encode bytes as base58btc (no multibase prefix). */
32
- export function encodeBase58(bytes: Uint8Array): string {
33
- if (bytes.length === 0) return "";
34
-
35
- // Count leading zeros
36
- let zeros = 0;
37
- for (const b of bytes) {
38
- if (b !== 0) break;
39
- zeros++;
40
- }
41
-
42
- // Convert to bigint
43
- let num = 0n;
44
- for (const b of bytes) {
45
- num = num * 256n + BigInt(b);
46
- }
47
-
48
- let result = "";
49
- while (num > 0n) {
50
- const remainder = Number(num % 58n);
51
- num = num / 58n;
52
- result = BASE58_ALPHABET[remainder]! + result;
53
- }
54
-
55
- // Add leading '1's for zero bytes
56
- return "1".repeat(zeros) + result;
57
- }
58
-
59
- /** Cap base58 input length BEFORE the BigInt accumulation loop. A poisoned
60
- * Agent Card with a multi-KB `publicKeyMultibase` would otherwise force
61
- * O(n^2) BigInt arithmetic — large `num * 58n + BigInt(idx)` per char — for
62
- * every input byte before any trailing length check fires.
63
- *
64
- * 1024 is well above any legitimate multibase-encoded public key (Ed25519
65
- * is ~50 chars, even multi-codec wrappers are well under 100).
66
- */
67
- const MAX_BASE58_INPUT_LEN = 1024;
68
-
69
- /** Decode base58btc string to bytes. */
70
- export function decodeBase58(str: string): Uint8Array {
71
- if (str.length === 0) return new Uint8Array(0);
72
- if (str.length > MAX_BASE58_INPUT_LEN) {
73
- throw new Error(`base58 input exceeds maximum length of ${MAX_BASE58_INPUT_LEN}`);
74
- }
75
-
76
- let num = 0n;
77
- for (const ch of str) {
78
- const idx = BASE58_ALPHABET.indexOf(ch);
79
- if (idx === -1) throw new Error(`Invalid base58 character: ${ch}`);
80
- num = num * 58n + BigInt(idx);
81
- }
82
-
83
- // Convert bigint to bytes
84
- const hex = num.toString(16).padStart(2, "0");
85
- const padded = hex.length % 2 ? "0" + hex : hex;
86
- const bytes: number[] = [];
87
- for (let i = 0; i < padded.length; i += 2) {
88
- bytes.push(parseInt(padded.slice(i, i + 2), 16));
89
- }
90
-
91
- // Add leading zero bytes
92
- let zeros = 0;
93
- for (const ch of str) {
94
- if (ch !== "1") break;
95
- zeros++;
96
- }
97
-
98
- return new Uint8Array([...new Uint8Array(zeros), ...bytes]);
99
- }
100
-
101
- /**
102
- * Encode a public key as a multibase base58btc string.
103
- * Format: 'z' prefix + base58btc(multicodec_prefix + public_key)
104
- */
105
- export function encodePublicKeyMultibase(publicKey: Uint8Array): string {
106
- if (!(publicKey instanceof Uint8Array)) {
107
- throw new Error("publicKey must be a Uint8Array");
108
- }
109
- if (publicKey.length !== 32) {
110
- throw new Error(`publicKey must be 32 bytes, got ${publicKey.length}`);
111
- }
112
- const prefixed = new Uint8Array(
113
- ED25519_MULTICODEC.length + publicKey.length,
114
- );
115
- prefixed.set(ED25519_MULTICODEC);
116
- prefixed.set(publicKey, ED25519_MULTICODEC.length);
117
- return "z" + encodeBase58(prefixed);
118
- }
119
-
120
- /**
121
- * Encode an X25519 public key as a multibase base58btc string.
122
- * Format: 'z' prefix + base58btc(x25519_multicodec_prefix + public_key)
123
- */
124
- export function encodeEncryptionKeyMultibase(publicKey: Uint8Array): string {
125
- if (!(publicKey instanceof Uint8Array)) {
126
- throw new Error("publicKey must be a Uint8Array");
127
- }
128
- if (publicKey.length !== 32) {
129
- throw new Error(`publicKey must be 32 bytes, got ${publicKey.length}`);
130
- }
131
- const prefixed = new Uint8Array(
132
- X25519_MULTICODEC.length + publicKey.length,
133
- );
134
- prefixed.set(X25519_MULTICODEC);
135
- prefixed.set(publicKey, X25519_MULTICODEC.length);
136
- return "z" + encodeBase58(prefixed);
137
- }
138
-
139
- /**
140
- * Decode a multibase base58btc public key string.
141
- * Returns the raw 32-byte public key.
142
- */
143
- export function decodePublicKeyMultibase(multibase: string): Uint8Array {
144
- if (typeof multibase !== "string" || multibase.length === 0 || multibase.length > 1024) {
145
- throw new Error("multibase must be a non-empty string under 1024 chars");
146
- }
147
- if (!multibase.startsWith("z")) {
148
- throw new Error("Expected multibase base58btc prefix 'z'");
149
- }
150
- const decoded = decodeBase58(multibase.slice(1));
151
- if (
152
- decoded[0] !== ED25519_MULTICODEC[0] ||
153
- decoded[1] !== ED25519_MULTICODEC[1]
154
- ) {
155
- throw new Error("Invalid Ed25519 multicodec prefix");
156
- }
157
- const key = decoded.slice(2);
158
- if (key.length !== 32) {
159
- throw new Error(`Invalid Ed25519 public key length: expected 32, got ${key.length}`);
160
- }
161
- return key;
162
- }
163
-
164
- /**
165
- * Decode a multibase base58btc X25519 public key string.
166
- * Returns the raw 32-byte public key.
167
- */
168
- export function decodeEncryptionKeyMultibase(multibase: string): Uint8Array {
169
- if (typeof multibase !== "string" || multibase.length === 0 || multibase.length > 1024) {
170
- throw new Error("multibase must be a non-empty string under 1024 chars");
171
- }
172
- if (!multibase.startsWith("z")) {
173
- throw new Error("Expected multibase base58btc prefix 'z'");
174
- }
175
- const decoded = decodeBase58(multibase.slice(1));
176
- if (
177
- decoded[0] !== X25519_MULTICODEC[0] ||
178
- decoded[1] !== X25519_MULTICODEC[1]
179
- ) {
180
- throw new Error("Invalid X25519 multicodec prefix");
181
- }
182
- const key = decoded.slice(2);
183
- if (key.length !== 32) {
184
- throw new Error(`Invalid X25519 public key length: expected 32, got ${key.length}`);
185
- }
186
- return key;
187
- }
188
-
189
- /**
190
- * Derive agent ID from a public key.
191
- * Format: tulpa:<multibase-encoded-public-key>
192
- */
193
- export function deriveAgentId(publicKey: Uint8Array): string {
194
- return `tulpa:${encodePublicKeyMultibase(publicKey)}`;
195
- }
196
-
197
- /**
198
- * Extract the public key from an agent ID.
199
- * Only used for initial key exchange — after that, always resolve via identity store.
200
- */
201
- export function extractPublicKeyFromAgentId(agentId: string): Uint8Array {
202
- if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
203
- throw new Error("Invalid agent ID");
204
- }
205
- const prefix = "tulpa:";
206
- if (!agentId.startsWith(prefix)) {
207
- throw new Error("Invalid agent ID format");
208
- }
209
- return decodePublicKeyMultibase(agentId.slice(prefix.length));
210
- }
@@ -1,170 +0,0 @@
1
- import { verifyInkSignature, type InkSignInput } from "./ink.js";
2
- import type { CandidateKey, KeyStatus } from "../models/key-entry.js";
3
-
4
- export interface MultiKeyVerifyResult {
5
- verified: boolean;
6
- keyId?: string;
7
- /** Status of the key that verified the signature (for observability). */
8
- keyStatus?: KeyStatus;
9
- /** True when the signature was verified using a retired key. Callers should track this for key rotation observability. */
10
- usedRetiredKey?: boolean;
11
- }
12
-
13
- /** Maximum number of candidate keys tried during multi-key verification.
14
- * Prevents a poisoned Agent Card from forcing O(n) Ed25519 verifications. */
15
- const MAX_CANDIDATE_KEYS = 20;
16
-
17
- /**
18
- * Check whether a candidate key's validity window contains a given
19
- * message timestamp. Returns true when the key is usable. Both window
20
- * endpoints are optional; missing endpoints are treated as open (so a
21
- * key with no validFrom is usable arbitrarily far back, and a key with
22
- * no validUntil is usable arbitrarily far forward — preserving the
23
- * legacy behaviour for callers that don't track windows).
24
- *
25
- * Defense in depth at the verifier:
26
- * - status === "revoked" is already filtered upstream; this function
27
- * ALSO refuses any key whose `revokedAt` field is present, in case
28
- * a caller forgot to set status.
29
- * - Non-string OR empty-string window fields are treated as malformed
30
- * and fail closed. An integrator that maps a NULL/blank database
31
- * column to "" must not get the same behaviour as "field absent" —
32
- * that would let an expired key slip through under the legacy
33
- * "no window = open" rule. The matching boundary check lives in
34
- * extractCandidateKeys; this guard catches custom resolveKeySet
35
- * implementations that bypass that boundary.
36
- * - Malformed timestamp strings (Date.parse returns NaN) also fail
37
- * closed for the same reason.
38
- */
39
- function isKeyValidAtTime(key: CandidateKey, messageMs: number): boolean {
40
- // Any field that is PRESENT but not a non-empty parseable datetime
41
- // string is treated as malformed and fails closed. "Present" means
42
- // !== undefined, so a `null`, number, object, or empty string here
43
- // is a misuse — refusing it stops a custom resolveKeySet that maps a
44
- // DB NULL to "" (or to literal null) from looking like "no window".
45
- const isPresent = (x: unknown): boolean => x !== undefined;
46
- // Cap length BEFORE Date.parse — a multi-megabyte string would
47
- // otherwise burn CPU in the date parser before the parse failure.
48
- // 64 chars matches the cap used everywhere else in INK (ISO 8601
49
- // with subsecond + timezone fits in ~30; 64 leaves headroom).
50
- const isValidDatetimeString = (x: unknown): x is string =>
51
- typeof x === "string" && x.length > 0 && x.length <= 64 && Number.isFinite(Date.parse(x));
52
-
53
- if (isPresent(key.revokedAt)) {
54
- // revokedAt present at all is a "do not verify" signal regardless
55
- // of whether the value parses. A revoked key with an unparseable
56
- // revokedAt is still revoked.
57
- return false;
58
- }
59
- if (isPresent(key.validFrom)) {
60
- if (!isValidDatetimeString(key.validFrom)) return false;
61
- if (messageMs < Date.parse(key.validFrom)) return false;
62
- }
63
- if (isPresent(key.validUntil)) {
64
- if (!isValidDatetimeString(key.validUntil)) return false;
65
- if (messageMs > Date.parse(key.validUntil)) return false;
66
- }
67
- return true;
68
- }
69
-
70
- /**
71
- * Verify an INK signature against a set of candidate keys.
72
- *
73
- * Verification order per spec §6.4:
74
- * 1. Hinted key (if provided and found) — optimization for keyId header
75
- * 2. Active keys first
76
- * 3. Retired keys second
77
- * 4. Revoked keys are always skipped
78
- *
79
- * In all three cases the key's `[validFrom, validUntil]` window MUST
80
- * contain the message timestamp. A key that has expired (validUntil in
81
- * the past) or is not yet valid (validFrom in the future) is skipped
82
- * even if its status would otherwise admit it. This closes the window
83
- * where an attacker who steals an expired key — even one still listed
84
- * as "retired" for historical verification — could sign fresh messages.
85
- *
86
- * Returns the matching keyId and keyStatus on success.
87
- */
88
- export async function verifyInkSignatureWithKeys(
89
- input: InkSignInput,
90
- signature: string,
91
- keys: CandidateKey[],
92
- hintKeyId?: string,
93
- ): Promise<MultiKeyVerifyResult> {
94
- if (input === null || typeof input !== "object" || Array.isArray(input)) {
95
- return { verified: false };
96
- }
97
- if (!Array.isArray(keys) || keys.length === 0) {
98
- return { verified: false };
99
- }
100
- if (typeof signature !== "string") {
101
- return { verified: false };
102
- }
103
-
104
- // Parse the message timestamp once so window checks are O(1) per key.
105
- // verifyInkAuth caps timestamp length upstream, but this helper is
106
- // exported, so guard locally too: a non-string, empty, oversized, or
107
- // non-parseable timestamp all fail closed. The 64-char cap stops a
108
- // multi-megabyte string from reaching Date.parse.
109
- if (typeof input.timestamp !== "string" || input.timestamp.length === 0 || input.timestamp.length > 64) {
110
- return { verified: false };
111
- }
112
- const messageMs = Date.parse(input.timestamp);
113
- if (!Number.isFinite(messageMs)) {
114
- return { verified: false };
115
- }
116
-
117
- // Enforce an upper bound on key set size to prevent DoS via poisoned Agent Cards
118
- // that contain hundreds of keys, forcing that many Ed25519 operations per request.
119
- const bounded = keys.slice(0, MAX_CANDIDATE_KEYS);
120
-
121
- // Try hinted key first if provided.
122
- // Allowlist of acceptable statuses. A deny-list (k.status !== "revoked") would
123
- // accept entries with malformed/unrecognised status — e.g. case-mismatched
124
- // "Revoked" or empty string would slip past here while being skipped by the
125
- // active/retired partition iteration below.
126
- if (hintKeyId) {
127
- const hinted = bounded.find(
128
- (k) => k.keyId === hintKeyId && (k.status === "active" || k.status === "retired"),
129
- );
130
- if (hinted && isKeyValidAtTime(hinted, messageMs)) {
131
- try {
132
- const valid = await verifyInkSignature(input, signature, hinted.publicKey);
133
- if (valid) return { verified: true, keyId: hinted.keyId, keyStatus: hinted.status, usedRetiredKey: hinted.status === "retired" };
134
- } catch {
135
- // Fall through to normal iteration
136
- }
137
- }
138
- }
139
-
140
- // Partition by status: active first, then retired. Skip revoked.
141
- // Drop any candidate whose validity window doesn't contain the
142
- // message timestamp before reaching the verify loop.
143
- const active = bounded.filter((k) => k.status === "active" && isKeyValidAtTime(k, messageMs));
144
- const retired = bounded.filter((k) => k.status === "retired" && isKeyValidAtTime(k, messageMs));
145
-
146
- // Try active keys first
147
- for (const key of active) {
148
- // Skip if already tried as hint
149
- if (hintKeyId && key.keyId === hintKeyId) continue;
150
- try {
151
- const valid = await verifyInkSignature(input, signature, key.publicKey);
152
- if (valid) return { verified: true, keyId: key.keyId, keyStatus: key.status, usedRetiredKey: false };
153
- } catch {
154
- // Key failed verification, try next
155
- }
156
- }
157
-
158
- // Try retired keys
159
- for (const key of retired) {
160
- if (hintKeyId && key.keyId === hintKeyId) continue;
161
- try {
162
- const valid = await verifyInkSignature(input, signature, key.publicKey);
163
- if (valid) return { verified: true, keyId: key.keyId, keyStatus: key.status, usedRetiredKey: true };
164
- } catch {
165
- // Key failed verification, try next
166
- }
167
- }
168
-
169
- return { verified: false };
170
- }
@@ -1,155 +0,0 @@
1
- import * as ed from "@noble/ed25519";
2
- import canonicalize from "canonicalize";
3
-
4
- /** Same bounds used by the ink.ts verify paths. Kept in sync so a peer
5
- * cannot pick the "softer" sign.ts path to bypass the cap. */
6
- const MAX_MESSAGE_NODES = 10_000;
7
- const MAX_MESSAGE_DEPTH = 32;
8
- const MAX_MESSAGE_CHARS = 1_200_000;
9
- /** Upper limit on the canonicalized message length, matching
10
- * MAX_SIGBASE_BODY_BYTES in ink.ts. Defense in depth alongside the node
11
- * walk: a message can be small in node count but still expand to huge
12
- * canonical bytes via long string values. */
13
- const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
14
-
15
- /**
16
- * Cheap depth/node/byte walk over a value before it is handed to
17
- * `canonicalize`. Bails before the recursive sort+serialize runs, so an
18
- * attacker who supplies a syntactically valid-shape signature with a
19
- * pathological message body cannot burn CPU/memory inside the verify
20
- * path. Mirrors src/crypto/ink.ts:isWithinCanonicalizeBounds, including
21
- * the byte counter that stops a single huge string from sneaking past
22
- * the node check.
23
- */
24
- function isWithinBounds(value: unknown): boolean {
25
- let nodes = 0;
26
- let chars = 0;
27
- function walk(v: unknown, depth: number): boolean {
28
- if (depth > MAX_MESSAGE_DEPTH) return false;
29
- if (++nodes > MAX_MESSAGE_NODES) return false;
30
- if (v === null || typeof v !== "object") {
31
- if (typeof v === "string") {
32
- chars += v.length;
33
- if (chars > MAX_MESSAGE_CHARS) return false;
34
- }
35
- return true;
36
- }
37
- if (Array.isArray(v)) {
38
- for (const item of v) if (!walk(item, depth + 1)) return false;
39
- return true;
40
- }
41
- for (const key of Object.keys(v as Record<string, unknown>)) {
42
- if (++nodes > MAX_MESSAGE_NODES) return false;
43
- chars += key.length;
44
- if (chars > MAX_MESSAGE_CHARS) return false;
45
- if (!walk((v as Record<string, unknown>)[key], depth + 1)) return false;
46
- }
47
- return true;
48
- }
49
- return walk(value, 0);
50
- }
51
-
52
- /**
53
- * Sign a message object using Ed25519.
54
- *
55
- * 1. Remove `signature` field if present
56
- * 2. JCS canonicalize (RFC 8785) via `canonicalize` library
57
- * 3. Sign canonical bytes directly with Ed25519
58
- * 4. Return base64url-encoded signature (no padding)
59
- */
60
- export async function signMessage(
61
- message: Record<string, unknown>,
62
- privateKey: Uint8Array,
63
- ): Promise<string> {
64
- if (message === null || typeof message !== "object" || Array.isArray(message)) {
65
- throw new Error("message must be a non-null object");
66
- }
67
- if (!(privateKey instanceof Uint8Array) || privateKey.length !== 32) {
68
- throw new Error("privateKey must be a 32-byte Uint8Array");
69
- }
70
- const { signature: _, ...unsigned } = message;
71
- // Refuse oversized inputs at sign time so the sign side cannot mint
72
- // signatures over payloads larger than any conformant verifier will
73
- // accept. Mirrors the matching guard in verifyMessage().
74
- if (!isWithinBounds(unsigned)) {
75
- throw new Error("Message exceeds maximum allowed complexity");
76
- }
77
- const canonical = canonicalize(unsigned);
78
- if (canonical === undefined) {
79
- throw new Error("Failed to canonicalize message");
80
- }
81
- if (canonical.length > MAX_MESSAGE_CANONICAL_BYTES) {
82
- throw new Error("Canonicalized message exceeds maximum allowed size");
83
- }
84
- // Domain-separated signing to prevent cross-protocol signature replay
85
- const prefixed = `tulpa/sign\n${canonical}`;
86
- const bytes = new TextEncoder().encode(prefixed);
87
- const sig = await ed.signAsync(bytes, privateKey);
88
- return base64urlEncode(sig);
89
- }
90
-
91
- /**
92
- * Verify a message signature.
93
- *
94
- * 1. Extract and remove `signature`
95
- * 2. JCS canonicalize the rest
96
- * 3. Verify Ed25519 signature against canonical bytes
97
- */
98
- export async function verifyMessage(
99
- message: Record<string, unknown>,
100
- publicKey: Uint8Array,
101
- ): Promise<boolean> {
102
- if (message === null || typeof message !== "object" || Array.isArray(message)) return false;
103
- if (!(publicKey instanceof Uint8Array)) 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
-
114
- // Pre-canonicalize complexity cap: bail before `canonicalize()` walks
115
- // an attacker-supplied object that would only be rejected later by
116
- // signature verification. Mirrors the guard in verifyInkSignature so
117
- // a peer can't pick whichever entrypoint is softer.
118
- if (!isWithinBounds(unsigned)) {
119
- return false;
120
- }
121
- const canonical = canonicalize(unsigned);
122
- if (canonical === undefined) {
123
- return false;
124
- }
125
- if (canonical.length > MAX_MESSAGE_CANONICAL_BYTES) {
126
- return false;
127
- }
128
-
129
- // Domain-prefixed verification only — legacy unprefixed signatures are no longer accepted.
130
- // signMessage() has always used `tulpa/sign\n` prefix; no callers produce unprefixed signatures.
131
- const prefixed = `tulpa/sign\n${canonical}`;
132
- const prefixedBytes = new TextEncoder().encode(prefixed);
133
- try {
134
- const sig = base64urlDecode(signature);
135
- return await ed.verifyAsync(sig, prefixedBytes, publicKey);
136
- } catch {
137
- // Malformed signature (invalid base64url, wrong byte length, bad key) — treat as invalid
138
- return false;
139
- }
140
- }
141
-
142
- /** Encode Uint8Array as base64url (no padding). */
143
- function base64urlEncode(bytes: Uint8Array): string {
144
- const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
145
- const base64 = btoa(binString);
146
- return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
147
- }
148
-
149
- /** Decode base64url string to Uint8Array. */
150
- function base64urlDecode(str: string): Uint8Array {
151
- const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
152
- const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
153
- const binString = atob(padded);
154
- return Uint8Array.from(binString, (c) => c.charCodeAt(0));
155
- }