@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 +17 -0
- package/dist/crypto.js +116 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/verifier.js +9 -5
- package/package.json +2 -2
- package/src/crypto.ts +125 -16
- package/src/index.ts +1 -0
- package/src/verifier.ts +8 -4
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
|
-
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
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
|
|
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
package/src/verifier.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import canonicalize from "canonicalize";
|
|
11
11
|
|
|
12
|
-
import {
|
|
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
|
-
|
|
842
|
-
|
|
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(
|
|
851
|
+
errors.push(SIGNATURE_FAILED);
|
|
848
852
|
}
|
|
849
853
|
|
|
850
854
|
// Check 3 (optional) — Content Bind: the user's bytes match a hash this
|