9.1 KiB
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
dblurtnpm package (unscoped) was last published in 2020 by a single maintainer (jacobgadikian), version 0.1.2 — not 0.2.3. The^0.2.3version 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, andaccountCreate/sendOperations/broadcast.sendhelpers that the relay already uses. - The
sign.tscode reads like it was written against the@beblurt/dblurtAPI documentation (references toClient.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]wherenis 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 hashingmaterial || counteruntil 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:
-"dblurt": "^0.2.3",
+"@beblurt/dblurt": "^0.10.9",
Update apps/web/src/lib/blurt/sign.ts:
-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
broadcastCustomJsontest-coverage carry-forward) now has a meaningful target to hit. - The
dblurtpackage typo is fixed before anyone tries to deploy the frontend. - The docstring at the top of
keygen.tsis 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/secp256k1is 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_v1op against a Blurt testnet (or a mocked RPC with a real signature-verifier), observe the signature validates." - Re-check Phase 2's
sign.tsagainst the@beblurt/dblurtAPI 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.