Skip to content

Commit d2060f8

Browse files
committed
feat: add VerifyDER, test vector generator, and snap planning docs
Add VerifyDER to pkg/keys for server-side validation of client-provided DER signatures before forwarding to Canton. This is needed for the non-custodial transfer execute endpoint. Add cmd/generate-test-vectors to produce deterministic crypto test vectors (SPKI DER, fingerprints, DER signatures) for cross-validation with the canton-snap TypeScript implementation. Add planning docs for the MetaMask Snap non-custodial signing approach.
1 parent 441dfc2 commit d2060f8

6 files changed

Lines changed: 714 additions & 0 deletions

File tree

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ docker-build:
100100
docker-run:
101101
docker-compose up -d
102102

103+
# Generate crypto test vectors for canton-snap cross-validation
104+
test-vectors:
105+
go run ./cmd/generate-test-vectors > test-vectors.json
106+
@echo "Wrote test-vectors.json (copy to canton-snap repo)"
107+
103108
# Development setup
104109
setup: deps db-up
105110
@echo "Waiting for database to be ready..."

cmd/generate-test-vectors/main.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Command generate-test-vectors produces deterministic crypto test vectors
2+
// for cross-validation with the canton-snap TypeScript implementation.
3+
//
4+
// Output is written to stdout as JSON. Redirect to a file:
5+
//
6+
// go run ./cmd/generate-test-vectors > vectors.json
7+
package main
8+
9+
import (
10+
"crypto/sha256"
11+
"encoding/hex"
12+
"encoding/json"
13+
"fmt"
14+
"os"
15+
16+
"github.com/chainsafe/canton-middleware/pkg/keys"
17+
)
18+
19+
type signatureVector struct {
20+
Hash string `json:"hash"`
21+
DERSignature string `json:"der_signature"`
22+
}
23+
24+
type keyVector struct {
25+
PrivateKey string `json:"private_key"`
26+
CompressedPubKey string `json:"compressed_public_key"`
27+
SPKIDer string `json:"spki_der"`
28+
Fingerprint string `json:"fingerprint"`
29+
Signatures []signatureVector `json:"signatures"`
30+
}
31+
32+
type vectorFile struct {
33+
Description string `json:"description"`
34+
Vectors []keyVector `json:"vectors"`
35+
}
36+
37+
// Test private keys — small, deterministic, easy to reproduce.
38+
// These are NOT secure keys — they exist solely for cross-validation.
39+
var testPrivateKeys = []string{
40+
"0000000000000000000000000000000000000000000000000000000000000001",
41+
"0000000000000000000000000000000000000000000000000000000000000002",
42+
"0000000000000000000000000000000000000000000000000000000000000003",
43+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
44+
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
45+
}
46+
47+
// Test messages to hash and sign.
48+
var testMessages = []string{
49+
"",
50+
"canton-snap cross-validation",
51+
"hello world",
52+
}
53+
54+
func main() {
55+
output := vectorFile{
56+
Description: "Canton crypto test vectors for cross-validation with canton-snap TypeScript. " +
57+
"Generated by: go run ./cmd/generate-test-vectors",
58+
}
59+
60+
for _, privHex := range testPrivateKeys {
61+
privBytes, err := hex.DecodeString(privHex)
62+
if err != nil {
63+
fmt.Fprintf(os.Stderr, "invalid private key hex %q: %v\n", privHex, err)
64+
os.Exit(1)
65+
}
66+
67+
kp, err := keys.CantonKeyPairFromPrivateKey(privBytes)
68+
if err != nil {
69+
fmt.Fprintf(os.Stderr, "invalid private key %q: %v\n", privHex, err)
70+
os.Exit(1)
71+
}
72+
73+
spki, err := kp.SPKIPublicKey()
74+
if err != nil {
75+
fmt.Fprintf(os.Stderr, "SPKIPublicKey failed for key %q: %v\n", privHex, err)
76+
os.Exit(1)
77+
}
78+
79+
fp, err := kp.Fingerprint()
80+
if err != nil {
81+
fmt.Fprintf(os.Stderr, "Fingerprint failed for key %q: %v\n", privHex, err)
82+
os.Exit(1)
83+
}
84+
85+
kv := keyVector{
86+
PrivateKey: privHex,
87+
CompressedPubKey: hex.EncodeToString(kp.PublicKey),
88+
SPKIDer: hex.EncodeToString(spki),
89+
Fingerprint: fp,
90+
}
91+
92+
for _, msg := range testMessages {
93+
hash := sha256.Sum256([]byte(msg))
94+
derSig, err := kp.SignHashDER(hash[:])
95+
if err != nil {
96+
fmt.Fprintf(os.Stderr, "SignHashDER failed for key %q, msg %q: %v\n", privHex, msg, err)
97+
os.Exit(1)
98+
}
99+
100+
// Self-verify to catch bugs early
101+
if err := keys.VerifyDER(kp.PublicKey, hash[:], derSig); err != nil {
102+
fmt.Fprintf(os.Stderr, "Self-verification failed for key %q, msg %q: %v\n", privHex, msg, err)
103+
os.Exit(1)
104+
}
105+
106+
kv.Signatures = append(kv.Signatures, signatureVector{
107+
Hash: hex.EncodeToString(hash[:]),
108+
DERSignature: hex.EncodeToString(derSig),
109+
})
110+
}
111+
112+
output.Vectors = append(output.Vectors, kv)
113+
}
114+
115+
enc := json.NewEncoder(os.Stdout)
116+
enc.SetIndent("", " ")
117+
if err := enc.Encode(output); err != nil {
118+
fmt.Fprintf(os.Stderr, "JSON encode failed: %v\n", err)
119+
os.Exit(1)
120+
}
121+
}

docs/non-custodial-snap-plan.md

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)