@adastracomputing/ink 0.2.0 → 0.4.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 +52 -0
- package/README.md +16 -5
- package/bin/verify-inclusion-impl.mjs +65 -22
- package/dist/audit/inclusion-receipt.d.ts +18 -8
- package/dist/audit/inclusion-receipt.js +29 -5
- package/dist/crypto/ink.js +12 -4
- package/dist/crypto/keys.d.ts +33 -1
- package/dist/crypto/keys.js +55 -3
- package/dist/crypto/sign.d.ts +21 -0
- package/dist/crypto/sign.js +26 -2
- package/dist/discovery/agent-card.d.ts +9 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +5 -5
- package/dist/ink/checkpoint.d.ts +21 -0
- package/dist/ink/checkpoint.js +79 -0
- package/dist/ink/discovery-gating.js +4 -4
- package/dist/ink/receipts.d.ts +33 -1
- package/dist/ink/receipts.js +45 -1
- package/dist/middleware/ink-auth.d.ts +1 -0
- package/dist/middleware/ink-auth.js +7 -4
- package/dist/models/agent-card.js +22 -22
- package/dist/models/ink-audit.js +40 -36
- package/dist/models/ink-handshake.js +13 -13
- package/dist/models/intent.d.ts +2 -2
- package/dist/models/intent.js +9 -0
- package/docs/maturity.md +17 -7
- package/package.json +10 -9
- package/specs/ink-agent-containment-and-governance-extension-spec.md +3 -2
- package/specs/ink-auditability.md +1 -1
- package/specs/ink-authorization-chain.md +1 -1
- package/specs/ink-compatibility-policy.md +15 -3
- package/specs/ink-compliance-checklist.md +3 -2
- package/specs/ink-containment-phase1-implementation-spec.md +3 -2
- package/specs/ink-introduction-receipts-extension.md +3 -5
- package/specs/ink-key-rotation-spec.md +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,58 @@ All notable changes to INK are recorded
|
|
|
4
4
|
here. Pre-1.0 releases follow `0.Y.Z` semantics, see
|
|
5
5
|
[`docs/maturity.md`](docs/maturity.md) for the versioning policy.
|
|
6
6
|
|
|
7
|
+
## 0.4.0, stricter verification, message-size bounds, checkpoint and receipt verification
|
|
8
|
+
|
|
9
|
+
This release tightens signature verification and input validation and adds
|
|
10
|
+
several verification helpers. It is published on the `next` dist-tag.
|
|
11
|
+
|
|
12
|
+
### Potentially breaking validation tightenings
|
|
13
|
+
|
|
14
|
+
These reject inputs that `0.3.0` accepted. Legitimate signer and receiver
|
|
15
|
+
traffic is unaffected; the rejected inputs are malformed, malicious, or outside
|
|
16
|
+
the documented profile.
|
|
17
|
+
|
|
18
|
+
- Ed25519 signatures are now verified in strict RFC 8032 mode at every
|
|
19
|
+
verification site. Small-order public keys and non-canonical point encodings
|
|
20
|
+
are rejected.
|
|
21
|
+
- Signed JSON numbers are constrained to the forms every canonicalizer
|
|
22
|
+
serializes identically: non-finite values, negative zero, and values whose
|
|
23
|
+
shortest form uses exponential notation are rejected at signing and
|
|
24
|
+
verification.
|
|
25
|
+
- The agent card, audit, handshake, and discovery schemas now enforce maximum
|
|
26
|
+
field lengths and array sizes.
|
|
27
|
+
- The `Authorization: INK-Ed25519` header is matched against single literal
|
|
28
|
+
spaces; a tab, carriage return, or line feed in the separator is rejected.
|
|
29
|
+
|
|
30
|
+
### Additions
|
|
31
|
+
|
|
32
|
+
- `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed
|
|
33
|
+
C2SP checkpoint: the witness Ed25519 signature over the checkpoint body and the
|
|
34
|
+
log origin. A checkpoint used for the inclusion-receipt cross-check must be
|
|
35
|
+
verified this way first.
|
|
36
|
+
- `verifyReceipt({ receipt, senderPublicKey, expected })` binds a delivery
|
|
37
|
+
receipt to the exact message it acknowledges: issuer key, `from`/`to`/
|
|
38
|
+
`messageId`, the recomputed message hash, and an optional `disposition`.
|
|
39
|
+
- `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf
|
|
40
|
+
hash and binds it to `receipt.eventId`. The legacy `eventHash` is retained but
|
|
41
|
+
does not provide that binding.
|
|
42
|
+
- `verifyInkAuth` returns a prefix-independent `principal` alongside the raw
|
|
43
|
+
sender id; per-sender security state (blocks, rate limits) should key on
|
|
44
|
+
`principal`. `canonicalAgentPrincipal(agentId)` is exported for the same use.
|
|
45
|
+
|
|
46
|
+
Per the pre-1.0 policy this release publishes under the `next` dist-tag; `latest`
|
|
47
|
+
is unchanged.
|
|
48
|
+
|
|
49
|
+
## 0.3.0, accept the ink: agentId alias for key extraction
|
|
50
|
+
|
|
51
|
+
`extractPublicKeyFromAgentId` now accepts either the canonical `tulpa:` prefix or the `ink:` alias introduced in ink/0.4. Both carry the identical multibase Ed25519 key, so the bootstrap verification key is byte-identical and a signature made with that key verifies regardless of which accepted prefix carried it. The prefix is identity syntax, not signing authority.
|
|
52
|
+
|
|
53
|
+
Emission is unchanged: `deriveAgentId` still returns `tulpa:` (accept both, emit one). The new `AGENT_ID_KEY_PREFIXES` export is frozen so a consumer cannot widen the accepted set at runtime. The change is additive and backward compatible. Existing `tulpa:` inputs behave exactly as before, and every previously rejected prefix other than `ink:` is still rejected. The wire protocol version is unchanged.
|
|
54
|
+
|
|
55
|
+
A receiver that keys per-sender security state (blocks, rate limits, duplicate-payload checks, cached verification keys, connection identity) MUST collapse the two spellings to one prefix-independent principal so a sender cannot switch prefix to dodge a block or split a rate-limit window. See [Identity](https://ink.tulpa.network/spec/identity/).
|
|
56
|
+
|
|
57
|
+
Per the pre-1.0 policy this release publishes under the `next` dist-tag.
|
|
58
|
+
|
|
7
59
|
## 0.2.0, version-keyed body-signature domain
|
|
8
60
|
|
|
9
61
|
Version-keyed body-signature domain. The body message signature is now domain-separated by protocol version. ink/0.1 messages, and any object with no explicit ink/0.2 protocol, keep the legacy `tulpa/sign` domain so every signature produced to date still verifies. ink/0.2 messages are signed and verified under the neutral `ink/sign` domain. The verifier selects exactly one domain from the signed `protocol` field and never tries an alternate, so a signature made under one version's domain cannot be replayed under another.
|
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
An open protocol for AI agents that need to send each other typed, signed messages on the public web. Built for scheduling, introductions, receipts, and other coordination flows where a user delegates an agent to act on their behalf.
|
|
6
6
|
|
|
7
|
-
**Status:
|
|
7
|
+
**Status: experimental; current defined wire version `ink/0.2`.** Wire formats, trust semantics, and APIs may change without backward-compatible migration before v1.0. On npm, `latest` is `0.1.2` and `0.2.0` is published on the `next` tag; senders still emit `ink/0.1` by default unless explicitly configured.
|
|
8
|
+
|
|
9
|
+
`ink/0.2` is the recommended target for new receiver implementations. It is a backward-compatible minor over `ink/0.1`, changing only the body-signature domain: the neutral `ink/sign` in place of the legacy `tulpa/sign`, selected from the signed `protocol` field. `ink/0.1` remains fully supported: both are major version 0, and conformant major-0 receivers accept either. There is no plan to drop `ink/0.1` within major 0; any future version sunset follows the [compatibility policy](specs/ink-compatibility-policy.md).
|
|
8
10
|
|
|
9
11
|
| | |
|
|
10
12
|
|---|---|
|
|
@@ -23,7 +25,7 @@ An open protocol for AI agents that need to send each other typed, signed messag
|
|
|
23
25
|
- [Agent-assisted implementation](#agent-assisted-implementation)
|
|
24
26
|
- [Tests](#tests)
|
|
25
27
|
- [Layout](#layout)
|
|
26
|
-
- [What's stable
|
|
28
|
+
- [What's stable](#whats-stable)
|
|
27
29
|
- [Naming](#naming)
|
|
28
30
|
- [Relationship to Tulpa](#relationship-to-tulpa)
|
|
29
31
|
- [Interoperability](#interoperability)
|
|
@@ -96,6 +98,13 @@ For consumers of bilateral audit-exchange responses (`network.tulpa.audit_respon
|
|
|
96
98
|
|
|
97
99
|
For consumers of witness audit-query responses (`network.tulpa.audit_query_response`, Auditability §7.3, added in `0.1.0-alpha.3`), call `verifyAuditQueryResponse({response, witnessPublicKey, expectedRequester, expectedMessageId, verifyEventSignature, expectedServiceDid?, laterCheckpoint?})`. The `verifyEventSignature` callback is REQUIRED: it resolves the submitting agent's Ed25519 keys (typically via Agent Card §2) and validates each event's `agentSignature`. Without it, the verifier refuses to return valid, because Merkle inclusion alone does not prove a real agent produced the event (§7.5). The verifier enforces envelope shape, the `requester` binding (prevents cross-requester replay), events/proofs strict one-to-one alignment, the §7.4 per-event scope rule, walks every Merkle proof via `computeAuditMerkleLeafHash` up to the response's `rootHash`, runs `verifyEventSignature` on every event and supports an optional later-checkpoint cross-check. The lower-level `verifyAuditQueryResponseSignature` is signature-only and is not sufficient to accept a witness response on its own.
|
|
98
100
|
|
|
101
|
+
Verification helpers added in `0.4.0`:
|
|
102
|
+
|
|
103
|
+
- `verifyCheckpoint(signed, witnessPublicKey, expectedOrigin)` verifies a signed C2SP checkpoint's witness Ed25519 signature and binds its log origin, returning the parsed `{origin, treeSize, rootHash}` or `null`. Any checkpoint passed to `verifyInclusionReceipt`'s `laterCheckpoint` cross-check must be verified this way first; an unverified checkpoint body is attacker-controllable and provides no anti-rollback value.
|
|
104
|
+
- `verifyReceipt({receipt, senderPublicKey, expected})` verifies a delivery receipt against the message it acknowledges: the issuer's signature plus `from`/`to`/`messageId`, the recomputed message hash, and an optional `disposition`. It returns `{valid, reason?}`.
|
|
105
|
+
- `verifyInclusionReceipt` accepts an `event` option that recomputes the leaf hash and binds `event.id` to `receipt.eventId`, so the proof attests the named event's inclusion. Prefer it over the legacy unbound `eventHash`.
|
|
106
|
+
- `verifyInkAuth` returns a prefix-independent `principal` alongside the raw `senderAgentId`. Per-sender security state (block lists, rate limits) MUST key on `principal`, because the `tulpa:` and `ink:` spellings of one key are the same actor; `canonicalAgentPrincipal(agentId)` exposes the same mapping.
|
|
107
|
+
|
|
99
108
|
## Agent-assisted implementation
|
|
100
109
|
|
|
101
110
|
If you are asking an AI coding agent to add INK support to an existing service, the canonical packet for that workflow is the [Agent-assisted implementation](https://ink.tulpa.network/guides/agent-assisted-implementation/) guide. It contains the curated implementer prompt, a mandatory traceability matrix, the conformance checklist, and a human-review checklist. The guide is updated as the protocol evolves; treat it as the live source rather than copying its contents into your repo.
|
|
@@ -130,9 +139,9 @@ test/ vitest unit + integration tests
|
|
|
130
139
|
|
|
131
140
|
The library runs on any runtime providing standard Web Crypto and `fetch`: Node 24+, Deno, Bun, Cloudflare Workers, browsers. The timestamp freshness window is enforced inside `verifyInkAuth`; nonce single-use is enforced when a `NonceStore` is passed (otherwise `checkReplay` must be called separately). Nonce backing storage and its TTL policy are the integrator's choice.
|
|
132
141
|
|
|
133
|
-
## What's stable
|
|
142
|
+
## What's stable
|
|
134
143
|
|
|
135
|
-
Reliable to depend on:
|
|
144
|
+
These hold across major version 0 (both `ink/0.1` and `ink/0.2`). Reliable to depend on:
|
|
136
145
|
|
|
137
146
|
- Envelope structure and signing base
|
|
138
147
|
- Authorization: signed intent plus Agent Card key set
|
|
@@ -150,9 +159,11 @@ Subject to change before v1.0:
|
|
|
150
159
|
|
|
151
160
|
You will see `network.tulpa.*` on the wire (e.g. `network.tulpa.intent`) and `ink.tulpa.network` for the docs site. Both are historical artifacts of the protocol's origin and do not imply a runtime dependency on Tulpa. A vendor-neutral namespace may be introduced in a future revision.
|
|
152
161
|
|
|
162
|
+
As a first, non-breaking step in that direction, agentIds may use either the canonical `tulpa:` method prefix or the `ink:` alias; both encode the same Ed25519 key and denote the same actor. `deriveAgentId` still emits `tulpa:`, and `extractPublicKeyFromAgentId` accepts both (accept-both, emit-one). A receiver MUST collapse the two spellings to one prefix-independent principal for all per-sender security state (blocks, rate limits, duplicate-payload checks, cached keys, connection identity).
|
|
163
|
+
|
|
153
164
|
## Relationship to Tulpa
|
|
154
165
|
|
|
155
|
-
INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the library in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate
|
|
166
|
+
INK is developed by [Ad Astra Computing](https://adastracomputing.com) as the underlying protocol for [Tulpa](https://tulpa.network). The spec and the library in this repo are deliberately free of Tulpa product code so other agent platforms can adopt INK without inheriting Tulpa's surface area. Tulpa's product integration (message orchestration, marketplace, user-facing APIs) lives in a separate codebase.
|
|
156
167
|
|
|
157
168
|
## Interoperability
|
|
158
169
|
|
|
@@ -38,6 +38,7 @@ function parseArgs(argv) {
|
|
|
38
38
|
const a = argv[i];
|
|
39
39
|
if (a === "--file" || a === "-f") out.file = argv[++i];
|
|
40
40
|
else if (a === "--witness" || a === "-w") out.witness = argv[++i];
|
|
41
|
+
else if (a === "--origin") out.origin = argv[++i];
|
|
41
42
|
else if (a === "--event-hash" || a === "-e") out.eventHash = argv[++i];
|
|
42
43
|
else if (a === "--allow-http") out.allowHttp = true;
|
|
43
44
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
@@ -75,6 +76,10 @@ Usage:
|
|
|
75
76
|
|
|
76
77
|
Options:
|
|
77
78
|
-w, --witness <url> Witness base URL (e.g. https://witness.tulpa.network)
|
|
79
|
+
--origin <name> Optional. Expected checkpoint origin (log identity)
|
|
80
|
+
to bind the signed checkpoint to. When omitted, the
|
|
81
|
+
checkpoint signature is still verified against the
|
|
82
|
+
witness key and the body/signature origins must agree.
|
|
78
83
|
-f, --file <path> Receipt JSON file. Omit to read from stdin.
|
|
79
84
|
-e, --event-hash <hex> Optional. RFC 6962 leaf hash for the audit event:
|
|
80
85
|
SHA-256(0x00 || JCS(event-without-agentSignature)),
|
|
@@ -226,7 +231,8 @@ async function verifyReceipt(receipt, witnessPublicKey, eventHash, laterCheckpoi
|
|
|
226
231
|
let sigValid = false;
|
|
227
232
|
try {
|
|
228
233
|
const sig = base64urlDecode(receipt.serviceSignature);
|
|
229
|
-
|
|
234
|
+
// RFC 8032 strict verification, matching the library (reject small-order keys).
|
|
235
|
+
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
|
|
230
236
|
} catch (e) {
|
|
231
237
|
steps.push({ name: "signature", pass: false, detail: e instanceof Error ? e.message : "signature decode failed" });
|
|
232
238
|
return { valid: false, steps };
|
|
@@ -343,28 +349,59 @@ async function fetchWitnessPublicKey(witnessUrl) {
|
|
|
343
349
|
}
|
|
344
350
|
|
|
345
351
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
+
* Verify a signed C2SP tlog-checkpoint and return { treeSize, rootHash,
|
|
353
|
+
* origin }, or null if the signature, origin, or format is invalid. The
|
|
354
|
+
* Ed25519 signature covers the body bytes `<origin>\n<treeSize>\n<rootHash>`
|
|
355
|
+
* (no trailing newline). The anti-rollback cross-check below only means
|
|
356
|
+
* anything against a checkpoint whose signature we have verified against the
|
|
357
|
+
* witness key, so this MUST verify, not just parse.
|
|
358
|
+
*
|
|
359
|
+
* Mirrors verifyCheckpoint() in src/ink/checkpoint.ts — keep them in sync.
|
|
352
360
|
*/
|
|
353
|
-
function
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
361
|
+
async function verifyCheckpointBody(signed, witnessPublicKey, expectedOrigin) {
|
|
362
|
+
if (typeof signed !== "string" || signed.length === 0 || signed.length > 4096) return null;
|
|
363
|
+
const SEP = "\n\n-- ";
|
|
364
|
+
const idx = signed.indexOf(SEP);
|
|
365
|
+
if (idx < 0) return null;
|
|
366
|
+
const body = signed.slice(0, idx);
|
|
367
|
+
const lines = body.split("\n");
|
|
358
368
|
if (lines.length !== 3) return null;
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
|
|
369
|
+
const [origin, sizeLine, rootHash] = lines;
|
|
370
|
+
if (!origin || origin.length > 256) return null;
|
|
371
|
+
if (!/^\d+$/.test(sizeLine)) return null;
|
|
372
|
+
const treeSize = parseInt(sizeLine, 10);
|
|
362
373
|
if (!Number.isInteger(treeSize) || treeSize < 0 || treeSize > Number.MAX_SAFE_INTEGER) return null;
|
|
363
|
-
if (!/^[0-9a-f]{64}$/.test(
|
|
364
|
-
|
|
374
|
+
if (!/^[0-9a-f]{64}$/.test(rootHash)) return null;
|
|
375
|
+
// The expected origin must be supplied by the caller (a trusted value), not
|
|
376
|
+
// taken from the checkpoint body, so a witness key that signs several origins
|
|
377
|
+
// cannot substitute a checkpoint for a different log than the receipt's.
|
|
378
|
+
if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
|
|
379
|
+
if (origin !== expectedOrigin) return null;
|
|
380
|
+
const sigLines = signed.slice(idx + 2).split("\n").filter((l) => l.length > 0);
|
|
381
|
+
if (sigLines.length === 0 || sigLines.length > 8) return null;
|
|
382
|
+
const bodyBytes = new TextEncoder().encode(body);
|
|
383
|
+
for (const line of sigLines) {
|
|
384
|
+
if (!line.startsWith("-- ")) return null;
|
|
385
|
+
const rest = line.slice(3);
|
|
386
|
+
const sp = rest.indexOf(" ");
|
|
387
|
+
if (sp < 0) return null;
|
|
388
|
+
if (rest.slice(0, sp) !== expectedOrigin) continue;
|
|
389
|
+
try {
|
|
390
|
+
const sig = base64urlDecode(rest.slice(sp + 1));
|
|
391
|
+
if (sig.length !== 64) return null;
|
|
392
|
+
const ok = await ed.verifyAsync(sig, bodyBytes, witnessPublicKey, { zip215: false });
|
|
393
|
+
return ok ? { treeSize, rootHash, origin } : null;
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
365
399
|
}
|
|
366
400
|
|
|
367
|
-
async function fetchCurrentCheckpoint(witnessUrl) {
|
|
401
|
+
async function fetchCurrentCheckpoint(witnessUrl, witnessPublicKey, expectedOrigin) {
|
|
402
|
+
// No trusted origin, no cross-check: refuse to trust the checkpoint body's
|
|
403
|
+
// self-asserted origin. Pass --origin <witness-origin> to enable it.
|
|
404
|
+
if (typeof expectedOrigin !== "string" || expectedOrigin.length === 0) return null;
|
|
368
405
|
const url = `${witnessUrl.replace(/\/$/, "")}/ink/v1/checkpoint`;
|
|
369
406
|
let body;
|
|
370
407
|
try {
|
|
@@ -374,7 +411,7 @@ async function fetchCurrentCheckpoint(witnessUrl) {
|
|
|
374
411
|
// 'not available' rather than crashing the verifier.
|
|
375
412
|
return null;
|
|
376
413
|
}
|
|
377
|
-
return
|
|
414
|
+
return verifyCheckpointBody(body, witnessPublicKey, expectedOrigin);
|
|
378
415
|
}
|
|
379
416
|
|
|
380
417
|
async function readStdin() {
|
|
@@ -451,16 +488,22 @@ async function main() {
|
|
|
451
488
|
process.exit(2);
|
|
452
489
|
}
|
|
453
490
|
|
|
454
|
-
|
|
491
|
+
// The checkpoint cross-check only carries weight against a checkpoint whose
|
|
492
|
+
// Ed25519 signature we have verified against the witness key. An unverified
|
|
493
|
+
// checkpoint (bad/absent signature, or origin mismatch) is dropped so the
|
|
494
|
+
// cross-check is skipped rather than trusting attacker-controlled values.
|
|
495
|
+
const laterCheckpoint = await fetchCurrentCheckpoint(witnessBase, witnessPublicKey, args.origin);
|
|
455
496
|
|
|
456
497
|
const result = await verifyReceipt(receipt, witnessPublicKey, args.eventHash, laterCheckpoint ?? undefined);
|
|
457
498
|
|
|
458
499
|
console.log(`Receipt: eventId=${receipt?.eventId} leafIndex=${receipt?.leafIndex} treeSize=${receipt?.treeSize}`);
|
|
459
500
|
console.log(`Witness: ${witnessBase}`);
|
|
460
501
|
if (laterCheckpoint) {
|
|
461
|
-
console.log(`Current checkpoint: treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
|
|
502
|
+
console.log(`Current checkpoint (signature verified): treeSize=${laterCheckpoint.treeSize} rootHash=${laterCheckpoint.rootHash}`);
|
|
503
|
+
} else if (!args.origin) {
|
|
504
|
+
console.log("Current checkpoint: cross-check skipped (pass --origin <witness-origin> to enable the anti-rollback check)");
|
|
462
505
|
} else {
|
|
463
|
-
console.log("Current checkpoint: not available (skipping checkpoint cross-check)");
|
|
506
|
+
console.log("Current checkpoint: not available or signature unverified (skipping checkpoint cross-check)");
|
|
464
507
|
}
|
|
465
508
|
console.log("");
|
|
466
509
|
for (const step of result.steps) {
|
|
@@ -26,21 +26,31 @@ export interface InclusionReceiptVerifyResult {
|
|
|
26
26
|
* - Service signature verification against `witnessPublicKey`
|
|
27
27
|
*
|
|
28
28
|
* Optionally performs (when the corresponding input is provided):
|
|
29
|
-
* - Leaf-to-root proof walk
|
|
29
|
+
* - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
|
|
30
|
+
* hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
|
|
30
31
|
* - Cross-check against a later signed checkpoint (`laterCheckpoint`)
|
|
31
32
|
*/
|
|
32
33
|
export declare function verifyInclusionReceipt(opts: {
|
|
33
34
|
receipt: InclusionReceipt;
|
|
34
35
|
/** Raw 32-byte Ed25519 public key of the witness service. */
|
|
35
36
|
witnessPublicKey: Uint8Array;
|
|
36
|
-
/** Optional
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
/** Optional audit event the receipt claims inclusion for. This is the
|
|
38
|
+
* RECOMMENDED way to verify the proof: the leaf hash is recomputed from the
|
|
39
|
+
* event with `computeAuditMerkleLeafHash`, and `event.id` is bound to
|
|
40
|
+
* `receipt.eventId`, so the proof attests that the event named by the
|
|
41
|
+
* receipt is in the tree — not merely that some caller-chosen hash is. */
|
|
42
|
+
event?: Record<string, unknown>;
|
|
43
|
+
/** Optional pre-computed RFC 6962 leaf hash (hex). LEGACY / lower-assurance:
|
|
44
|
+
* unlike `event`, a bare hash is NOT bound to `receipt.eventId`, so the proof
|
|
45
|
+
* only attests "this hash is in the tree", not "the event the receipt names
|
|
46
|
+
* is in the tree". Prefer `event`. Ignored when `event` is provided. */
|
|
40
47
|
eventHash?: string;
|
|
41
|
-
/** Optional later checkpoint to cross-check the receipt against.
|
|
42
|
-
*
|
|
43
|
-
* has
|
|
48
|
+
/** Optional later checkpoint to cross-check the receipt against. This MUST be
|
|
49
|
+
* the parsed body of a checkpoint whose Ed25519 signature and origin the
|
|
50
|
+
* caller has already verified with `verifyCheckpoint` against the witness
|
|
51
|
+
* key. Passing an unverified (merely parsed) checkpoint gives the
|
|
52
|
+
* anti-rollback / fork cross-check no security, because the treeSize and
|
|
53
|
+
* rootHash would then be attacker-controllable. */
|
|
44
54
|
laterCheckpoint?: {
|
|
45
55
|
treeSize: number;
|
|
46
56
|
rootHash: string;
|
|
@@ -30,12 +30,13 @@ import { base64urlDecode, jcsCanonicalize, hexToBytes, bytesToHex, computeAuditM
|
|
|
30
30
|
* - Service signature verification against `witnessPublicKey`
|
|
31
31
|
*
|
|
32
32
|
* Optionally performs (when the corresponding input is provided):
|
|
33
|
-
* - Leaf-to-root proof walk
|
|
33
|
+
* - Leaf-to-root proof walk: pass `event` (recommended — recomputes the leaf
|
|
34
|
+
* hash and binds it to `receipt.eventId`) or `eventHash` (legacy, unbound)
|
|
34
35
|
* - Cross-check against a later signed checkpoint (`laterCheckpoint`)
|
|
35
36
|
*/
|
|
36
37
|
export async function verifyInclusionReceipt(opts) {
|
|
37
38
|
const steps = [];
|
|
38
|
-
const { receipt, witnessPublicKey, eventHash, laterCheckpoint } = opts;
|
|
39
|
+
const { receipt, witnessPublicKey, event, eventHash, laterCheckpoint } = opts;
|
|
39
40
|
// ── Step 1: structural validation ──
|
|
40
41
|
const structuralProblem = checkReceiptShape(receipt);
|
|
41
42
|
if (structuralProblem) {
|
|
@@ -55,7 +56,7 @@ export async function verifyInclusionReceipt(opts) {
|
|
|
55
56
|
let sigValid = false;
|
|
56
57
|
try {
|
|
57
58
|
const sig = base64urlDecode(receipt.serviceSignature);
|
|
58
|
-
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey);
|
|
59
|
+
sigValid = await ed.verifyAsync(sig, new TextEncoder().encode(sigBase), witnessPublicKey, { zip215: false });
|
|
59
60
|
}
|
|
60
61
|
catch (e) {
|
|
61
62
|
steps.push({
|
|
@@ -71,12 +72,35 @@ export async function verifyInclusionReceipt(opts) {
|
|
|
71
72
|
}
|
|
72
73
|
steps.push({ name: "signature", pass: true });
|
|
73
74
|
// ── Step 3: inclusion-proof walk (optional) ──
|
|
74
|
-
|
|
75
|
+
// Prefer the `event` path: recompute the leaf hash from the event and bind it
|
|
76
|
+
// to receipt.eventId, so the proof attests the named event's inclusion.
|
|
77
|
+
let leafHash;
|
|
78
|
+
if (event !== undefined) {
|
|
79
|
+
if (typeof event.id !== "string") {
|
|
80
|
+
steps.push({ name: "proof", pass: false, detail: "event.id is missing or not a string" });
|
|
81
|
+
return { valid: false, steps };
|
|
82
|
+
}
|
|
83
|
+
if (event.id !== receipt.eventId) {
|
|
84
|
+
steps.push({ name: "proof", pass: false, detail: "event.id does not match receipt.eventId" });
|
|
85
|
+
return { valid: false, steps };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
leafHash = await computeAuditMerkleLeafHash(event);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
steps.push({ name: "proof", pass: false, detail: "could not compute leaf hash from event" });
|
|
92
|
+
return { valid: false, steps };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (eventHash !== undefined) {
|
|
75
96
|
if (!/^[0-9a-f]{64}$/.test(eventHash)) {
|
|
76
97
|
steps.push({ name: "proof", pass: false, detail: "eventHash must be 64 lowercase hex chars" });
|
|
77
98
|
return { valid: false, steps };
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
leafHash = eventHash;
|
|
101
|
+
}
|
|
102
|
+
if (leafHash !== undefined) {
|
|
103
|
+
const verified = await verifyInclusionProof(leafHash, receipt.inclusionProof, receipt.leafIndex, receipt.treeSize, receipt.rootHash);
|
|
80
104
|
if (!verified) {
|
|
81
105
|
steps.push({ name: "proof", pass: false, detail: "leaf-to-root walk did not reach claimed rootHash" });
|
|
82
106
|
return { valid: false, steps };
|
package/dist/crypto/ink.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as ed from "@noble/ed25519";
|
|
2
2
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
3
3
|
import canonicalize from "canonicalize";
|
|
4
|
+
import { isJcsSafeNumber } from "./sign.js";
|
|
4
5
|
// ── Encoding helpers ──
|
|
5
6
|
const MAX_ENCODE_INPUT_BYTES = 2_000_000;
|
|
6
7
|
function base64urlEncode(bytes) {
|
|
@@ -137,6 +138,11 @@ function isWithinCanonicalizeBounds(value) {
|
|
|
137
138
|
if (chars > MAX_PRECHECK_CHARS)
|
|
138
139
|
return false;
|
|
139
140
|
}
|
|
141
|
+
else if (typeof v === "number" && !isJcsSafeNumber(v)) {
|
|
142
|
+
// Reject numbers that don't canonicalize identically across JSON
|
|
143
|
+
// serializers (non-finite, -0, exponential notation). See sign.ts.
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
140
146
|
return true;
|
|
141
147
|
}
|
|
142
148
|
if (Array.isArray(v)) {
|
|
@@ -236,7 +242,9 @@ export async function verifyInkSignature(input, signatureBase64url, publicKey) {
|
|
|
236
242
|
const bytes = new TextEncoder().encode(sigBase);
|
|
237
243
|
try {
|
|
238
244
|
const sig = base64urlDecode(signatureBase64url);
|
|
239
|
-
|
|
245
|
+
// RFC 8032 strict verification (not the default ZIP-215): reject
|
|
246
|
+
// small-order keys and non-canonical encodings. See verifyMessage.
|
|
247
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
240
248
|
}
|
|
241
249
|
catch {
|
|
242
250
|
return false;
|
|
@@ -629,7 +637,7 @@ export async function verifyAuditEventSignature(event, publicKey) {
|
|
|
629
637
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
630
638
|
return false;
|
|
631
639
|
const sig = base64urlDecode(signature);
|
|
632
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
640
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
633
641
|
}
|
|
634
642
|
catch {
|
|
635
643
|
return false;
|
|
@@ -739,7 +747,7 @@ export async function verifyAuditResponseSignature(events, signature, publicKey)
|
|
|
739
747
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
740
748
|
return false;
|
|
741
749
|
const sig = base64urlDecode(signature);
|
|
742
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
750
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
743
751
|
}
|
|
744
752
|
catch {
|
|
745
753
|
return false;
|
|
@@ -905,7 +913,7 @@ export async function verifyAuditQueryResponseSignature(responseWithoutSignature
|
|
|
905
913
|
if (bytes.length > MAX_SIGBASE_BODY_BYTES)
|
|
906
914
|
return false;
|
|
907
915
|
const sig = base64urlDecode(signature);
|
|
908
|
-
return await ed.verifyAsync(sig, bytes, publicKey);
|
|
916
|
+
return await ed.verifyAsync(sig, bytes, publicKey, { zip215: false });
|
|
909
917
|
}
|
|
910
918
|
catch {
|
|
911
919
|
return false;
|
package/dist/crypto/keys.d.ts
CHANGED
|
@@ -30,13 +30,45 @@ export declare function decodePublicKeyMultibase(multibase: string): Uint8Array;
|
|
|
30
30
|
* Returns the raw 32-byte public key.
|
|
31
31
|
*/
|
|
32
32
|
export declare function decodeEncryptionKeyMultibase(multibase: string): Uint8Array;
|
|
33
|
+
/**
|
|
34
|
+
* agentId method prefixes that carry the same key-derived identity. Both encode
|
|
35
|
+
* the identical multibase public key, so they denote the same actor. `tulpa:`
|
|
36
|
+
* is canonical for emission (see deriveAgentId); `ink:` is an accepted inbound
|
|
37
|
+
* alias introduced in ink/0.4. Accept both, emit one.
|
|
38
|
+
*/
|
|
39
|
+
export declare const AGENT_ID_KEY_PREFIXES: readonly ["tulpa:", "ink:"];
|
|
33
40
|
/**
|
|
34
41
|
* Derive agent ID from a public key.
|
|
35
|
-
* Format: tulpa:<multibase-encoded-public-key>
|
|
42
|
+
* Format: tulpa:<multibase-encoded-public-key> (canonical emission).
|
|
36
43
|
*/
|
|
37
44
|
export declare function deriveAgentId(publicKey: Uint8Array): string;
|
|
38
45
|
/**
|
|
39
46
|
* Extract the public key from an agent ID.
|
|
40
47
|
* Only used for initial key exchange — after that, always resolve via identity store.
|
|
48
|
+
*
|
|
49
|
+
* Accepts either the canonical `tulpa:` prefix or the `ink:` alias (ink/0.4):
|
|
50
|
+
* both carry the identical multibase key, so a signature made with that key
|
|
51
|
+
* verifies regardless of which accepted prefix carried it. The prefix is
|
|
52
|
+
* identity syntax, not signing authority. The multibase tail is decoded the
|
|
53
|
+
* same way for both, so a malformed tail is rejected identically.
|
|
41
54
|
*/
|
|
42
55
|
export declare function extractPublicKeyFromAgentId(agentId: string): Uint8Array;
|
|
56
|
+
/**
|
|
57
|
+
* Collapse an agent ID to a single, prefix-independent principal string that
|
|
58
|
+
* per-sender security state (block lists, rate limits, duplicate-payload
|
|
59
|
+
* checks, cached verification keys, connection identity) MUST key on.
|
|
60
|
+
*
|
|
61
|
+
* The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
|
|
62
|
+
* key and are therefore the same actor; this maps both — and any non-canonical
|
|
63
|
+
* multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
|
|
64
|
+
* cannot switch prefix or re-encode to dodge a block or split a rate-limit
|
|
65
|
+
* window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
|
|
66
|
+
* input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
|
|
67
|
+
* cannot forge a collision with a canonicalized key principal.
|
|
68
|
+
*
|
|
69
|
+
* Not idempotent: call exactly once, at the storage boundary, on the raw
|
|
70
|
+
* agent ID. Total over well-formed string input (it never throws on a
|
|
71
|
+
* malformed key body — that is escaped to `raw:…` so a principal is always
|
|
72
|
+
* derivable); throws only on a non-string, empty, or over-length argument.
|
|
73
|
+
*/
|
|
74
|
+
export declare function canonicalAgentPrincipal(agentId: string): string;
|
package/dist/crypto/keys.js
CHANGED
|
@@ -156,9 +156,16 @@ export function decodeEncryptionKeyMultibase(multibase) {
|
|
|
156
156
|
}
|
|
157
157
|
return key;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* agentId method prefixes that carry the same key-derived identity. Both encode
|
|
161
|
+
* the identical multibase public key, so they denote the same actor. `tulpa:`
|
|
162
|
+
* is canonical for emission (see deriveAgentId); `ink:` is an accepted inbound
|
|
163
|
+
* alias introduced in ink/0.4. Accept both, emit one.
|
|
164
|
+
*/
|
|
165
|
+
export const AGENT_ID_KEY_PREFIXES = Object.freeze(["tulpa:", "ink:"]);
|
|
159
166
|
/**
|
|
160
167
|
* Derive agent ID from a public key.
|
|
161
|
-
* Format: tulpa:<multibase-encoded-public-key>
|
|
168
|
+
* Format: tulpa:<multibase-encoded-public-key> (canonical emission).
|
|
162
169
|
*/
|
|
163
170
|
export function deriveAgentId(publicKey) {
|
|
164
171
|
return `tulpa:${encodePublicKeyMultibase(publicKey)}`;
|
|
@@ -166,14 +173,59 @@ export function deriveAgentId(publicKey) {
|
|
|
166
173
|
/**
|
|
167
174
|
* Extract the public key from an agent ID.
|
|
168
175
|
* Only used for initial key exchange — after that, always resolve via identity store.
|
|
176
|
+
*
|
|
177
|
+
* Accepts either the canonical `tulpa:` prefix or the `ink:` alias (ink/0.4):
|
|
178
|
+
* both carry the identical multibase key, so a signature made with that key
|
|
179
|
+
* verifies regardless of which accepted prefix carried it. The prefix is
|
|
180
|
+
* identity syntax, not signing authority. The multibase tail is decoded the
|
|
181
|
+
* same way for both, so a malformed tail is rejected identically.
|
|
169
182
|
*/
|
|
170
183
|
export function extractPublicKeyFromAgentId(agentId) {
|
|
171
184
|
if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
|
|
172
185
|
throw new Error("Invalid agent ID");
|
|
173
186
|
}
|
|
174
|
-
const prefix =
|
|
175
|
-
if (!
|
|
187
|
+
const prefix = AGENT_ID_KEY_PREFIXES.find((p) => agentId.startsWith(p));
|
|
188
|
+
if (!prefix) {
|
|
176
189
|
throw new Error("Invalid agent ID format");
|
|
177
190
|
}
|
|
178
191
|
return decodePublicKeyMultibase(agentId.slice(prefix.length));
|
|
179
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Collapse an agent ID to a single, prefix-independent principal string that
|
|
195
|
+
* per-sender security state (block lists, rate limits, duplicate-payload
|
|
196
|
+
* checks, cached verification keys, connection identity) MUST key on.
|
|
197
|
+
*
|
|
198
|
+
* The accepted spellings `tulpa:zKEY` and `ink:zKEY` encode the same Ed25519
|
|
199
|
+
* key and are therefore the same actor; this maps both — and any non-canonical
|
|
200
|
+
* multibase encoding of that key — to `key:<canonical-multibase>`, so a sender
|
|
201
|
+
* cannot switch prefix or re-encode to dodge a block or split a rate-limit
|
|
202
|
+
* window. DIDs (and any other identifier) are returned unchanged. A raw `key:`
|
|
203
|
+
* input — never a legitimate agent ID — is escaped to `raw:key:…` so a sender
|
|
204
|
+
* cannot forge a collision with a canonicalized key principal.
|
|
205
|
+
*
|
|
206
|
+
* Not idempotent: call exactly once, at the storage boundary, on the raw
|
|
207
|
+
* agent ID. Total over well-formed string input (it never throws on a
|
|
208
|
+
* malformed key body — that is escaped to `raw:…` so a principal is always
|
|
209
|
+
* derivable); throws only on a non-string, empty, or over-length argument.
|
|
210
|
+
*/
|
|
211
|
+
export function canonicalAgentPrincipal(agentId) {
|
|
212
|
+
if (typeof agentId !== "string" || agentId.length === 0 || agentId.length > 512) {
|
|
213
|
+
throw new Error("Invalid agent ID");
|
|
214
|
+
}
|
|
215
|
+
const prefix = AGENT_ID_KEY_PREFIXES.find((p) => agentId.startsWith(p));
|
|
216
|
+
if (prefix) {
|
|
217
|
+
try {
|
|
218
|
+
return "key:" + encodePublicKeyMultibase(decodePublicKeyMultibase(agentId.slice(prefix.length)));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Malformed multibase body: keep the function total by treating it as an
|
|
222
|
+
// opaque identifier. Such an ID cannot authenticate via the bootstrap
|
|
223
|
+
// path anyway, so it never collides with a real key principal.
|
|
224
|
+
return "raw:" + agentId;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (agentId.startsWith("key:")) {
|
|
228
|
+
return "raw:" + agentId;
|
|
229
|
+
}
|
|
230
|
+
return agentId;
|
|
231
|
+
}
|
package/dist/crypto/sign.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number is safe for canonical JSON only if every conforming canonicalizer
|
|
3
|
+
* serializes it identically. We reject non-finite values (not valid JSON),
|
|
4
|
+
* negative zero (serializes as `0`, losing the sign), and any value whose
|
|
5
|
+
* shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
|
|
6
|
+
* forms are exactly where JSON serializers and strict RFC 8785 disagree.
|
|
7
|
+
* Rejecting them keeps the signed-byte representation unambiguous across
|
|
8
|
+
* implementations (the reference and a future second implementation), without
|
|
9
|
+
* affecting the small integers and plain decimals INK payloads actually carry.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isJcsSafeNumber(n: number): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Cheap depth/node/byte walk over a value before it is handed to
|
|
14
|
+
* `canonicalize`. Bails before the recursive sort+serialize runs, so an
|
|
15
|
+
* attacker who supplies a syntactically valid-shape signature with a
|
|
16
|
+
* pathological message body cannot burn CPU/memory inside the verify
|
|
17
|
+
* path. Mirrors src/crypto/ink.ts:isWithinCanonicalizeBounds, including
|
|
18
|
+
* the byte counter that stops a single huge string from sneaking past
|
|
19
|
+
* the node check.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isWithinBounds(value: unknown): boolean;
|
|
1
22
|
/**
|
|
2
23
|
* Sign a message object using Ed25519.
|
|
3
24
|
*
|
package/dist/crypto/sign.js
CHANGED
|
@@ -10,6 +10,23 @@ const MAX_MESSAGE_CHARS = 1_200_000;
|
|
|
10
10
|
* walk: a message can be small in node count but still expand to huge
|
|
11
11
|
* canonical bytes via long string values. */
|
|
12
12
|
const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
|
|
13
|
+
/**
|
|
14
|
+
* A number is safe for canonical JSON only if every conforming canonicalizer
|
|
15
|
+
* serializes it identically. We reject non-finite values (not valid JSON),
|
|
16
|
+
* negative zero (serializes as `0`, losing the sign), and any value whose
|
|
17
|
+
* shortest decimal uses exponential notation (`1e21`, `1e-7`) — exponential
|
|
18
|
+
* forms are exactly where JSON serializers and strict RFC 8785 disagree.
|
|
19
|
+
* Rejecting them keeps the signed-byte representation unambiguous across
|
|
20
|
+
* implementations (the reference and a future second implementation), without
|
|
21
|
+
* affecting the small integers and plain decimals INK payloads actually carry.
|
|
22
|
+
*/
|
|
23
|
+
export function isJcsSafeNumber(n) {
|
|
24
|
+
if (!Number.isFinite(n))
|
|
25
|
+
return false;
|
|
26
|
+
if (Object.is(n, -0))
|
|
27
|
+
return false;
|
|
28
|
+
return !/[eE]/.test(String(n));
|
|
29
|
+
}
|
|
13
30
|
/**
|
|
14
31
|
* Cheap depth/node/byte walk over a value before it is handed to
|
|
15
32
|
* `canonicalize`. Bails before the recursive sort+serialize runs, so an
|
|
@@ -19,7 +36,7 @@ const MAX_MESSAGE_CANONICAL_BYTES = 1_048_576;
|
|
|
19
36
|
* the byte counter that stops a single huge string from sneaking past
|
|
20
37
|
* the node check.
|
|
21
38
|
*/
|
|
22
|
-
function isWithinBounds(value) {
|
|
39
|
+
export function isWithinBounds(value) {
|
|
23
40
|
let nodes = 0;
|
|
24
41
|
let chars = 0;
|
|
25
42
|
function walk(v, depth) {
|
|
@@ -33,6 +50,9 @@ function isWithinBounds(value) {
|
|
|
33
50
|
if (chars > MAX_MESSAGE_CHARS)
|
|
34
51
|
return false;
|
|
35
52
|
}
|
|
53
|
+
else if (typeof v === "number" && !isJcsSafeNumber(v)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
36
56
|
return true;
|
|
37
57
|
}
|
|
38
58
|
if (Array.isArray(v)) {
|
|
@@ -162,7 +182,11 @@ export async function verifyMessage(message, publicKey) {
|
|
|
162
182
|
const prefixedBytes = new TextEncoder().encode(prefixed);
|
|
163
183
|
try {
|
|
164
184
|
const sig = base64urlDecode(signature);
|
|
165
|
-
|
|
185
|
+
// RFC 8032 strict verification, not the library default ZIP-215 mode:
|
|
186
|
+
// reject small-order public keys and non-canonical point encodings so a
|
|
187
|
+
// signature binds to exactly one (key, message). Identity is the embedded
|
|
188
|
+
// public key and signatures feed the audit log, so strictness is required.
|
|
189
|
+
return await ed.verifyAsync(sig, prefixedBytes, publicKey, { zip215: false });
|
|
166
190
|
}
|
|
167
191
|
catch {
|
|
168
192
|
// Malformed signature (invalid base64url, wrong byte length, bad key) — treat as invalid
|