@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.
- package/CHANGELOG.md +42 -0
- package/README.md +15 -3
- package/dist/audit/inclusion-receipt.d.ts +142 -0
- package/dist/audit/inclusion-receipt.js +496 -0
- package/dist/crypto/ink.d.ts +178 -0
- package/dist/crypto/ink.js +915 -0
- package/dist/crypto/keys.d.ts +42 -0
- package/dist/crypto/keys.js +179 -0
- package/dist/crypto/multi-key-verify.d.ts +29 -0
- package/dist/crypto/multi-key-verify.js +153 -0
- package/dist/crypto/sign.d.ts +17 -0
- package/dist/crypto/sign.js +152 -0
- package/dist/crypto/verify.js +1 -0
- package/dist/discovery/agent-card.d.ts +83 -0
- package/dist/discovery/agent-card.js +545 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +16 -0
- package/dist/ink/checkpoint.d.ts +19 -0
- package/dist/ink/checkpoint.js +69 -0
- package/dist/ink/discovery-gating.d.ts +247 -0
- package/dist/ink/discovery-gating.js +94 -0
- package/dist/ink/handshake-budget.d.ts +90 -0
- package/dist/ink/handshake-budget.js +397 -0
- package/dist/ink/receipts.d.ts +31 -0
- package/dist/ink/receipts.js +89 -0
- package/dist/ink/transport-auth.d.ts +47 -0
- package/dist/ink/transport-auth.js +77 -0
- package/dist/middleware/ink-auth.d.ts +68 -0
- package/dist/middleware/ink-auth.js +214 -0
- package/dist/models/agent-card.d.ts +170 -0
- package/dist/models/agent-card.js +107 -0
- package/dist/models/ink-audit.d.ts +344 -0
- package/dist/models/ink-audit.js +167 -0
- package/dist/models/ink-handshake.d.ts +129 -0
- package/dist/models/ink-handshake.js +89 -0
- package/dist/models/intent.d.ts +437 -0
- package/dist/models/intent.js +172 -0
- package/dist/models/key-entry.d.ts +60 -0
- package/dist/models/key-entry.js +13 -0
- package/dist/models/profile.d.ts +61 -0
- package/dist/models/profile.js +24 -0
- package/package.json +15 -11
- package/specs/ink-auditability.md +2 -2
- package/specs/ink-containment-phase1-implementation-spec.md +1 -1
- package/src/audit/inclusion-receipt.ts +0 -604
- package/src/crypto/ink.ts +0 -1046
- package/src/crypto/keys.ts +0 -210
- package/src/crypto/multi-key-verify.ts +0 -170
- package/src/crypto/sign.ts +0 -155
- package/src/discovery/agent-card.ts +0 -508
- package/src/index.ts +0 -73
- package/src/ink/checkpoint.ts +0 -75
- package/src/ink/discovery-gating.ts +0 -147
- package/src/ink/handshake-budget.ts +0 -413
- package/src/ink/receipts.ts +0 -114
- package/src/ink/transport-auth.ts +0 -96
- package/src/middleware/ink-auth.ts +0 -263
- package/src/models/agent-card.ts +0 -63
- package/src/models/ink-audit.ts +0 -205
- package/src/models/ink-handshake.ts +0 -123
- package/src/models/intent.ts +0 -201
- package/src/models/key-entry.ts +0 -52
- package/src/models/profile.ts +0 -31
- /package/{src/crypto/verify.ts → dist/crypto/verify.d.ts} +0 -0
package/src/crypto/keys.ts
DELETED
|
@@ -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
|
-
}
|
package/src/crypto/sign.ts
DELETED
|
@@ -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
|
-
}
|