231 lines
9.1 KiB
Markdown
231 lines
9.1 KiB
Markdown
# ADR-0007: Correct the keygen curve (Ed25519 → secp256k1) and the dblurt package
|
|
|
|
**Status:** Accepted
|
|
**Date:** 2026-04-18
|
|
**Deciders:** project maintainer
|
|
**Supersedes / corrects:** `docs/adr/0002-live-keys-policy.md` key-format
|
|
language (the policy is unchanged; the format described for "raw
|
|
32-byte secret scalar" is now a 32-byte secp256k1 scalar, not an
|
|
Ed25519 seed).
|
|
|
|
## Context
|
|
|
|
While beginning the Phase 3a frontend registration flow, a review
|
|
of `apps/web/src/lib/crypto/keygen.ts` surfaced two latent issues
|
|
that have been in the codebase since Phase 2. Both would have
|
|
blocked any real on-chain broadcast but went undetected because
|
|
Phase 2's `broadcastCustomJson` code path was never exercised
|
|
against a live Blurt RPC node. The Phase 2 carry-forward item P2-12
|
|
had explicitly flagged the lack of integration testing as a
|
|
blocker-adjacent risk; it turns out to have been a blocker outright.
|
|
|
|
### Issue 1: wrong elliptic curve
|
|
|
|
The keygen derives per-role keypairs via
|
|
`sodium.crypto_sign_seed_keypair(material)`, which produces an
|
|
**Ed25519** keypair. Blurt is a Graphene-lineage chain and uses
|
|
**secp256k1** for all account authorities. Ed25519 public keys are
|
|
32 bytes; secp256k1 compressed points are 33 bytes. These formats
|
|
are mutually incompatible — a Blurt node will reject any signature
|
|
verified against an Ed25519 public key, and the BLT address-prefix
|
|
format itself encodes a 33-byte secp256k1 point with a RIPEMD160
|
|
checksum.
|
|
|
|
The Phase-2 docstring at the top of `keygen.ts` (line 38) explicitly
|
|
says `→ ed25519 keypair (sodium.crypto_sign_seed_keypair)`. The
|
|
choice was apparently made because libsodium was already a
|
|
dependency for `secretbox`/Argon2id/memzero and had a convenient
|
|
keypair primitive. But it produces the wrong kind of key for the
|
|
chain we target.
|
|
|
|
### Issue 2: wrong dblurt package
|
|
|
|
`apps/web/package.json` lists `"dblurt": "^0.2.3"` as a runtime
|
|
dependency, and `apps/web/src/lib/blurt/sign.ts` imports from
|
|
`'dblurt'`. Investigation reveals:
|
|
|
|
- The `dblurt` npm package (unscoped) was last published in 2020 by
|
|
a single maintainer (`jacobgadikian`), version 0.1.2 — not 0.2.3.
|
|
The `^0.2.3` version constraint cannot resolve to any published
|
|
version of this package.
|
|
- The actively-maintained Blurt client library is
|
|
`@beblurt/dblurt`, version 0.10.9, with the Promise-based API,
|
|
TypeScript types, and `accountCreate` / `sendOperations` /
|
|
`broadcast.send` helpers that the relay already uses.
|
|
- The `sign.ts` code reads like it was written against the
|
|
`@beblurt/dblurt` API documentation (references to
|
|
`Client.signTransaction`, `PublicKey`, `PrivateKey`), so the
|
|
import path is a naming slip rather than intent.
|
|
|
|
Both bugs travelled together: the curve mismatch would have
|
|
surfaced the moment a `broadcastCustomJson` call hit the network,
|
|
and the wrong package name would have been caught on the first
|
|
`npm install` that resolved dependencies. Neither happened in the
|
|
Phase 2 deliverable window because the only consumer of the path
|
|
(Settings → display name broadcast) was unit-tested with the
|
|
library mocked at the module boundary.
|
|
|
|
## Decision
|
|
|
|
### Migrate keygen to secp256k1
|
|
|
|
Replace the `sodium.crypto_sign_seed_keypair` call inside
|
|
`deriveKeyForRole` with a secp256k1 keypair constructor. The rest
|
|
of the derivation pipeline stays unchanged:
|
|
|
|
BIP-39 mnemonic
|
|
→ BIP-39 seed (PBKDF2-HMAC-SHA-512, 2048 rounds) -- unchanged
|
|
→ master seed (BLAKE2b, "morphit-v1/master") -- unchanged
|
|
→ per-role material (BLAKE2b, "morphit-v1/<role>") -- unchanged
|
|
→ secp256k1 keypair (NEW) -- was Ed25519
|
|
|
|
Key observations:
|
|
|
|
- The 32-byte BLAKE2b output remains the per-role "material." For
|
|
secp256k1 it is interpreted as a private-key scalar.
|
|
- secp256k1 requires the scalar be in `[1, n-1]` where `n` is the
|
|
curve order. The probability that a uniformly random 32-byte
|
|
value falls outside this range is roughly 2⁻¹²⁸ — astronomically
|
|
rare but not zero. Standard remediation: if the raw bytes don't
|
|
fit, derive a replacement by hashing `material || counter` until
|
|
one does. We adopt this approach.
|
|
- Public key serialization is the 33-byte compressed form
|
|
(`secp256k1.getPublicKey(privateKey, true)`), matching Blurt's
|
|
BLT address format.
|
|
|
|
**Library choice:** `@noble/secp256k1` by paulmillr. Pure
|
|
TypeScript, ~250 lines, audited, widely used across the crypto
|
|
ecosystem (Bitcoin/Ethereum tooling, Solana clients, MetaMask).
|
|
Adds a small direct dependency; it is already in the transitive
|
|
tree via `@beblurt/dblurt`.
|
|
|
|
### Fix the dblurt package
|
|
|
|
Update `apps/web/package.json`:
|
|
|
|
```diff
|
|
-"dblurt": "^0.2.3",
|
|
+"@beblurt/dblurt": "^0.10.9",
|
|
```
|
|
|
|
Update `apps/web/src/lib/blurt/sign.ts`:
|
|
|
|
```diff
|
|
-from 'dblurt'
|
|
+from '@beblurt/dblurt'
|
|
```
|
|
|
|
Revalidate the API calls used — the `@beblurt/dblurt` documented
|
|
API includes `PrivateKey`, `PublicKey`, `Client`, `Operation`,
|
|
`Transaction`, `SignedTransaction`, `Client.sign` (or
|
|
`broadcast.sign`), which matches what `sign.ts` imports. The API
|
|
shape looks compatible, but the version is newer; small tweaks
|
|
may be needed once `npm install` succeeds and TypeScript resolves
|
|
real types.
|
|
|
|
### Add key-format helpers
|
|
|
|
The client side currently has `formatPublicKey(pk: Uint8Array):
|
|
string` that hex-encodes the bytes. This is insufficient for the
|
|
relay protocol: we need the BLT-prefixed, RIPEMD160-checksummed,
|
|
base58-encoded form. Add two new functions:
|
|
|
|
- `formatPublicKeyBLT(pk: Uint8Array): string` — 33-byte compressed
|
|
point → `BLT...` string.
|
|
- `formatPrivateKeyWIF(sk: Uint8Array): string` — 32-byte scalar →
|
|
`5...` WIF string (for seed-export features in future phases;
|
|
NOT used at registration time, since the user's private keys
|
|
stay local).
|
|
|
|
Both wrap `@beblurt/dblurt`'s `PublicKey` / `PrivateKey` classes
|
|
rather than re-implementing base58check.
|
|
|
|
### Explicit keystore break
|
|
|
|
`FullIdentity` and the encrypted keystore envelope both contain
|
|
key bytes. After the migration, existing (pre-fix) keystores
|
|
cannot be loaded — the bytes inside are Ed25519 and the new code
|
|
expects secp256k1.
|
|
|
|
Because Phase 2 was delivered as a tarball that was never
|
|
deployed publicly, **no real user keystore exists**. Any keystore
|
|
on disk right now is from a developer's test run. We declare a
|
|
hard break: a pre-fix keystore fails to decrypt (or decrypts but
|
|
derives nonsense), and the user re-runs onboarding.
|
|
|
|
Future keystore changes will follow a softer migration path once
|
|
there are real users; this specific break is permitted because we
|
|
are still pre-launch.
|
|
|
|
## Alternatives considered
|
|
|
|
### Keep Ed25519, convert at the chain boundary
|
|
|
|
Impossible. Blurt/Graphene's signature verification uses ECDSA
|
|
over secp256k1 at the consensus layer. An Ed25519 signature cannot
|
|
be made to verify as a secp256k1 signature regardless of encoding.
|
|
Rejected as non-viable.
|
|
|
|
### Use libsodium's Ristretto / other curve primitives
|
|
|
|
Not applicable. Blurt is committed to secp256k1 at the protocol
|
|
level; no alternate curve is accepted.
|
|
|
|
### Fork a Graphene-targeted keygen that uses Ed25519
|
|
|
|
Would require a Blurt chain fork. Out of scope.
|
|
|
|
### Use `@beblurt/dblurt`'s internal secp256k1 rather than `@noble`
|
|
|
|
Tempting (one less direct dependency), but the dblurt library
|
|
doesn't expose its internal secp256k1 for direct scalar-from-bytes
|
|
usage. We'd be reaching into its internals. `@noble/secp256k1` is
|
|
public API and smaller; keep them separate.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- On-chain broadcast actually works. Phase 3a registration can
|
|
proceed.
|
|
- P2-12 (the `broadcastCustomJson` test-coverage carry-forward)
|
|
now has a meaningful target to hit.
|
|
- The `dblurt` package typo is fixed before anyone tries to
|
|
deploy the frontend.
|
|
- The docstring at the top of `keygen.ts` is corrected to match
|
|
the code.
|
|
|
|
### Negative
|
|
|
|
- Any developer who has a local Morphit keystore from a Phase 2
|
|
tarball test must re-run onboarding. Documented in the release
|
|
notes for this fix.
|
|
- `@noble/secp256k1` is a new direct dependency on the frontend.
|
|
One small, well-maintained library, but one more to audit.
|
|
|
|
### Follow-up work
|
|
|
|
- Write a real integration test for the broadcast path. P2-12's
|
|
target is now "broadcast a `morphit_profile_v1` op against a
|
|
Blurt testnet (or a mocked RPC with a real signature-verifier),
|
|
observe the signature validates."
|
|
- Re-check Phase 2's `sign.ts` against the `@beblurt/dblurt` API
|
|
now that the import resolves. Likely only minor adjustments
|
|
needed; any actual API-shape divergence requires a follow-up
|
|
ADR.
|
|
- Audit every other place in the frontend that handles keys to
|
|
confirm no code paths assume 32-byte public keys (e.g.
|
|
identicon generator, IdentityLabel component, backup-export
|
|
rendering).
|
|
|
|
## References
|
|
|
|
- `docs/adr/0002-live-keys-policy.md` — live-keys policy (unchanged
|
|
in substance; format language updated).
|
|
- `docs/REVIEW-PHASE2.md` — P2-12 carry-forward item (upgraded from
|
|
"blocker-adjacent" to the actual cause of this ADR).
|
|
- `apps/web/src/lib/crypto/keygen.ts` — file to be modified.
|
|
- `apps/web/src/lib/blurt/sign.ts` — file to be modified.
|
|
- `apps/web/package.json` — dependency list to be corrected.
|
|
- `https://github.com/paulmillr/noble-secp256k1` — `@noble/secp256k1`.
|
|
- `https://www.npmjs.com/package/@beblurt/dblurt` — correct Blurt lib.
|