Skip to content

feat: hot key, cold key and guardian key#227

Draft
0xnullifier wants to merge 16 commits into
utk-psm-integrationfrom
utk-3keys
Draft

feat: hot key, cold key and guardian key#227
0xnullifier wants to merge 16 commits into
utk-psm-integrationfrom
utk-3keys

Conversation

@0xnullifier

Copy link
Copy Markdown
Collaborator

No description provided.

@WiktorStarczewski WiktorStarczewski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. importGuardianAccountBySeed returns 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.
  2. No legacy-Guardian detection. Pre-cutover Guardian records decode fine but fail at sign time. Detecting type === Guardian && hotPublicKey == null at vault load and surfacing a re-creation prompt would catch the failure earlier than the next send.
  3. Mobile crashes on Guardian creation because nativePlugin.generateHotKey() throws. Gate the Guardian flow behind !isMobile() until Phase 4 lands, or have nativePlugin defer to jsFallback as 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.

Comment thread CHANGELOG.md Outdated
MigrationLevel = 'migration',
Mnemonic = 'mnemonic',
AccAuthSecretKey = 'accauthsecretkey',
AccColdSecretKey = 'accouldsecretkey',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/miden/sdk/miden-client-interface.ts Outdated
signerCommitments: [signerCommitment.toHex()],
guardianCommitment: commitment,
guardianPublicKey: pubkey,
signerCommitments: [hot.commitmentHex, coldCommitmentHex],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/secure-hot-key/nativePlugin.ts Outdated
Comment thread src/lib/shared/types.ts
// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@0xnullifier 0xnullifier marked this pull request as draft May 5, 2026 12:24
@0xnullifier 0xnullifier mentioned this pull request May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants