|
| 1 | +# Non-Custodial Signing via MetaMask Snap — Implementation Plan |
| 2 | + |
| 3 | +## Problem Statement |
| 4 | + |
| 5 | +The Canton middleware currently holds every user's Canton signing key on the server (custodial model). While this enables a seamless MetaMask experience (users add a custom RPC network and use MetaMask's native Send UI), it makes the server a single point of compromise — if breached, every user's Canton key is exposed. |
| 6 | + |
| 7 | +We need a non-custodial option where the user's Canton signing key never leaves their control. |
| 8 | + |
| 9 | +## Why MetaMask Can't Sign Canton Transactions Directly |
| 10 | + |
| 11 | +Canton and Ethereum use the **same elliptic curve** (secp256k1) but **different hash functions**: |
| 12 | + |
| 13 | +| Aspect | Canton | MetaMask/Ethereum | |
| 14 | +|--------|--------|-------------------| |
| 15 | +| Hash algorithm | SHA-256 | Keccak-256 | |
| 16 | +| Signature encoding | ASN.1 DER | Raw 65-byte (r \|\| s \|\| v) | |
| 17 | +| Curve | secp256k1 | secp256k1 | |
| 18 | + |
| 19 | +An ECDSA signature is mathematically bound to the specific hash it was computed over. MetaMask always applies Keccak-256 before signing — there is no setting, API, or wrapping trick to change this. A signature over `keccak256(data)` will never verify against `sha256(data)`. This is a fundamental property of ECDSA security, not a software limitation. |
| 20 | + |
| 21 | +### Approaches Ruled Out |
| 22 | + |
| 23 | +| Approach | Why It Fails | |
| 24 | +|----------|-------------| |
| 25 | +| EIP-712 structured signing | Still Keccak-256 under the hood | |
| 26 | +| Raw ECDSA extraction | Signature is bound to the hash — mathematically impossible to re-target | |
| 27 | +| `eth_sign` raw hash | Deprecated, disabled by default, MetaMask may still re-hash, terrible UX (blind signing) | |
| 28 | +| EIP-4337 account abstraction | Problem is on Canton's side, not Ethereum's | |
| 29 | +| WalletConnect custom methods | Just a transport — the wallet still can't do SHA-256 signing | |
| 30 | + |
| 31 | +## Solution: MetaMask Snap |
| 32 | + |
| 33 | +[MetaMask Snaps](https://metamask.io/snaps/) are sandboxed JavaScript plugins that run inside MetaMask. A snap can: |
| 34 | + |
| 35 | +- **Derive a secp256k1 key** from the user's existing seed phrase via BIP-44 |
| 36 | +- **Sign with SHA-256 + DER encoding** — exactly what Canton requires |
| 37 | +- **Show a confirmation dialog** inside MetaMask before every signature |
| 38 | +- **Never expose the private key** — it stays in MetaMask's encrypted vault, no network access |
| 39 | + |
| 40 | +The Canton key is deterministically derived from the same seed phrase MetaMask already uses. One backup recovers both Ethereum and Canton keys. |
| 41 | + |
| 42 | +## Architecture: Hybrid (Custodial + Non-Custodial) |
| 43 | + |
| 44 | +Both signing modes coexist. A `key_mode` field on the user record routes between flows. |
| 45 | + |
| 46 | +``` |
| 47 | + "Add Network" user (custodial) Snap user (non-custodial) |
| 48 | + │ │ |
| 49 | + │ MetaMask native Send UI │ Web dApp UI |
| 50 | + ▼ ▼ |
| 51 | + ┌──────────────────┐ ┌─────────────────────────┐ |
| 52 | + │ /eth JSON-RPC │ │ /api/v2/transfer/ │ |
| 53 | + │ facade (existing)│ │ prepare → execute │ |
| 54 | + └────────┬─────────┘ └────────────┬────────────┘ |
| 55 | + │ server signs │ snap signs |
| 56 | + │ with custodial key │ in MetaMask |
| 57 | + ▼ ▼ |
| 58 | + ┌───────────────────────────────────────────────────────┐ |
| 59 | + │ Canton Interactive Submission API │ |
| 60 | + │ PrepareSubmission → ExecuteSubmission │ |
| 61 | + └───────────────────────────────────────────────────────┘ |
| 62 | +``` |
| 63 | + |
| 64 | +- **Custodial users**: Add the middleware as a custom network in MetaMask, use the native Send UI. Zero friction. No changes to existing flow. |
| 65 | +- **Non-custodial users**: Install the Canton Snap, interact through a web dApp that orchestrates prepare/sign/execute. The server never sees their private key. |
| 66 | +- Both produce identical Canton ledger state. |
| 67 | + |
| 68 | +## Snap Design |
| 69 | + |
| 70 | +### Key Derivation |
| 71 | + |
| 72 | +Derivation path: **`m/44'/60'/1'/0/0`** |
| 73 | + |
| 74 | +- Reuses coin type 60 (secp256k1) with account index 1 to segregate from ETH keys |
| 75 | +- `snap_getBip44Entropy` provides the BIP-44 node at `m/44'/60'`; the snap derives children via `@metamask/key-tree` |
| 76 | +- The snap does NOT get access to MetaMask's actual ETH private key — these are separate keys from the same seed |
| 77 | + |
| 78 | +### Snap RPC Methods |
| 79 | + |
| 80 | +| Method | Purpose | User Dialog | |
| 81 | +|--------|---------|-------------| |
| 82 | +| `canton_getPublicKey` | Export compressed pubkey + SPKI DER + fingerprint for registration | "Export Canton public key?" | |
| 83 | +| `canton_signHash` | Sign a 32-byte SHA-256 hash, return DER signature | Shows operation, token, amount, recipient | |
| 84 | +| `canton_signTopology` | Sign topology hash during registration | "Approve Canton party registration?" | |
| 85 | +| `canton_getFingerprint` | Quick fingerprint lookup | None | |
| 86 | +| `canton_getState` | Return registered key indices | None | |
| 87 | + |
| 88 | +### Snap Permissions |
| 89 | + |
| 90 | +| Permission | Purpose | |
| 91 | +|------------|---------| |
| 92 | +| `snap_getBip44Entropy` (coinType 60) | Derive Canton secp256k1 keys from seed | |
| 93 | +| `snap_dialog` | Show confirmation dialogs for signing | |
| 94 | +| `snap_manageState` | Persist key index and fingerprint mappings | |
| 95 | +| `endowment:rpc` (dapps: true) | Allow dApp to call snap RPC methods | |
| 96 | + |
| 97 | +**No network access.** The snap is a pure signing oracle — all data flows through the dApp. |
| 98 | + |
| 99 | +### Confirmation Dialog |
| 100 | + |
| 101 | +``` |
| 102 | +┌─────────────────────────────────────┐ |
| 103 | +│ Canton Network Transaction │ |
| 104 | +│ │ |
| 105 | +│ Operation: Transfer │ |
| 106 | +│ Token: DEMO │ |
| 107 | +│ Amount: 100.50 │ |
| 108 | +│ Recipient: party-abc123... │ |
| 109 | +│ Sender: party-def456... │ |
| 110 | +│ │ |
| 111 | +│ Hash: a1b2c3d4... │ |
| 112 | +│ │ |
| 113 | +│ ⚠ Verify the details match your │ |
| 114 | +│ intent before approving. │ |
| 115 | +│ │ |
| 116 | +│ [Reject] [Approve] │ |
| 117 | +└─────────────────────────────────────┘ |
| 118 | +``` |
| 119 | + |
| 120 | +## Flows |
| 121 | + |
| 122 | +### Non-Custodial Registration |
| 123 | + |
| 124 | +``` |
| 125 | +User → dApp: clicks "Register with Snap" |
| 126 | +dApp → Snap: canton_getPublicKey() |
| 127 | +Snap → User: "Export Canton key?" → Approve |
| 128 | +Snap → dApp: { compressedPubKey, spkiDer, fingerprint } |
| 129 | +dApp → MetaMask: personal_sign (EIP-191, proves ETH address) |
| 130 | +dApp → Server: POST /register/prepare-topology { canton_public_key, signature, message } |
| 131 | +Server → Canton: GenerateExternalPartyTopology(pubkey) |
| 132 | +Server → dApp: { topology_hash, public_key_fingerprint, registration_token } |
| 133 | +dApp → Snap: canton_signTopology(topology_hash) |
| 134 | +Snap → User: "Approve Canton registration?" → Approve |
| 135 | +Snap → dApp: { derSignature } |
| 136 | +dApp → Server: POST /register { key_mode=external, registration_token, topology_signature, canton_public_key } |
| 137 | +Server → Canton: AllocateExternalPartyWithSignature(topology, signature) |
| 138 | +Server → dApp: { partyId, fingerprint, key_mode=external } |
| 139 | +``` |
| 140 | + |
| 141 | +### Non-Custodial Transfer |
| 142 | + |
| 143 | +``` |
| 144 | +User → dApp: initiates transfer (token, amount, recipient) |
| 145 | +dApp → Server: POST /api/v2/transfer/prepare { to, amount, token } |
| 146 | + (authenticated via X-Signature / X-Message headers) |
| 147 | +Server → Canton: PrepareSubmission(transfer command) |
| 148 | +Server → Cache: store PreparedTransfer (2-5 min TTL) |
| 149 | +Server → dApp: { transfer_id, transaction_hash, party_id, expires_at } |
| 150 | +dApp → Snap: canton_signHash(hash, metadata) |
| 151 | +Snap → User: "Sign transfer: 100 DEMO → Alice?" → Approve |
| 152 | +Snap → dApp: { derSignature, fingerprint } |
| 153 | +dApp → Server: POST /api/v2/transfer/execute { transfer_id, signature, signed_by } |
| 154 | +Server: verify signature against stored public key |
| 155 | +Server → Canton: ExecuteSubmissionAndWait(prepared_tx, signature) |
| 156 | +Server → dApp: { status: "completed" } |
| 157 | +``` |
| 158 | + |
| 159 | +## Server-Side Status |
| 160 | + |
| 161 | +### Already Implemented (on main, PRs #152-155) |
| 162 | + |
| 163 | +- `POST /api/v2/transfer/prepare` and `POST /api/v2/transfer/execute` endpoints (`pkg/transfer/`) |
| 164 | +- `POST /register/prepare-topology` and `POST /register` (external mode) (`pkg/user/service/`) |
| 165 | +- `PrepareTransfer()` and `ExecuteTransfer()` on the Canton token SDK (`pkg/cantonsdk/token/`) |
| 166 | +- `GenerateExternalPartyTopology()` and `AllocateExternalPartyWithSignature()` (`pkg/cantonsdk/identity/`) |
| 167 | +- User model with `KeyMode` field (custodial/external) (`pkg/user/`) |
| 168 | +- In-memory caches with TTL for both transfers and topology |
| 169 | +- EVM timed message authentication with replay protection |
| 170 | +- Unit tests and mocks |
| 171 | + |
| 172 | +### Server-Side Fixes Needed |
| 173 | + |
| 174 | +1. **Signature verification before forwarding to Canton** — Add `keys.VerifyDER(publicKey, hash, signature)` in `Execute()`. Currently garbage signatures get forwarded to Canton, wasting a gRPC round-trip. |
| 175 | + |
| 176 | +2. **Ownership check in Execute** — Verify `pt.PartyID == sender.CantonPartyID` to prevent user B from executing user A's prepared transfer if they know the transfer ID. |
| 177 | + |
| 178 | +3. **Store SPKI public key on user record** — Needed for server-side signature verification (currently only the fingerprint is stored). |
| 179 | + |
| 180 | +4. **Consider bumping cache TTL** — Currently 2 minutes. A MetaMask Snap dialog adds latency; 3-5 minutes is safer for first-time users. |
| 181 | + |
| 182 | +## Security Model |
| 183 | + |
| 184 | +### Threat Analysis |
| 185 | + |
| 186 | +| Threat | Severity | Mitigation | |
| 187 | +|--------|----------|------------| |
| 188 | +| Server compromise exposes custodial keys | High | Non-custodial users unaffected — key never on server | |
| 189 | +| Malicious dApp sends wrong metadata with correct hash | Medium | Snap displays metadata for user verification (same as hardware wallets) | |
| 190 | +| Replay of transfer ID | Low | Single-use (deleted after execute), TTL expiry | |
| 191 | +| Man-in-the-middle modifies hash | Medium | HTTPS; modified hash won't match PreparedTransaction, Canton rejects | |
| 192 | +| Snap supply chain attack | Medium | No network access, pinned versions, auditable code, npm 2FA | |
| 193 | +| Private key extraction from snap | Low | SES sandbox, no network, key exists in memory only during signing | |
| 194 | +| User B executes user A's prepared transfer | Medium | Fix: add ownership check (pt.PartyID == sender.CantonPartyID) | |
| 195 | + |
| 196 | +### Key Security Properties |
| 197 | + |
| 198 | +1. **Private key never leaves MetaMask** — snap has no network access, cannot exfiltrate |
| 199 | +2. **Server never sees the private key** — receives only the public key at registration and signatures at execution |
| 200 | +3. **User confirms every signing operation** — snap dialog cannot be bypassed |
| 201 | +4. **Server validates everything** — verifies DER signature against stored public key before forwarding to Canton |
| 202 | + |
| 203 | +## Migration Strategy |
| 204 | + |
| 205 | +### Phase 1: Coexistence |
| 206 | +Both modes operate simultaneously. `key_mode` routes between them. New users choose at registration time. |
| 207 | + |
| 208 | +### Phase 2: Voluntary Migration |
| 209 | +Existing custodial users can migrate: |
| 210 | +1. Install the snap |
| 211 | +2. dApp gets snap's public key |
| 212 | +3. `POST /api/v1/migrate-to-snap` registers new key with Canton (key rotation via topology API) |
| 213 | +4. Server deletes encrypted private key from DB |
| 214 | +5. Canton party ID stays the same — only the signing key changes |
| 215 | + |
| 216 | +### Phase 3: Optional Deprecation |
| 217 | +If desired, new registrations can default to non-custodial. Custodial mode remains available for institutional/API users. |
| 218 | + |
| 219 | +## Development Phases |
| 220 | + |
| 221 | +| Phase | Scope | Estimate | |
| 222 | +|-------|-------|----------| |
| 223 | +| **1. Crypto compatibility** | TypeScript signing/encoding modules. Cross-validation tests against Go's `pkg/keys/`. Prove Canton accepts TypeScript-generated signatures. | 1-2 weeks | |
| 224 | +| **2. Snap scaffold** | Working MetaMask Snap with key derivation, signing, dialogs. Test on MetaMask Flask. | 1 week | |
| 225 | +| **3. Server fixes** | Signature verification, ownership check, store SPKI pubkey, bump cache TTL. | 2-3 days | |
| 226 | +| **4. Frontend/dApp** | Snap install flow, registration UI, transfer UI, mode detection. | 1 week | |
| 227 | +| **5. Migration + hardening** | Custodial-to-snap migration endpoint, error handling, E2E tests. | 1 week | |
| 228 | +| **6. Publish** | npm publish, Snaps directory submission, security audit. | Ongoing | |
| 229 | + |
| 230 | +**Phase 1 is the critical risk reducer** — it proves Canton accepts TypeScript-generated signatures before building anything else. |
| 231 | + |
| 232 | +## Why This Approach |
| 233 | + |
| 234 | +1. **Additive, not disruptive** — existing custodial MetaMask flow is untouched |
| 235 | +2. **Server-side is mostly done** — prepare/execute endpoints, registration, caches, user model all merged |
| 236 | +3. **One seed phrase backs up everything** — Canton key derived from MetaMask seed |
| 237 | +4. **Familiar UX** — snap signing dialogs appear inside MetaMask, not a separate app |
| 238 | +5. **Auditable and minimal** — snap is ~200 lines of crypto code, no network access, sandboxed |
| 239 | +6. **Users choose their trust model** — custodial for convenience, snap for security |
0 commit comments