Skip to content

Commit 7f7f93d

Browse files
committed
Don't persist BIP39 mnemonic password; require it on load and spend
The mnemonic password is a second factor whose value depends on not being stored next to the seed. The prior flow both persisted it and plumbed it through the sign path, getting the worst of both. Switch to a supply model: - Drop WalletData.mnemonicPassword; fromMnemonicWithPassword no longer writes it. - loadSpendingKey / getChainAddress derive from the supplied password. - loadExisting + RailgunEngine.loadExistingWallet take an optional mnemonicPassword; keys/address are re-derived each session. - assertMnemonicPasswordMatchesID verifies the supplied password reproduces the wallet ID on load and on every spend, turning a wrong/missing password into a clear error instead of a silently divergent key. Tests: supply-model load, no-password regression, wrong/missing-password rejection, and sign+verify with a supplied password (plus negative cases).
1 parent 78eac16 commit 7f7f93d

6 files changed

Lines changed: 133 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ RAILGUN Wallet
4242

4343
- `railgunEngine.createWalletFromMnemonic()` (instance method)
4444
- `railgunEngine.createWalletFromMnemonicWithPassword()` (instance method)
45+
- The BIP39 mnemonic password is **not stored** by the engine. The caller owns it and
46+
must supply it again to `loadExistingWallet()` and to every spend.
4547
- `railgunEngine.loadExistingWallet()` (instance method)
48+
- Pass the BIP39 mnemonic password as the third argument for wallets created with one.
4649

4750
View-only RAILGUN Wallet
4851

‎src/models/wallet-types.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export type AddressKeys = {
3636

3737
export type WalletData = {
3838
mnemonic: string;
39-
mnemonicPassword?: string;
4039
index: number;
4140
creationBlockNumbers: Optional<number[][]>;
4241
};

‎src/railgun-engine.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2098,13 +2098,25 @@ class RailgunEngine extends EventEmitter {
20982098
* Load existing wallet
20992099
* @param {string} encryptionKey - encryption key of wallet
21002100
* @param {string} id - wallet ID
2101+
* @param {string} mnemonicPassword - BIP39 mnemonic password, if the wallet was created
2102+
* with one. Not stored by the engine; must be supplied on every load (and on spend).
21012103
* @returns id
21022104
*/
2103-
async loadExistingWallet(encryptionKey: string, id: string): Promise<RailgunWallet> {
2105+
async loadExistingWallet(
2106+
encryptionKey: string,
2107+
id: string,
2108+
mnemonicPassword?: string,
2109+
): Promise<RailgunWallet> {
21042110
if (isDefined(this.wallets[id])) {
21052111
return this.wallets[id] as RailgunWallet;
21062112
}
2107-
const wallet = await RailgunWallet.loadExisting(this.db, encryptionKey, id, this.prover);
2113+
const wallet = await RailgunWallet.loadExisting(
2114+
this.db,
2115+
encryptionKey,
2116+
id,
2117+
this.prover,
2118+
mnemonicPassword,
2119+
);
21082120
await this.loadWallet(wallet);
21092121
return wallet;
21102122
}

‎src/transaction/__tests__/transaction-erc20.test.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,8 @@ describe('transaction-erc20', function test() {
851851

852852
expect(signature).to.deep.equal(signEDDSA(privateKey, msg));
853853

854+
// Mnemonic-password wallet: the password is supplied (never stored) and must be
855+
// threaded through sign -> getSpendingKeyPair to derive the correct spending key.
854856
const mnemonicPassword = 'test mnemonic password';
855857
const passwordWallet = await RailgunWallet.fromMnemonicWithPassword(
856858
db,
@@ -861,20 +863,32 @@ describe('transaction-erc20', function test() {
861863
undefined,
862864
prover,
863865
);
864-
const signatureWithMnemonicPassword = await wallet.sign(
866+
const passwordSpendingKeyPair = await passwordWallet.getSpendingKeyPair(
867+
testEncryptionKey,
868+
mnemonicPassword,
869+
);
870+
const signatureWithMnemonicPassword = await passwordWallet.sign(
865871
publicInputs,
866872
testEncryptionKey,
867873
mnemonicPassword,
868874
);
869-
const passwordSpendingKeyPair = await passwordWallet.getSpendingKeyPair(testEncryptionKey);
870875

871876
assert.isTrue(
872-
verifyEDDSA(
873-
msg,
874-
signatureWithMnemonicPassword,
875-
passwordSpendingKeyPair.pubkey,
876-
),
877+
verifyEDDSA(msg, signatureWithMnemonicPassword, passwordSpendingKeyPair.pubkey),
877878
);
879+
expect(signatureWithMnemonicPassword).to.deep.equal(
880+
signEDDSA(passwordSpendingKeyPair.privateKey, msg),
881+
);
882+
883+
// Signing a mnemonic-password wallet without the password must fail loudly rather
884+
// than silently derive a different, unrelated key.
885+
await expect(passwordWallet.sign(publicInputs, testEncryptionKey)).to.be.rejectedWith(
886+
'Incorrect mnemonic password for wallet.',
887+
);
888+
// Supplying a password for a wallet that has none must likewise fail.
889+
await expect(
890+
wallet.sign(publicInputs, testEncryptionKey, mnemonicPassword),
891+
).to.be.rejectedWith('Incorrect mnemonic password for wallet.');
878892
});
879893

880894
it('Should request one hardware wallet batch approval and sign with the returned sub-session', async () => {

‎src/wallet/__tests__/railgun-wallet.test.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,20 +96,77 @@ describe('railgun-wallet', () => {
9696
new Prover(testArtifactsGetter),
9797
);
9898

99+
// A mnemonic password produces a distinct wallet from the no-password wallet.
100+
expect(passwordWallet.id).to.not.equal(wallet.id);
101+
102+
// The mnemonic password is never stored, so it must be supplied again on load.
99103
const loadedWallet = await RailgunWallet.loadExisting(
100104
db,
101105
testEncryptionKey,
102106
passwordWallet.id,
103107
new Prover(testArtifactsGetter),
108+
mnemonicPassword,
104109
);
105110

106-
expect(passwordWallet.id).to.not.equal(wallet.id);
107111
expect(loadedWallet.id).to.equal(passwordWallet.id);
112+
expect(await loadedWallet.getChainAddress(testEncryptionKey, mnemonicPassword)).to.equal(
113+
await passwordWallet.getChainAddress(testEncryptionKey, mnemonicPassword),
114+
);
115+
// The chain address also differs from the no-password wallet.
116+
expect(
117+
await passwordWallet.getChainAddress(testEncryptionKey, mnemonicPassword),
118+
).to.not.equal(await wallet.getChainAddress(testEncryptionKey));
119+
});
120+
121+
it('Should produce the same id/address for a no-password wallet (regression)', async () => {
122+
// Loading without a password must reproduce the wallet unchanged — guards the
123+
// empty-passphrase derivation path against regressions.
124+
const loadedWallet = await RailgunWallet.loadExisting(
125+
db,
126+
testEncryptionKey,
127+
wallet.id,
128+
new Prover(testArtifactsGetter),
129+
);
130+
expect(loadedWallet.id).to.equal(wallet.id);
108131
expect(await loadedWallet.getChainAddress(testEncryptionKey)).to.equal(
109-
await passwordWallet.getChainAddress(testEncryptionKey),
132+
await wallet.getChainAddress(testEncryptionKey),
110133
);
111134
});
112135

136+
it('Should reject loading a mnemonic-password wallet with a wrong/missing password', async () => {
137+
const mnemonicPassword = 'test mnemonic password';
138+
const passwordWallet = await RailgunWallet.fromMnemonicWithPassword(
139+
db,
140+
testEncryptionKey,
141+
testMnemonic,
142+
mnemonicPassword,
143+
0,
144+
undefined, // creationBlockNumbers
145+
new Prover(testArtifactsGetter),
146+
);
147+
148+
// Missing password.
149+
await expect(
150+
RailgunWallet.loadExisting(
151+
db,
152+
testEncryptionKey,
153+
passwordWallet.id,
154+
new Prover(testArtifactsGetter),
155+
),
156+
).to.be.rejectedWith('Incorrect mnemonic password for wallet.');
157+
158+
// Wrong password.
159+
await expect(
160+
RailgunWallet.loadExisting(
161+
db,
162+
testEncryptionKey,
163+
passwordWallet.id,
164+
new Prover(testArtifactsGetter),
165+
'wrong password',
166+
),
167+
).to.be.rejectedWith('Incorrect mnemonic password for wallet.');
168+
});
169+
113170
it('Should load existing view-only wallet', async () => {
114171
const viewOnlyWallet2 = await ViewOnlyWallet.loadExisting(
115172
db,

‎src/wallet/railgun-wallet.ts‎

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,28 @@ class RailgunWallet extends AbstractWallet {
4242
*/
4343
private async loadSpendingKey(
4444
encryptionKey: string,
45-
mnemonicPasswordOverride?: string,
45+
mnemonicPassword?: string,
4646
): Promise<WalletNode> {
47-
const { mnemonic, mnemonicPassword, index } = (await RailgunWallet.read(
47+
const { mnemonic, index } = (await RailgunWallet.read(
4848
this.db,
4949
this.id,
5050
encryptionKey,
5151
)) as WalletData;
52-
return deriveNodes(mnemonic, index, mnemonicPasswordOverride ?? mnemonicPassword).spending;
52+
RailgunWallet.assertMnemonicPasswordMatchesID(this.id, mnemonic, index, mnemonicPassword);
53+
return deriveNodes(mnemonic, index, mnemonicPassword).spending;
5354
}
5455

5556
/**
56-
* Helper to get the ethereum/whatever address is associated with this wallet
57+
* Helper to get the ethereum/whatever address is associated with this wallet.
58+
* The BIP39 mnemonic password (if any) must be supplied; it is never stored.
5759
*/
58-
async getChainAddress(encryptionKey: string): Promise<string> {
59-
const { mnemonic, mnemonicPassword, index } = (await AbstractWallet.read(
60+
async getChainAddress(encryptionKey: string, mnemonicPassword?: string): Promise<string> {
61+
const { mnemonic, index } = (await AbstractWallet.read(
6062
this.db,
6163
this.id,
6264
encryptionKey,
6365
)) as WalletData;
66+
RailgunWallet.assertMnemonicPasswordMatchesID(this.id, mnemonic, index, mnemonicPassword);
6467
return Mnemonic.to0xAddress(mnemonic, index, mnemonicPassword);
6568
}
6669

@@ -78,6 +81,23 @@ class RailgunWallet extends AbstractWallet {
7881
);
7982
}
8083

84+
/**
85+
* The BIP39 mnemonic password is never persisted; it must be supplied by the caller
86+
* on load and on every spend. Verify the supplied password reproduces this wallet's
87+
* ID — a mismatch means the wrong (or a missing) password was supplied, which would
88+
* otherwise silently derive a different, unrelated key. Fail loudly instead.
89+
*/
90+
private static assertMnemonicPasswordMatchesID(
91+
id: string,
92+
mnemonic: string,
93+
index: number,
94+
mnemonicPassword: string = '',
95+
): void {
96+
if (RailgunWallet.generateID(mnemonic, index, mnemonicPassword) !== id) {
97+
throw new Error('Incorrect mnemonic password for wallet.');
98+
}
99+
}
100+
81101
private static async createWallet(
82102
id: string,
83103
db: Database,
@@ -142,12 +162,10 @@ class RailgunWallet extends AbstractWallet {
142162
): Promise<RailgunWallet> {
143163
const id = RailgunWallet.generateID(mnemonic, index, mnemonicPassword);
144164

145-
// Write encrypted mnemonic to DB
146-
const walletData: WalletData = { mnemonic, index, creationBlockNumbers };
147-
if (mnemonicPassword !== '') {
148-
walletData.mnemonicPassword = mnemonicPassword;
149-
}
150-
await AbstractWallet.write(db, id, encryptionKey, walletData);
165+
// Write encrypted mnemonic to DB. The BIP39 mnemonic password is intentionally NOT
166+
// persisted: storing it next to the mnemonic would defeat its purpose as a second
167+
// factor. The caller owns the password and must supply it on load and on spend.
168+
await AbstractWallet.write(db, id, encryptionKey, { mnemonic, index, creationBlockNumbers });
151169

152170
return this.createWallet(
153171
id,
@@ -172,16 +190,21 @@ class RailgunWallet extends AbstractWallet {
172190
encryptionKey: string,
173191
id: string,
174192
prover: Prover,
193+
mnemonicPassword?: string,
175194
): Promise<RailgunWallet> {
176195
// Get encrypted mnemonic and index from DB
177-
const { mnemonic, mnemonicPassword, index, creationBlockNumbers } = (await AbstractWallet.read(
196+
const { mnemonic, index, creationBlockNumbers } = (await AbstractWallet.read(
178197
db,
179198
id,
180199
encryptionKey,
181200
)) as WalletData;
182201
if (!mnemonic) {
183202
throw new Error('Incorrect wallet type.');
184203
}
204+
// The mnemonic password is not stored — the supplied one must reproduce this wallet's
205+
// ID. Verifying here turns a wrong/missing password into a clear error instead of
206+
// silently loading a different, unrelated wallet's keys.
207+
RailgunWallet.assertMnemonicPasswordMatchesID(id, mnemonic, index, mnemonicPassword);
185208

186209
return this.createWallet(
187210
id,

0 commit comments

Comments
 (0)