@sanning/proof 0.17.0 → 0.18.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/dist/crypto.d.ts CHANGED
@@ -4,6 +4,23 @@ export declare function isHexField(value: unknown, nibbles: number, lower?: bool
4
4
  export declare function utf8(s: string): Uint8Array;
5
5
  export declare function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array>;
6
6
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
7
+ /** The sentence a refused signature reads in an envelope result. */
8
+ export declare const SIGNATURE_FAILED = "Ed25519 signature verification failed";
9
+ export declare const ED25519_RULE_REASONS: readonly ["the public key is not the canonical encoding of a point", "the public key is a point of small order", "the public key is not in the prime-order subgroup", "R is not the canonical encoding of a point", "R is the identity", "R is a point of small order", "R is not in the prime-order subgroup", "S is not below the group order"];
10
+ /**
11
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
12
+ * when it breaks none and the equation is what is left to check. Lengths are
13
+ * the caller's (32 and 64 bytes). Never throws.
14
+ */
15
+ export declare function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null;
16
+ /**
17
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
18
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
19
+ * with `": "` and the clause of the rule it broke when it broke one. A
20
+ * malformed field, a wrong length and an equation that does not hold read
21
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
22
+ */
23
+ export declare function ed25519Refusal(message: Uint8Array, signatureHex: string, publicKeyHex: string): Promise<string | null>;
7
24
  export declare function ed25519Verify(message: Uint8Array, signatureHex: string, publicKeyHex: string): Promise<boolean>;
8
25
  export declare function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string>;
9
26
  export declare function ed25519PublicKey(privateKeyHex: string): Promise<string>;
package/dist/crypto.js CHANGED
@@ -6,7 +6,9 @@ import * as ed from "@noble/ed25519";
6
6
  // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
7
7
  // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
8
8
  // browser and in Node >= 19, so the same code path runs in the app and in tests.
9
- ed.etc.sha512Async = async (...msgs) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
9
+ // noble 3 ships this provider as its default; it is set here so the kernel
10
+ // says what it hashes with rather than inheriting it.
11
+ ed.hashes.sha512Async = async (message) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
10
12
  // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
11
13
  // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
12
14
  // typing satisfied without a runtime copy.
@@ -70,31 +72,129 @@ export async function sha256Bytes(bytes) {
70
72
  export async function sha256Hex(bytes) {
71
73
  return bytesToHex(await sha256Bytes(bytes));
72
74
  }
73
- // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
74
- // appear in the envelope; message is raw bytes. Never throws — a malformed
75
- // signature or key is "not verified," not an exception, so a hostile envelope
76
- // can't crash the checker.
75
+ // ---------------------------------------------------------------------------
76
+ // The Ed25519 rule (envelope-spec §7, A44, proof#230)
77
+ //
78
+ // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
79
+ // key A and the signature's R must each be the canonical encoding of a point
80
+ // in the prime-order subgroup other than the identity, and S below the group
81
+ // order L. Only then is the equation checked.
82
+ //
83
+ // Why more than `zip215: false`: noble checks the equation with the cofactor,
84
+ // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
85
+ // their own they disagree when A or R has a small-order component. noble 2.3
86
+ // also could not encode an identity R and hashed nothing for k, so it accepted
87
+ // one signature from a key holder over every message. With A and R in the
88
+ // prime-order subgroup the two equations are the same equation, and every
89
+ // kernel accepts exactly the same signatures.
90
+ //
91
+ // Each clause is checked here, in this order, and named in these words: the
92
+ // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
93
+ // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
94
+ // Several clauses are ones noble 3 refuses on its own; they are checked here
95
+ // anyway so the refusal names the clause, which a library's `false` cannot.
96
+ /** The sentence a refused signature reads in an envelope result. */
97
+ export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
98
+ export const ED25519_RULE_REASONS = [
99
+ "the public key is not the canonical encoding of a point",
100
+ "the public key is a point of small order",
101
+ "the public key is not in the prime-order subgroup",
102
+ "R is not the canonical encoding of a point",
103
+ "R is the identity",
104
+ "R is a point of small order",
105
+ "R is not in the prime-order subgroup",
106
+ "S is not below the group order",
107
+ ];
108
+ const [KEY_NOT_CANONICAL, KEY_SMALL_ORDER, KEY_NOT_IN_SUBGROUP, R_NOT_CANONICAL, R_IDENTITY, R_SMALL_ORDER, R_NOT_IN_SUBGROUP, S_NOT_BELOW_L,] = ED25519_RULE_REASONS;
109
+ const ED25519_L = 2n ** 252n + 27742317777372353535851937790883648493n;
110
+ function bytesToNumberLE(bytes) {
111
+ let v = 0n;
112
+ for (let i = bytes.length - 1; i >= 0; i--)
113
+ v = (v << 8n) | BigInt(bytes[i]);
114
+ return v;
115
+ }
116
+ // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
117
+ // branches: it throws on y at or above p and on x zero with the sign bit set,
118
+ // the two non-canonical encodings of a point, and on bytes that are no point.
119
+ function decodeStrict(bytes) {
120
+ try {
121
+ return ed.Point.fromBytes(bytes, false);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
129
+ * when it breaks none and the equation is what is left to check. Lengths are
130
+ * the caller's (32 and 64 bytes). Never throws.
131
+ */
132
+ export function ed25519RuleReason(signature, publicKey) {
133
+ const A = decodeStrict(publicKey);
134
+ if (A === null)
135
+ return KEY_NOT_CANONICAL;
136
+ if (A.isSmallOrder())
137
+ return KEY_SMALL_ORDER;
138
+ if (!A.isTorsionFree())
139
+ return KEY_NOT_IN_SUBGROUP;
140
+ const R = decodeStrict(signature.subarray(0, 32));
141
+ if (R === null)
142
+ return R_NOT_CANONICAL;
143
+ if (R.is0())
144
+ return R_IDENTITY;
145
+ if (R.isSmallOrder())
146
+ return R_SMALL_ORDER;
147
+ if (!R.isTorsionFree())
148
+ return R_NOT_IN_SUBGROUP;
149
+ if (bytesToNumberLE(signature.subarray(32, 64)) >= ED25519_L)
150
+ return S_NOT_BELOW_L;
151
+ return null;
152
+ }
153
+ /**
154
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
155
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
156
+ * with `": "` and the clause of the rule it broke when it broke one. A
157
+ * malformed field, a wrong length and an equation that does not hold read
158
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
159
+ */
160
+ export async function ed25519Refusal(message, signatureHex, publicKeyHex) {
161
+ let signature;
162
+ let publicKey;
163
+ try {
164
+ signature = hexToBytes(signatureHex);
165
+ publicKey = hexToBytes(publicKeyHex);
166
+ }
167
+ catch {
168
+ return SIGNATURE_FAILED;
169
+ }
170
+ if (signature.length !== 64 || publicKey.length !== 32)
171
+ return SIGNATURE_FAILED;
172
+ const reason = ed25519RuleReason(signature, publicKey);
173
+ if (reason !== null)
174
+ return `${SIGNATURE_FAILED}: ${reason}`;
175
+ try {
176
+ return (await ed.verifyAsync(signature, message, publicKey, { zip215: false })) ? null : SIGNATURE_FAILED;
177
+ }
178
+ catch {
179
+ return SIGNATURE_FAILED;
180
+ }
181
+ }
182
+ // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
183
+ // public key) as they appear in the envelope; message is raw bytes. Never
184
+ // throws — a malformed signature or key is "not verified," not an exception,
185
+ // so a hostile envelope can't crash the checker.
77
186
  // Arguments in the Python twin's order, `ed25519_verify(message,
78
187
  // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
79
188
  // until 0.13.0; a caller that still passes the signature first gets a compile
80
189
  // error, because a string is not a Uint8Array.
81
190
  export async function ed25519Verify(message, signatureHex, publicKeyHex) {
82
- try {
83
- // zip215:false → strict RFC-8032 verification, matching the Go agent's
84
- // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
85
- return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
86
- zip215: false,
87
- });
88
- }
89
- catch {
90
- return false;
91
- }
191
+ return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
92
192
  }
93
193
  // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
94
194
  // hex). Returns the signature as lowercase hex. The signing counterpart to
95
195
  // ed25519Verify — the ONE signing primitive the composer (`proof export`,
96
196
  // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
97
- // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
197
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
98
198
  // module load serves signing and verification alike, so this stays WebCrypto-only.
99
199
  export async function ed25519Sign(message, privateKeyHex) {
100
200
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyEnvelope, } from "./verifier.js";
2
- export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
2
+ export { bytesToHex, deriveOperatorAddress, ed25519Refusal, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
3
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
4
4
  export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions } from "./types.js";
5
5
  export { ACCEPTED_EVIDENCE_MAJORS, ANCHOR_TRACE_BODY_TYPE, CO_SIGNATURES_NOTE, EXPORT_BODY_TYPE, STAMP_BODY_VERSION, STAMP_TRACE_BODY_TYPE, STAMP_EVIDENCE_BODY_TYPE, coSignaturesPresent, disclosedContent, recomputeExportVerdict, verifyEvidenceBundle, } from "./evidence.js";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // mutable accept-set would be a runtime-widenable kernel. Additive — no
19
19
  // existing export changes.
20
20
  export { ACCEPTED_SPEC_MAJORS, contentHashes, jcs, specVersionSupported, verifyEnvelope, } from "./verifier.js";
21
- export { bytesToHex, deriveOperatorAddress, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
21
+ export { bytesToHex, deriveOperatorAddress, ed25519Refusal, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
22
22
  export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle.js";
23
23
  // Evidence-bundle verification (specs/evidence-bundle.md): the signed
24
24
  // sanning.evidence/v1 wrapper carrying the sanning.anchor.trace/v3 body. The CLI
package/dist/verifier.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // Conformance is enforced by test/conformance.test.ts, which runs every
8
8
  // corpus test vector through this code and asserts byte-for-byte agreement.
9
9
  import canonicalize from "canonicalize";
10
- import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
10
+ import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
11
11
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
12
12
  // accepted profile majors, nothing else. Minors within an accepted
13
13
  // major ("sanning.agent/v1.<minor>") are additive and tolerated — matching the Go
@@ -808,18 +808,22 @@ async function verifyEnvelopeChecked(env, opts) {
808
808
  // invalidating the primary signature; the field is reserved/default-absent.
809
809
  // The corpus has no co-signed vectors, so this strip is pinned by an explicit
810
810
  // unit test rather than by conformance.
811
+ // The refusal names the clause of the Ed25519 rule a signature breaks
812
+ // (envelope-spec §7, A44), so a forged shape reads differently from a
813
+ // corrupted byte; an equation that does not hold reads the bare sentence.
811
814
  let signatureOk = false;
812
815
  try {
813
816
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
814
- signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
815
- if (!signatureOk)
816
- errors.push("Ed25519 signature verification failed");
817
+ const refusal = await ed25519Refusal(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
818
+ signatureOk = refusal === null;
819
+ if (refusal !== null)
820
+ errors.push(refusal);
817
821
  }
818
822
  catch {
819
823
  // The sentence the Python kernel writes for the same failure. What was
820
824
  // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
821
825
  // public result carries the kernel's own words only (proof#150).
822
- errors.push("Ed25519 signature verification failed");
826
+ errors.push(SIGNATURE_FAILED);
823
827
  }
824
828
  // Check 3 (optional) — Content Bind: the user's bytes match a hash this
825
829
  // envelope commits to. The tag got us here; only this proves the bytes match.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "prepublishOnly": "npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "@noble/ed25519": "^2.1.0",
33
+ "@noble/ed25519": "^3.2.0",
34
34
  "canonicalize": "^2.0.0"
35
35
  },
36
36
  "devDependencies": {
package/src/crypto.ts CHANGED
@@ -8,8 +8,10 @@ import * as ed from "@noble/ed25519";
8
8
  // @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
9
9
  // pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
10
10
  // browser and in Node >= 19, so the same code path runs in the app and in tests.
11
- ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
12
- new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
11
+ // noble 3 ships this provider as its default; it is set here so the kernel
12
+ // says what it hashes with rather than inheriting it.
13
+ ed.hashes.sha512Async = async (message) =>
14
+ new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(message)));
13
15
 
14
16
  // WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
15
17
  // Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
@@ -76,10 +78,125 @@ export async function sha256Hex(bytes: Uint8Array): Promise<string> {
76
78
  return bytesToHex(await sha256Bytes(bytes));
77
79
  }
78
80
 
79
- // Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
80
- // appear in the envelope; message is raw bytes. Never throws — a malformed
81
- // signature or key is "not verified," not an exception, so a hostile envelope
82
- // can't crash the checker.
81
+ // ---------------------------------------------------------------------------
82
+ // The Ed25519 rule (envelope-spec §7, A44, proof#230)
83
+ //
84
+ // Strict RFC 8032 (`zip215: false`, a frozen invariant), and more: the public
85
+ // key A and the signature's R must each be the canonical encoding of a point
86
+ // in the prime-order subgroup other than the identity, and S below the group
87
+ // order L. Only then is the equation checked.
88
+ //
89
+ // Why more than `zip215: false`: noble checks the equation with the cofactor,
90
+ // 8·S·B = 8·R + 8·k·A, and Go's crypto/ed25519 and libsodium without it, so on
91
+ // their own they disagree when A or R has a small-order component. noble 2.3
92
+ // also could not encode an identity R and hashed nothing for k, so it accepted
93
+ // one signature from a key holder over every message. With A and R in the
94
+ // prime-order subgroup the two equations are the same equation, and every
95
+ // kernel accepts exactly the same signatures.
96
+ //
97
+ // Each clause is checked here, in this order, and named in these words: the
98
+ // Python twin is `ed25519_rule_reason` in `src/sanning_proof/ed25519_rule.py`
99
+ // and the Go kernel's `verifyEd25519Strict` in sanning-io/agent `pkg/proof`.
100
+ // Several clauses are ones noble 3 refuses on its own; they are checked here
101
+ // anyway so the refusal names the clause, which a library's `false` cannot.
102
+
103
+ /** The sentence a refused signature reads in an envelope result. */
104
+ export const SIGNATURE_FAILED = "Ed25519 signature verification failed";
105
+
106
+ export const ED25519_RULE_REASONS = [
107
+ "the public key is not the canonical encoding of a point",
108
+ "the public key is a point of small order",
109
+ "the public key is not in the prime-order subgroup",
110
+ "R is not the canonical encoding of a point",
111
+ "R is the identity",
112
+ "R is a point of small order",
113
+ "R is not in the prime-order subgroup",
114
+ "S is not below the group order",
115
+ ] as const;
116
+
117
+ const [
118
+ KEY_NOT_CANONICAL,
119
+ KEY_SMALL_ORDER,
120
+ KEY_NOT_IN_SUBGROUP,
121
+ R_NOT_CANONICAL,
122
+ R_IDENTITY,
123
+ R_SMALL_ORDER,
124
+ R_NOT_IN_SUBGROUP,
125
+ S_NOT_BELOW_L,
126
+ ] = ED25519_RULE_REASONS;
127
+
128
+ const ED25519_L = 2n ** 252n + 27742317777372353535851937790883648493n;
129
+
130
+ function bytesToNumberLE(bytes: Uint8Array): bigint {
131
+ let v = 0n;
132
+ for (let i = bytes.length - 1; i >= 0; i--) v = (v << 8n) | BigInt(bytes[i]);
133
+ return v;
134
+ }
135
+
136
+ // `Point.fromBytes(..., false)` is RFC 8032 decoding with its two strict
137
+ // branches: it throws on y at or above p and on x zero with the sign bit set,
138
+ // the two non-canonical encodings of a point, and on bytes that are no point.
139
+ function decodeStrict(bytes: Uint8Array): ed.Point | null {
140
+ try {
141
+ return ed.Point.fromBytes(bytes, false);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The clause of the rule that (`publicKey`, `signature`) breaks, or `null`
149
+ * when it breaks none and the equation is what is left to check. Lengths are
150
+ * the caller's (32 and 64 bytes). Never throws.
151
+ */
152
+ export function ed25519RuleReason(signature: Uint8Array, publicKey: Uint8Array): string | null {
153
+ const A = decodeStrict(publicKey);
154
+ if (A === null) return KEY_NOT_CANONICAL;
155
+ if (A.isSmallOrder()) return KEY_SMALL_ORDER;
156
+ if (!A.isTorsionFree()) return KEY_NOT_IN_SUBGROUP;
157
+ const R = decodeStrict(signature.subarray(0, 32));
158
+ if (R === null) return R_NOT_CANONICAL;
159
+ if (R.is0()) return R_IDENTITY;
160
+ if (R.isSmallOrder()) return R_SMALL_ORDER;
161
+ if (!R.isTorsionFree()) return R_NOT_IN_SUBGROUP;
162
+ if (bytesToNumberLE(signature.subarray(32, 64)) >= ED25519_L) return S_NOT_BELOW_L;
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * `null` iff `signatureHex` is a signature of `message` under `publicKeyHex`
168
+ * by the rule; otherwise the sentence the refusal reads: `SIGNATURE_FAILED`,
169
+ * with `": "` and the clause of the rule it broke when it broke one. A
170
+ * malformed field, a wrong length and an equation that does not hold read
171
+ * the bare sentence. Never throws. The Python twin is `ed25519_refusal`.
172
+ */
173
+ export async function ed25519Refusal(
174
+ message: Uint8Array,
175
+ signatureHex: string,
176
+ publicKeyHex: string,
177
+ ): Promise<string | null> {
178
+ let signature: Uint8Array;
179
+ let publicKey: Uint8Array;
180
+ try {
181
+ signature = hexToBytes(signatureHex);
182
+ publicKey = hexToBytes(publicKeyHex);
183
+ } catch {
184
+ return SIGNATURE_FAILED;
185
+ }
186
+ if (signature.length !== 64 || publicKey.length !== 32) return SIGNATURE_FAILED;
187
+ const reason = ed25519RuleReason(signature, publicKey);
188
+ if (reason !== null) return `${SIGNATURE_FAILED}: ${reason}`;
189
+ try {
190
+ return (await ed.verifyAsync(signature, message, publicKey, { zip215: false })) ? null : SIGNATURE_FAILED;
191
+ } catch {
192
+ return SIGNATURE_FAILED;
193
+ }
194
+ }
195
+
196
+ // Verify an Ed25519 signature by the rule above. Inputs are hex (signature,
197
+ // public key) as they appear in the envelope; message is raw bytes. Never
198
+ // throws — a malformed signature or key is "not verified," not an exception,
199
+ // so a hostile envelope can't crash the checker.
83
200
  // Arguments in the Python twin's order, `ed25519_verify(message,
84
201
  // signature_hex, public_key_hex)` (P-03, 0.13.0). The message came second
85
202
  // until 0.13.0; a caller that still passes the signature first gets a compile
@@ -89,22 +206,14 @@ export async function ed25519Verify(
89
206
  signatureHex: string,
90
207
  publicKeyHex: string,
91
208
  ): Promise<boolean> {
92
- try {
93
- // zip215:false → strict RFC-8032 verification, matching the Go agent's
94
- // crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
95
- return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
96
- zip215: false,
97
- });
98
- } catch {
99
- return false;
100
- }
209
+ return (await ed25519Refusal(message, signatureHex, publicKeyHex)) === null;
101
210
  }
102
211
 
103
212
  // Ed25519 SIGN the raw `message` bytes with a 32-byte private-key seed (lowercase
104
213
  // hex). Returns the signature as lowercase hex. The signing counterpart to
105
214
  // ed25519Verify — the ONE signing primitive the composer (`proof export`,
106
215
  // evidence-export.md §5) needs to Ed25519-sign the export wrapper with the
107
- // exporter's key ("anyone can export", P-4). The @noble sha512Async hook wired at
216
+ // exporter's key ("anyone can export", P-4). The @noble sha512Async provider wired at
108
217
  // module load serves signing and verification alike, so this stays WebCrypto-only.
109
218
  export async function ed25519Sign(message: Uint8Array, privateKeyHex: string): Promise<string> {
110
219
  return bytesToHex(await ed.signAsync(message, hexToBytes(privateKeyHex)));
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  export {
29
29
  bytesToHex,
30
30
  deriveOperatorAddress,
31
+ ed25519Refusal,
31
32
  ed25519Verify,
32
33
  hexToBytes,
33
34
  sha256Bytes,
package/src/verifier.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import canonicalize from "canonicalize";
11
11
 
12
- import { ed25519Verify, isHexField, sha256Hex, utf8 } from "./crypto.js";
12
+ import { SIGNATURE_FAILED, ed25519Refusal, isHexField, sha256Hex, utf8 } from "./crypto.js";
13
13
  import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from "./types.js";
14
14
 
15
15
  // Fail-closed accepted-profile registry (envelope-spec §2): exactly the
@@ -835,16 +835,20 @@ async function verifyEnvelopeChecked(
835
835
  // invalidating the primary signature; the field is reserved/default-absent.
836
836
  // The corpus has no co-signed vectors, so this strip is pinned by an explicit
837
837
  // unit test rather than by conformance.
838
+ // The refusal names the clause of the Ed25519 rule a signature breaks
839
+ // (envelope-spec §7, A44), so a forged shape reads differently from a
840
+ // corrupted byte; an equation that does not hold reads the bare sentence.
838
841
  let signatureOk = false;
839
842
  try {
840
843
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
841
- signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
842
- if (!signatureOk) errors.push("Ed25519 signature verification failed");
844
+ const refusal = await ed25519Refusal(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
845
+ signatureOk = refusal === null;
846
+ if (refusal !== null) errors.push(refusal);
843
847
  } catch {
844
848
  // The sentence the Python kernel writes for the same failure. What was
845
849
  // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
846
850
  // public result carries the kernel's own words only (proof#150).
847
- errors.push("Ed25519 signature verification failed");
851
+ errors.push(SIGNATURE_FAILED);
848
852
  }
849
853
 
850
854
  // Check 3 (optional) — Content Bind: the user's bytes match a hash this