Skip to content

Commit 99d49eb

Browse files
committed
feat: use deciphered seed for xud key
This changes the way the xud node key is derived from the recovery mnemonic to use the deciphered seed - which does not contain salt and certain error checking bytes - rather than the enciphered seed. This is to ensure that the same node key is derived from a recovery seed regardless of how it is enciphered using salt and an aezeed password.
1 parent 76eda46 commit 99d49eb

File tree

10 files changed

+174
-83
lines changed

10 files changed

+174
-83
lines changed

Diff for: lib/lndclient/LndClient.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -774,11 +774,11 @@ class LndClient extends SwapClient {
774774
return this.unaryCall<lndrpc.QueryRoutesRequest, lndrpc.QueryRoutesResponse>('queryRoutes', request);
775775
}
776776

777-
public genSeed = async (): Promise<lndrpc.GenSeedResponse.AsObject> => {
777+
public genSeed = async () => {
778778
const genSeedResponse = await this.unaryWalletUnlockerCall<lndrpc.GenSeedRequest, lndrpc.GenSeedResponse>(
779779
'genSeed', new lndrpc.GenSeedRequest(),
780780
);
781-
return genSeedResponse.toObject();
781+
return genSeedResponse.getCipherSeedMnemonicList();
782782
}
783783

784784
public initWallet = async (walletPassword: string, seedMnemonic: string[]): Promise<lndrpc.InitWalletResponse.AsObject> => {

Diff for: lib/nodekey/NodeKey.ts

+27-11
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,13 @@ import { createCipheriv, createDecipheriv, createHash } from 'crypto';
88
* and can sign messages to prove their veracity.
99
*/
1010
class NodeKey {
11-
/**
12-
* The public key in hex string format.
13-
*/
14-
public readonly pubKey: string;
15-
1611
private static ENCRYPTION_IV_LENGTH = 16;
1712

18-
constructor(public readonly privKey: Buffer) {
19-
const pubKey = secp256k1.publicKeyCreate(privKey);
20-
this.pubKey = pubKey.toString('hex');
21-
}
13+
/**
14+
* @param privKey The 32 byte private key
15+
* @param pubKey The public key in hex string format.
16+
*/
17+
constructor(public readonly privKey: Buffer, public readonly pubKey: string) { }
2218

2319
/**
2420
* Generates a random NodeKey.
@@ -29,7 +25,27 @@ class NodeKey {
2925
privKey = await randomBytes(32);
3026
} while (!secp256k1.privateKeyVerify(privKey));
3127

32-
return new NodeKey(privKey);
28+
return NodeKey.fromBytes(privKey);
29+
}
30+
31+
/**
32+
* Converts a buffer of bytes to a NodeKey. Uses the first 32 bytes from the buffer to generate
33+
* the private key. If the buffer has fewer than 32 bytes, the buffer is right-padded with zeros.
34+
*/
35+
public static fromBytes = (bytes: Buffer): NodeKey => {
36+
let privKey: Buffer;
37+
if (bytes.byteLength === 32) {
38+
privKey = bytes;
39+
} else if (bytes.byteLength < 32) {
40+
privKey = Buffer.concat([bytes, Buffer.alloc(32 - bytes.byteLength)]);
41+
} else {
42+
privKey = bytes.slice(0, 32);
43+
}
44+
45+
const pubKeyBytes = secp256k1.publicKeyCreate(privKey);
46+
const pubKey = pubKeyBytes.toString('hex');
47+
48+
return new NodeKey(privKey, pubKey);
3349
}
3450

3551
private static getCipherKey = (password: string) => {
@@ -58,7 +74,7 @@ class NodeKey {
5874
privKey = fileBuffer;
5975
}
6076
if (secp256k1.privateKeyVerify(privKey)) {
61-
return new NodeKey(privKey);
77+
return NodeKey.fromBytes(privKey);
6278
} else {
6379
throw new Error(`${path} does not contain a valid ECDSA private key`);
6480
}

Diff for: lib/service/InitService.ts

+9-20
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { EventEmitter } from 'events';
22
import NodeKey from '../nodekey/NodeKey';
33
import swapErrors from '../swaps/errors';
44
import SwapClientManager from '../swaps/SwapClientManager';
5+
import { decipher } from '../utils/seedutil';
56
import errors from './errors';
6-
import assert = require('assert');
7-
import { encipher } from '../utils/seedutil';
87

98
interface InitService {
109
once(event: 'nodekey', listener: (nodeKey: NodeKey) => void): this;
@@ -27,18 +26,13 @@ class InitService extends EventEmitter {
2726
await this.prepareCall();
2827

2928
try {
30-
const seed = await this.swapClientManager.genSeed();
29+
const seedMnemonic = await this.swapClientManager.genSeed();
3130

32-
const seedBytes = typeof seed.encipheredSeed === 'string' ?
33-
Buffer.from(seed.encipheredSeed, 'base64') :
34-
Buffer.from(seed.encipheredSeed);
35-
assert.equal(seedBytes.length, 33);
36-
37-
// the seed is 33 bytes, the first byte of which is the version
38-
// so we use the remaining 32 bytes to generate our private key
31+
// we use the deciphered seed (without the salt and extra fields that make up the enciphered seed)
32+
// to generate an xud nodekey from the same seed used for wallets
3933
// TODO: use seedutil tool to derive a child private key from deciphered seed key?
40-
const privKey = Buffer.from(seedBytes.slice(1));
41-
const nodeKey = new NodeKey(privKey);
34+
const decipheredSeed = await decipher(seedMnemonic);
35+
const nodeKey = NodeKey.fromBytes(decipheredSeed);
4236

4337
// use this seed to init any lnd wallets that are uninitialized
4438
const initWalletResult = await this.swapClientManager.initWallets(password, seed.cipherSeedMnemonicList);
@@ -50,7 +44,7 @@ class InitService extends EventEmitter {
5044
return {
5145
initializedLndWallets,
5246
initializedRaiden,
53-
mnemonic: seed ? seed.cipherSeedMnemonicList : undefined,
47+
mnemonic: seedMnemonic,
5448
};
5549
} finally {
5650
this.pendingCall = false;
@@ -85,13 +79,8 @@ class InitService extends EventEmitter {
8579
await this.prepareCall();
8680

8781
try {
88-
const seedBytes = await encipher(seedMnemonicList);
89-
90-
// the seed is 33 bytes, the first byte of which is the version
91-
// so we use the remaining 32 bytes to generate our private key
92-
// TODO: use seedutil tool to derive a child private key from deciphered seed key?
93-
const privKey = Buffer.from(seedBytes.slice(1));
94-
const nodeKey = new NodeKey(privKey);
82+
const decipheredSeed = await decipher(seedMnemonicList);
83+
const nodeKey = NodeKey.fromBytes(decipheredSeed);
9584

9685
// use this seed to restore any lnd wallets that are uninitialized
9786
const initWalletResult = await this.swapClientManager.initWallets(password, seedMnemonicList);

Diff for: lib/utils/seedutil.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,15 @@ async function encipher(mnemonic: string[]) {
3838
return Buffer.from(encipheredSeed, 'hex');
3939
}
4040

41-
export { keystore, encipher };
41+
async function decipher(mnemonic: string[]) {
42+
const { stdout, stderr } = await exec(`./seedutil/seedutil decipher ${mnemonic.join(' ')}`);
43+
44+
if (stderr) {
45+
throw new Error(stderr);
46+
}
47+
48+
const decipheredSeed = stdout.trim();
49+
return Buffer.from(decipheredSeed, 'hex');
50+
}
51+
52+
export { keystore, encipher, decipher };

Diff for: seedutil/SeedUtil.spec.ts

+30
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const ERRORS = {
4646
MISSING_ENCRYPTION_PASSWORD: 'expecting encryption password',
4747
INVALID_AEZEED: 'invalid aezeed',
4848
KEYSTORE_FILE_ALREADY_EXISTS: 'account already exists',
49+
INVALID_PASSPHRASE: 'invalid passphrase',
4950
};
5051

5152
const PASSWORD = 'wasspord';
@@ -102,6 +103,35 @@ describe('SeedUtil encipher', () => {
102103
});
103104
});
104105

106+
describe('SeedUtil decipher', () => {
107+
test('it errors with no arguments', async () => {
108+
await expect(executeCommand('./seedutil/seedutil decipher'))
109+
.rejects.toThrow(ERRORS.INVALID_ARGS_LENGTH);
110+
});
111+
112+
test('it errors with 23 words', async () => {
113+
const cmd = `./seedutil/seedutil decipher ${VALID_SEED.seedWords.slice(0, 23).join(' ')}`;
114+
await expect(executeCommand(cmd))
115+
.rejects.toThrow(ERRORS.INVALID_ARGS_LENGTH);
116+
});
117+
118+
test('it errors with 24 words and invalid aezeed password', async () => {
119+
const cmd = `./seedutil/seedutil decipher ${VALID_SEED.seedWords.join(' ')}`;
120+
await expect(executeCommand(cmd))
121+
.rejects.toThrow(ERRORS.INVALID_PASSPHRASE);
122+
});
123+
124+
test('it succeeds with 24 words, valid aezeed password', async () => {
125+
const cmd = `./seedutil/seedutil decipher -aezeedpass=${VALID_SEED.seedPassword} ${VALID_SEED.seedWords.join(' ')}`;
126+
await expect(executeCommand(cmd)).resolves.toMatchSnapshot();
127+
});
128+
129+
test('it succeeds with 24 words, no aezeed password', async () => {
130+
const cmd = `./seedutil/seedutil decipher ${VALID_SEED_NO_PASS.seedWords.join(' ')}`;
131+
await expect(executeCommand(cmd)).resolves.toMatchSnapshot();
132+
});
133+
});
134+
105135
describe('SeedUtil keystore', () => {
106136
beforeEach(async () => {
107137
await deleteDir(DEFAULT_KEYSTORE_PATH);

Diff for: seedutil/__snapshots__/SeedUtil.spec.ts.snap

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`SeedUtil decipher it succeeds with 24 words, no aezeed password 1`] = `
4+
"000f4b8aab5f566c2dc3be2675a537fedcb9ad
5+
"
6+
`;
7+
8+
exports[`SeedUtil decipher it succeeds with 24 words, valid aezeed password 1`] = `
9+
"000ecd52fdfc072182654f163f5f0f9a621d72
10+
"
11+
`;
12+
313
exports[`SeedUtil encipher it succeeds with 24 words, no aezeed password 1`] = `
414
"00738860374692022c462027a35aaaef3c3289aa0a057e2600000000002cad2e2b
515
"

Diff for: seedutil/main.go

+28-9
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,20 @@ var (
2626
defaultKeyStorePath = filepath.Join(filepath.Dir(os.Args[0]))
2727
)
2828

29-
func parseSeed(args []string, aezeedPassphrase *string) *aezeed.CipherSeed {
30-
if len(args) < aezeed.NummnemonicWords {
29+
func parseMnemonic(words []string) aezeed.Mnemonic{
30+
if len(words) < aezeed.NummnemonicWords {
3131
fmt.Fprintf(os.Stderr, "\nerror: expecting %v-word mnemonic seed separated by spaces\n", aezeed.NummnemonicWords)
3232
os.Exit(1)
3333
}
3434

3535
// parse seed from args
3636
var mnemonic aezeed.Mnemonic
37-
copy(mnemonic[:], args[0:24])
37+
copy(mnemonic[:], words[0:24])
3838

39+
return mnemonic
40+
}
41+
42+
func mnemonicToCipherSeed(mnemonic aezeed.Mnemonic, aezeedPassphrase *string) *aezeed.CipherSeed {
3943
// map back to cipher
4044
aezeedPassphraseBytes := []byte(*aezeedPassphrase)
4145
cipherSeed, err := mnemonic.ToCipherSeed(aezeedPassphraseBytes)
@@ -50,9 +54,10 @@ func parseSeed(args []string, aezeedPassphrase *string) *aezeed.CipherSeed {
5054
func main() {
5155
keystoreCommand := flag.NewFlagSet("keystore", flag.ExitOnError)
5256
encipherCommand := flag.NewFlagSet("encipher", flag.ExitOnError)
57+
mnemonicCommand := flag.NewFlagSet("mnemonic", flag.ExitOnError)
5358

5459
if len(os.Args) < 2 {
55-
fmt.Println("keystore or encipher subcommand is required")
60+
fmt.Println("subcommand is required")
5661
os.Exit(1)
5762
}
5863

@@ -65,7 +70,8 @@ func main() {
6570
keystoreCommand.Parse(os.Args[2:])
6671
args = keystoreCommand.Args()
6772

68-
cipherSeed := parseSeed(args, aezeedPassphrase)
73+
mnemonic := parseMnemonic(args)
74+
cipherSeed := mnemonicToCipherSeed(mnemonic, aezeedPassphrase)
6975

7076
// derive 64-byte key from cipherSeed's 16 bytes of entropy
7177
hmac512 := hmac.New(sha512.New, masterKey)
@@ -105,11 +111,24 @@ func main() {
105111
encipherCommand.Parse(os.Args[2:])
106112
args = encipherCommand.Args()
107113

108-
cipherSeed := parseSeed(args, aezeedPassphrase)
114+
mnemonic := parseMnemonic(args)
115+
cipherSeed := mnemonicToCipherSeed(mnemonic, aezeedPassphrase)
116+
109117
encipheredSeed, _ := cipherSeed.Encipher([]byte(*aezeedPassphrase))
110-
enciphedSeedArr := make([]byte, 33)
111-
copy(enciphedSeedArr[:], encipheredSeed[:])
112-
fmt.Println(hex.EncodeToString(enciphedSeedArr))
118+
fmt.Println(hex.EncodeToString(encipheredSeed[:]))
119+
case "decipher":
120+
aezeedPassphrase := mnemonicCommand.String("aezeedpass", defaultAezeedPassphrase, "aezeed passphrase")
121+
mnemonicCommand.Parse(os.Args[2:])
122+
args = mnemonicCommand.Args()
123+
124+
mnemonic := parseMnemonic(args)
125+
decipheredSeed, err := mnemonic.Decipher([]byte(*aezeedPassphrase))
126+
if err != nil {
127+
fmt.Fprintln(os.Stderr, err)
128+
os.Exit(1)
129+
}
130+
131+
fmt.Println(hex.EncodeToString(decipheredSeed[:]))
113132
default:
114133
flag.PrintDefaults()
115134
os.Exit(1)

Diff for: test/jest/Backup.spec.ts

-2
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,6 @@ describe('Backup', () => {
8888
});
8989

9090
test('should write LND backups on new event', () => {
91-
expect(onListenerMock).toBeCalledTimes(2);
92-
9391
channelBackupCallback(backups.lnd.event);
9492

9593
expect(

Diff for: test/jest/NodeKey.spec.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import NodeKey from '../../lib/nodekey/NodeKey';
2+
import secp256k1 from 'secp256k1';
3+
import { getTempDir } from '../utils';
4+
5+
function validateNodeKey(nodeKey: NodeKey) {
6+
expect(nodeKey.pubKey).toHaveLength(66);
7+
expect(secp256k1.privateKeyVerify(nodeKey['privKey'])).toEqual(true);
8+
expect(secp256k1.publicKeyVerify(Buffer.from(nodeKey.pubKey, 'hex'))).toEqual(true);
9+
}
10+
11+
describe('NodeKey', () => {
12+
test('it should generate a valid node key', async () => {
13+
const nodeKey = await NodeKey['generate']();
14+
validateNodeKey(nodeKey);
15+
});
16+
17+
test('it should write a nodekey to disk and read it back without encryption', async () => {
18+
const nodeKey = await NodeKey['generate']();
19+
const path = NodeKey.getPath(getTempDir(true));
20+
await nodeKey.toFile(path);
21+
const nodeKeyFromDisk = await NodeKey.fromFile(path);
22+
expect(nodeKey.privKey.compare(nodeKeyFromDisk.privKey)).toEqual(0);
23+
});
24+
25+
test('it should write a nodekey to disk and read it back with encryption', async () => {
26+
const password = 'wasspord';
27+
const nodeKey = await NodeKey['generate']();
28+
const path = NodeKey.getPath(getTempDir(true));
29+
await nodeKey.toFile(path, password);
30+
const nodeKeyFromDisk = await NodeKey.fromFile(path, password);
31+
expect(nodeKey.privKey.compare(nodeKeyFromDisk.privKey)).toEqual(0);
32+
});
33+
34+
test('it should write a nodekey to disk with encryption and fail reading it with the wrong password', async () => {
35+
const password = 'wasspord';
36+
const nodeKey = await NodeKey['generate']();
37+
const path = NodeKey.getPath(getTempDir(true));
38+
await nodeKey.toFile(path, password);
39+
await expect(NodeKey.fromFile(path, 'wrongpassword')).rejects.toThrow();
40+
});
41+
42+
test('it should create a valid nodekey from a 32 byte buffer', async () => {
43+
const nodeKey = NodeKey.fromBytes(Buffer.allocUnsafe(32));
44+
validateNodeKey(nodeKey);
45+
});
46+
47+
test('it should create a valid nodekey from a greater than 32 byte buffer', async () => {
48+
const nodeKey = NodeKey.fromBytes(Buffer.allocUnsafe(42));
49+
validateNodeKey(nodeKey);
50+
});
51+
52+
test('it should create a valid nodekey from a lesser than 32 byte buffer', async () => {
53+
const nodeKey = NodeKey.fromBytes(Buffer.allocUnsafe(22));
54+
validateNodeKey(nodeKey);
55+
});
56+
});

Diff for: test/unit/NodeKey.spec.ts

-38
This file was deleted.

0 commit comments

Comments
 (0)