feat: hot key, cold key and guardian key#227
Conversation
WiktorStarczewski
left a comment
There was a problem hiding this comment.
Approving — direction is right, the secure-hot-key facade is a clean abstraction and the test coverage on the happy path is solid.
Leaving inline recommendations rather than blockers, but flagging three I'd like addressed before this rolls to users:
importGuardianAccountBySeedreturns success while producing a broken-recovery account until Phase 8 lands. The docstring acknowledges it; the function surface doesn't. Throwing — or surfacing a clear UI banner — is safer than letting the user think their import worked.- No legacy-Guardian detection. Pre-cutover Guardian records decode fine but fail at sign time. Detecting
type === Guardian && hotPublicKey == nullat vault load and surfacing a re-creation prompt would catch the failure earlier than the next send. - Mobile crashes on Guardian creation because
nativePlugin.generateHotKey()throws. Gate the Guardian flow behind!isMobile()until Phase 4 lands, or havenativePlugindefer tojsFallbackas a soft-degradation.
The rest are mechanical cleanups (typo, type-fencing, missing tests for signHotDigest/nativePlugin, signer-commitment ordering invariant). Empty PR body is also worth filling in given this is a phased migration with a hard cutover.
| MigrationLevel = 'migration', | ||
| Mnemonic = 'mnemonic', | ||
| AccAuthSecretKey = 'accauthsecretkey', | ||
| AccColdSecretKey = 'accouldsecretkey', |
There was a problem hiding this comment.
Typo: 'accouldsecretkey' — should be 'accoldsecretkey'. Not user-visible, but this becomes the on-disk storage prefix and gets baked into every cold-key record forever once accounts start writing. Easier to rename now (no live data yet) than after launch.
| [ | ||
| [accAuthPubKeyStrgKey(keys.hotPublicKey), keys.hotPublicKey], | ||
| [accAuthSecretKeyStrgKey(keys.hotPublicKey), keys.hotCiphertext], | ||
| [accColdSecretKeyStrgKey(keys.coldPublicKey), keys.coldSecretKeyHex] |
There was a problem hiding this comment.
Asymmetric with the hot path: hot writes both accAuthPubKeyStrgKey(hotPublicKey) and the secret-key entry, but cold only writes accColdSecretKeyStrgKey(coldPublicKey) — no accAuthPubKeyStrgKey(coldPublicKey). If anything in the codebase enumerates "all known auth pubkeys" via the accAuthPubKey namespace, cold is invisible to it. Probably intentional (cold is for rotation, not daily signing) but worth a one-line comment so the next reader doesn't think it's a bug.
| // createGuardianAccount so the vault can persist them. Callers must use | ||
| // createGuardianMidenWallet directly — this branch protects against the | ||
| // older string-returning API being relied on. | ||
| throw new Error('Guardian wallets must be created via createGuardianMidenWallet'); |
There was a problem hiding this comment.
Runtime throw rather than type-fence. The signature still accepts WalletType.Guardian, so accidental calls only blow up at runtime. Better:
async createMidenWallet(
walletType: Exclude<WalletType, WalletType.Guardian>,
seed?: Uint8Array,
): Promise<string>so tsc catches the misuse. Same applies to importAccountBySeed below.
| signerCommitments: [signerCommitment.toHex()], | ||
| guardianCommitment: commitment, | ||
| guardianPublicKey: pubkey, | ||
| signerCommitments: [hot.commitmentHex, coldCommitmentHex], |
There was a problem hiding this comment.
Comment above says "order is load-bearing for downstream role routing" — but only the test asserts it. A future contributor sorting the array (for deterministic hashing, dedup, etc.) would silently break role routing. Recommend codifying the invariant:
const SIGNER_ROLES = ['hot', 'cold'] as const;
// ...
signerCommitments: [hot.commitmentHex, coldCommitmentHex] satisfies readonly string[]or a runtime assertion at signer-key persistence time that maps commitment → role and traps mismatches.
| private signWordFn: (wordHex: string) => Promise<string>; | ||
|
|
||
| constructor(publicKey: string, commitment: string, signWordFn: SignWordFunction) { | ||
| constructor(publicKey: string, commitment: string, signWordFn: SignWordFunction, scheme: SignatureScheme = 'ecdsa') { |
There was a problem hiding this comment.
Default flipped from hardcoded 'falcon' to parameter default 'ecdsa'. Intentional in the cutover, but every existing 3-arg new WalletSigner(pk, commit, signFn) call site now silently becomes ECDSA. Worth grepping the base branch (utk-psm-integration) for all WalletSigner constructions to confirm the cutover is what's wanted at each one — silent default flips are exactly where unintended behavior changes hide.
| return { ciphertext, publicKeyHex, commitmentHex }; | ||
| } | ||
|
|
||
| export async function signHotDigest(ciphertext: string, wordHex: string): Promise<string> { |
There was a problem hiding this comment.
signHotDigest and deleteHotKey have zero test coverage in this PR. They're not invoked yet (Phase 3 wires them up), but the ciphertext format is the on-disk blob shape — high-stakes to keep stable across phases. A round-trip test like:
const hot = await generateHotKey();
const sig = await signHotDigest(hot.ciphertext, '0x' + '00'.repeat(32));
expect(sig).toMatch(/^0x[0-9a-f]+$/);would catch a future refactor that changes the ciphertext encoding and silently breaks loading of existing hot keys.
| // Set on Guardian accounts created with the 3-key model (hot + cold + guardian). | ||
| // Absent on non-Guardian accounts and on legacy single-signer Guardian records | ||
| // produced before the migration; consumers should treat absence as "not 3-key". | ||
| hotPublicKey?: string; |
There was a problem hiding this comment.
Optional fields are non-breaking for non-Guardian accounts (good) but they're also non-breaking for legacy Guardian accounts created on the pre-cutover Falcon build — those records decode fine, then fail at sign time because the on-chain account uses Falcon and the wallet now produces ECDSA sigs.
Recommend a load-time check: type === Guardian && hotPublicKey == null ⇒ flag the account as "requires re-creation" and surface a banner. Catching the breakage at vault load is much better UX than the user discovering it when their next send fails.
…var in generateGuardianTransaction
…izeGuardianSwitch
No description provided.