Commit a839483
authored
feat!: prepared-transaction envelope, origin gating, audit fixes (#52)
* docs(snap): internal security audit for v0.2.0
Reviewed packages/snap source, manifest, build config, and dependency
tree. No critical findings. Two High and six Medium findings concentrated
in input handling and authorization on src/index.ts:
- H-01: origin is never inspected; any installed dApp can drive every
RPC method and the dialog does not say which dApp is asking.
- H-02: dApp-supplied SignHashMetadata is rendered in the signing
dialog as if authoritative, while only the hash is signed.
- M-01/M-02: hexToBytes silently substitutes zero for non-hex chars
and truncates odd-length input.
- M-03: hash length is not validated before the dialog renders, so a
multi-megabyte hex string is passed to Copyable.
- M-04: keyIndex accepts NaN, floats, negatives, huge numbers — the
derivation salt space is effectively unbounded.
- M-05: canton_getFingerprint has no dialog and discloses a stable
Canton identity to any caller.
- M-06: topology dialog wording assumes initial registration and is
misleading for key rotation or party-hosting changes.
All transitive npm-audit advisories are in development-only paths and
do not reach the published bundle. Recommended remediation order and
file-level pointers are included in §5 of the report.
* fix(snap): close audit findings H-01..L-03 before registry submission
H-01 Pass `origin` into `onRpcRequest` and surface it in every dialog
so the user sees which dApp is asking before approving.
H-02 Caller-supplied `SignHashMetadata` is now rendered under an
unmissable "details are supplied by the dApp and are NOT verified
by the snap" warning, and the dialog footer states explicitly that
approving will share the Canton fingerprint with the dApp.
M-01 New `src/hex.ts` with a strict parser: rejects non-hex
M-02 characters and odd-length input. The four duplicate local
hexToBytes helpers (silently zero-substituting NaN, silently
truncating odd-length) are gone.
M-03 `parseSignHash` / `parseTopologyHash` validate hash length and
format BEFORE any dialog renders, so an oversized hex blob can no
longer be passed to `Copyable`. signHash is 32 bytes exactly;
signTopology is 1..128 bytes.
M-04 `validateKeyIndex` rejects NaN, floats, negatives, non-integers,
and values > 1000 — the derivation salt space is no longer
unbounded and integer-equal indices no longer split into distinct
keys due to float stringification.
M-05 `canton_getFingerprint` now asks for consent on the first call
L-01 from each origin and persists the allowlist via snap_manageState
(closing L-01 by actually using the permission the manifest
already declares). Subsequent calls from approved origins return
silently — no dialog spam for legitimate apps.
M-06 Topology dialog wording rewritten to be operation-agnostic
("Sign Canton Topology Transaction") with a warning that topology
ops can rotate keys or change party membership.
L-02 Misleading comment in keyDerivation.ts removed. The real reason
we use snap_getEntropy is that it returns snap-scoped entropy
unlinkable from BIP-44 wallet paths — the previous comment claimed
snap_getBip44Entropy "forbids coin type 60" which is backwards.
L-03 Signing dialog footer notes that approving shares the fingerprint
with the dApp, so the disclosure is no longer implicit.
Tests:
- jest: new cases for invalid keyIndex, malformed hex, wrong-length
hash, oversized topology hash, first-call-vs-subsequent-call
fingerprint flow, rejection paths. 17 passing.
- vitest: new validation.test.ts covers NaN/Infinity/null/strings
that the JSON-RPC transport can't carry into jest. 16 new + 28
existing crypto vectors = 44 passing.
- mm-snap build: clean, no warnings.
I-01 (SECURITY.md), I-02 (broader fuzz coverage), and I-03 (use
@noble/curves' built-in DER encoder) deferred to follow-ups.
* refactor(snap): use @noble/hashes utils for hex codec
@noble/hashes already exports hexToBytes / bytesToHex from
@noble/hashes/utils, and the snap already depends on the library. The
custom src/hex.ts wrapper just re-derived behavior the library
already provides — same strict rejection of non-hex characters and
odd-length input, but with an audited implementation we don't have
to maintain or review separately.
- Delete packages/snap/src/hex.ts.
- validation.ts, index.ts, sign.ts, keyDerivation.ts, fingerprint.ts,
and the validation test import hexToBytes / bytesToHex directly
from @noble/hashes/utils.
- The one-line stripHexPrefix helper stays inline in the two files
that use it (validation.ts and keyDerivation.ts).
* refactor(snap): deeper audit pass — drop hand-rolled DER, validate metadata, harden state, broaden tests
Second pass beyond the H/M findings already closed. Focus areas: replace
remaining hand-rolled code with audited library primitives, plug the
gaps in input validation, and bound state growth.
Library swaps:
- Drop the 50-line hand-rolled ASN.1 DER encoder in sign.ts. @noble/curves
exposes Signature.toDERRawBytes(), which is canonical DER and produces
byte-identical output to our previous encoder against every Go test
vector (verified before swap).
Input validation:
- SignHashMetadata is now validated by validateMetadata: each field must
be a string ≤ 200 chars, required fields are checked, arrays and
non-objects are rejected. Previously a non-string field would render
as "[object Object]" in the dialog; an oversized string could DoS
the dialog renderer.
- compressedPubKeyToSPKIDer now rejects keys whose first byte is not
0x02 or 0x03 (the valid compressed-point prefixes). Defense in depth —
@noble/curves' ProjectivePoint.fromHex would catch it downstream, but
failing at the input check makes the error site obvious.
State:
- allowFingerprintOrigin caps the allowlist at 200 entries with FIFO
eviction. A long-lived install can no longer grow snap_manageState
unbounded if the user keeps approving new origins.
- Drop the unused EMPTY_STATE export.
Tests (76 total — was 61):
- crypto.test.ts: new "rejects compressed key with invalid prefix" +
"accepts both valid compressed prefixes" cases (30 total).
- validation.test.ts: 7 new metadata cases + boundary cases for
parseTopologyHash min/max byte length, one-over-max, odd-length
(27 total).
- index.test.js: rejects metadata with non-string field, rejects
metadata with oversized string (19 total).
SECURITY.md: closes audit finding I-01. Lists supported versions,
private reporting channels (GitHub Security Advisories +
security@chainsafe.io), scope, and coordinated disclosure policy.
* Revert SECURITY.md addition
* fix(snap): address external review — strict topology, keyed allowlist, key context in dialogs
External review (H/M/L) on top of the internal audit. Five real items
land here; H-1 (blind hash signing) is acknowledged but only partially
mitigated until canonical PreparedTransaction parsing is in scope.
H-1 (partial) Signing dialog now leads with metadata when present;
hash drops below as "Hash to sign". When metadata is
absent, the dialog emits a "RAW HASH SIGNING — the dApp
did not provide any transaction context" warning. The
cryptographic binding problem (snap cannot verify that
metadata matches the signed digest) is unchanged —
fixing it requires the snap to re-derive Canton's
canonical transaction hash from a structured payload,
which needs canton-middleware to ship the schema +
cross-validation vectors.
H-2 parseTopologyHash now requires a SHA-256 multihash exactly:
34 bytes with the 0x1220 prefix. Wrong prefix, wrong digest
length, or wrong overall length all reject. The previous 1..128
byte window let any byte string through.
M-4 Fingerprint allowlist is now keyed by (origin, keyIndex), not
just origin. Approving keyIndex 0 no longer lets a dApp silently
enumerate keyIndex 1..1000. Schema changed from string[] to
Record<string, number[]>; the loader handles the older shape as
"no approvals" so existing installs don't crash. Cap is 200
origins × 32 keyIndexes per origin with FIFO eviction.
M-5 Every dialog (export, sign, topology, fingerprint) now shows
"Key index: N" and the corresponding Canton fingerprint via a
Copyable. A dApp can no longer drive a non-default keyIndex
invisibly. dialogs.ts factored out a shared contextLines() helper.
M-6 README architecture diagram corrected: replaced the inaccurate
"Derives key at m/44'/60'/1'/0/0" line with the actual
snap_getEntropy derivation, and added a paragraph documenting the
implication — keys are scoped to the snap ID, so local vs npm
snap IDs derive different identities and there is no migration
between them.
L-8 Private-key derivation now rejection-samples via
secp256k1.utils.isValidPrivateKey. sha256 output ≥ n is ≈ 2⁻¹²⁸,
but rolling forward with a counter is cleaner than the previous
"would throw downstream" path.
Other:
- handleSignHash/Topology/GetFingerprint/GetPublicKey all use a small
deriveFull() helper so origin, keyIndex, and fingerprint are
computed once per request.
- Tests: +1 jest (re-prompts for different keyIndex from same origin),
+1 vitest (multihash wrong-algorithm-prefix rejected), updated the
oversized-topology-hash and validation test fixtures to use a real
multihash. 58 vitest + 20 jest passing.
* fix(snap): finalize external-review fixes — prepared-tx envelope, origin module, sha2 imports
Lands the canton_signHash redesign that binds the dialog to the signed
digest, plus the small clean-ups surfaced by the self-validation pass.
Snap
- canton_signHash no longer accepts a raw hash. Callers must supply a
`canton-snap.prepared-transaction.v1` envelope; the snap recomputes
the canonical SHA-256 multihash from a whitelisted field set and
rejects with "transactionHash does not match canonical transaction
data" on any drift. The signature is over sha256(transactionHash),
so what the dialog renders provably maps to what gets signed.
- Schema string lives in src/constants.ts and is imported by the
validator and the vitest fixtures so the three call sites can't
silently drift.
- canonicalJson algorithm is now documented in detail (key sort, no
whitespace, ECMA-262 string escaping, finite-number rejection) so a
Go-side middleware implementer has an exact target.
- New non-ASCII round-trip test (café / CJK / emoji) guards against
surrogate / HTML-escape divergence between the snap and middleware.
- assertSigningOrigin moved to src/origin.ts so it's directly unit-
testable. Loopback set now accepts both "[::1]" (what WHATWG URL
actually returns) and bare "::1" defensively.
- All sha256 imports switched from @noble/hashes/sha256 (now
deprecated upstream) to @noble/hashes/sha2.
- stripHexPrefix consolidated in src/hex.ts; two duplicate copies
removed from validation.ts and keyDerivation.ts.
- Signing-dialog "verified" wording softened: "These fields were used
to compute the prepared transaction hash you're about to sign" —
cryptographically accurate without overclaiming Canton-level safety.
dApp side (transfer flow)
- prepareTransfer / prepareAcceptTransfer now require the middleware
to return a prepared_transaction envelope and refuse the legacy
hash-only response with "Middleware did not return a secure
prepared_transaction envelope; update canton-middleware before
using snap signing."
- SNAP_ID defaults to npm:@chainsafe/canton-snap; VITE_SNAP_ID still
overrides for local Flask dev. Documented in .env.example and README.
Tests
- vitest 65 passing (was 58): +5 assertSigningOrigin cases, +2
canonical-JSON non-ASCII / extra-field cases.
- jest 20 passing (was 19): all signHash paths exercise the new
prepared-transaction envelope.
- mm-snap build clean, eslint clean.
* ci(snap): drop release-as 0.2.1 pin
The feat! commit in this PR will drive a 0.3.0 bump naturally;
keeping release-as would override that to 0.2.1.
* revert(snap): drop prepared-tx envelope enforcement; restore hash+metadata signing
The prepared-tx envelope required canton-middleware to emit a
`prepared_transaction` field that no released middleware build returns.
Shipping it now breaks every transfer ("Middleware did not return a
secure prepared_transaction envelope"). Reverting to the hash+metadata
interface so this PR doesn't break local dev.
The envelope check is the right end-state — it removes blind signing —
but middleware needs to ship the canonical envelope first. Tracked as
two follow-up issues:
- canton-middleware: emit `prepared_transaction` envelope
- canton-snap: re-introduce envelope enforcement once middleware ships it
Both land together in a future PR pair.
Kept from the audit pass:
- origin allowlist (origin.ts)
- strict topology multihash validation (parseTopologyHash)
- keyed (origin, keyIndex) fingerprint allowlist
- key-index + fingerprint context in every dialog
- @noble/hashes/sha2 + secp256k1.utils.isValidPrivateKey rejection sampling
- hex.ts consolidation
* fix(snap): sha256 the hash before signing in canton_signHash
Canton's CantonKeyPair.SignDER always sha256-hashes its input before
ECDSA-signing — confirmed in pkg/keys/canton_keys.go:212-215 and
mirrored in pkg/transfer's test helpers signTransferHash / signCantonTx
(both call kp.SignDER, which internally does sha256.Sum256(message)).
The snap was signing the raw 32-byte transaction hash directly, so
middleware would receive a signature over H while Canton's verifier
checked it against sha256(H) → 'signature verification failed' (403).
Fix: compute sha256(hashBytes) and sign that digest. canton_signTopology
already follows this pattern (sha256 of the multihash before signing);
canton_signHash is now consistent.1 parent 8c6cb6d commit a839483
21 files changed
Lines changed: 792 additions & 313 deletions
File tree
- docs
- packages
- dapp/src
- hooks
- pages
- snap
- src
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
15 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
16 | 17 | | |
17 | 18 | | |
18 | 19 | | |
| 20 | + | |
| 21 | + | |
19 | 22 | | |
20 | 23 | | |
21 | 24 | | |
| |||
24 | 27 | | |
25 | 28 | | |
26 | 29 | | |
27 | | - | |
28 | | - | |
| 30 | + | |
| 31 | + | |
29 | 32 | | |
30 | 33 | | |
31 | 34 | | |
32 | 35 | | |
33 | 36 | | |
34 | 37 | | |
35 | | - | |
| 38 | + | |
36 | 39 | | |
37 | 40 | | |
38 | | - | |
| 41 | + | |
39 | 42 | | |
40 | 43 | | |
41 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
67 | | - | |
| 67 | + | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
137 | | - | |
| 137 | + | |
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
187 | | - | |
188 | | - | |
| 187 | + | |
| 188 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
3 | 2 | | |
4 | 3 | | |
5 | 4 | | |
| |||
80 | 79 | | |
81 | 80 | | |
82 | 81 | | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
| 82 | + | |
87 | 83 | | |
88 | 84 | | |
89 | 85 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
259 | 259 | | |
260 | 260 | | |
261 | 261 | | |
262 | | - | |
| 262 | + | |
263 | 263 | | |
264 | 264 | | |
| 265 | + | |
265 | 266 | | |
266 | 267 | | |
267 | 268 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
| 361 | + | |
362 | 362 | | |
363 | 363 | | |
364 | 364 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
8 | 7 | | |
9 | 8 | | |
10 | 9 | | |
11 | 10 | | |
12 | 11 | | |
13 | 12 | | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
18 | 23 | | |
19 | 24 | | |
20 | 25 | | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
38 | 45 | | |
39 | 46 | | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
50 | 65 | | |
51 | 66 | | |
| 67 | + | |
52 | 68 | | |
53 | | - | |
54 | | - | |
| 69 | + | |
55 | 70 | | |
56 | 71 | | |
57 | 72 | | |
58 | 73 | | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
63 | 80 | | |
64 | 81 | | |
65 | | - | |
66 | | - | |
67 | | - | |
| 82 | + | |
| 83 | + | |
68 | 84 | | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
69 | 89 | | |
70 | 90 | | |
71 | 91 | | |
72 | 92 | | |
73 | 93 | | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
52 | 53 | | |
53 | 54 | | |
54 | 55 | | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
0 commit comments