Skip to content

Commit 7ecdd7e

Browse files
authored
feat: change master password (#2007)
* feat(db): migration framework This introduces a framework for tracking a database's version and migrating from one version to the next when the database schema needs to be modified. The migrations consist of an array of methods that each are responsible for upgrading from a particular version, and they are run in sequence when we detect that the current database version is lower than the latest version. * feat(db): migration to db v1 * refactor: cryptoUtils * test: cryptoUtils * feat: change master password This adds the ability to change the master password of an existing xud node. Changing the password re-encrypts the node key on disk and queues password changes for all lnd wallets. Lnd does not currently offer the ability to change the password of an unlocked, running instance. Instead lnd can only change its password right after being started while it is still locked. Xud therefore saves the old password for each lnd wallet to the xud database and encrypts the old password using the new passwords. On subsequent unlocks of xud, when we go to unlock lnd wallets we first check whether we have any old passwords in the database corresponding to any lnd wallets. If we do, we decrypt the old password and change the password for lnd, which in turn will unlock lnd. Closes #1981.
1 parent 22c474a commit 7ecdd7e

32 files changed

+1375
-481
lines changed

Diff for: docs/api.md

+29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: lib/Xud.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,16 @@ class Xud extends EventEmitter {
119119
const nodeKeyPath = NodeKey.getPath(this.config.xudir, this.config.instanceid);
120120
const nodeKeyExists = await fs.access(nodeKeyPath).then(() => true).catch(() => false);
121121

122-
this.swapClientManager = new SwapClientManager(this.config, loggers, this.unitConverter);
123-
await this.swapClientManager.init(this.db.models);
122+
this.swapClientManager = new SwapClientManager(this.config, loggers, this.unitConverter, this.db.models);
123+
await this.swapClientManager.init();
124124

125125
let nodeKey: NodeKey | undefined;
126126
if (this.config.noencrypt) {
127127
if (nodeKeyExists) {
128128
nodeKey = await NodeKey.fromFile(nodeKeyPath);
129129
} else {
130-
nodeKey = await NodeKey.generate();
131-
await nodeKey.toFile(nodeKeyPath);
130+
nodeKey = await NodeKey.generate(nodeKeyPath);
131+
await nodeKey.toFile();
132132
}
133133

134134
// we need to initialize connext every time xud starts, even in noencrypt mode

Diff for: lib/cli/command.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,16 @@ export const callback = (argv: Arguments, formatOutput?: Function, displayJson?:
9999
}
100100
} else {
101101
const responseObj = response.toObject();
102-
if (Object.keys(responseObj).length === 0) {
103-
console.log('success');
104-
} else {
105-
if (!argv.json && formatOutput) {
106-
formatOutput(responseObj, argv);
102+
if (argv.json || !formatOutput) {
103+
if (Object.keys(responseObj).length === 0) {
104+
console.log('success');
107105
} else {
108106
displayJson
109-
? displayJson(responseObj, argv)
110-
: console.log(JSON.stringify(responseObj, undefined, 2));
107+
? displayJson(responseObj, argv)
108+
: console.log(JSON.stringify(responseObj, undefined, 2));
111109
}
110+
} else {
111+
formatOutput(responseObj, argv);
112112
}
113113
}
114114
};

Diff for: lib/cli/commands/changepass.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import readline from 'readline';
2+
import { Arguments } from 'yargs';
3+
import { ChangePasswordRequest } from '../../proto/xudrpc_pb';
4+
import { callback, loadXudClient } from '../command';
5+
6+
export const command = 'changepass';
7+
8+
export const describe = 'change the password for an existing xud instance';
9+
10+
export const builder = {};
11+
12+
const formatOutput = () => {
13+
console.log('The master xud password was succesfully changed.');
14+
console.log('Passwords for lnd wallets will be changed the next time xud is restarted and unlocked.');
15+
};
16+
17+
export const handler = (argv: Arguments<any>) => {
18+
const rl = readline.createInterface({
19+
input: process.stdin,
20+
terminal: true,
21+
});
22+
23+
console.log(`\
24+
You are changing the master password for xud and underlying wallets.\
25+
`);
26+
process.stdout.write('Enter old password: ');
27+
rl.question('', (oldPassword) => {
28+
process.stdout.write('\nEnter new password: ');
29+
rl.question('', (password1) => {
30+
process.stdout.write('\nRe-enter password: ');
31+
rl.question('', async (password2) => {
32+
process.stdout.write('\n\n');
33+
rl.close();
34+
if (password1 === password2) {
35+
const request = new ChangePasswordRequest();
36+
request.setNewPassword(password1);
37+
request.setOldPassword(oldPassword);
38+
39+
const client = await loadXudClient(argv);
40+
// wait up to 3 seconds for rpc server to listen before call in case xud was just started
41+
client.waitForReady(Date.now() + 3000, () => {
42+
client.changePassword(request, callback(argv, formatOutput));
43+
});
44+
} else {
45+
process.exitCode = 1;
46+
console.error('Passwords do not match, please try again');
47+
}
48+
});
49+
});
50+
});
51+
};

Diff for: lib/db/DB.ts

+59-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { derivePairId } from '../utils/utils';
1+
import assert from 'assert';
2+
import { promises as fs } from 'fs';
23
import { ModelCtor, Sequelize } from 'sequelize';
34
import { XuNetwork } from '../constants/enums';
45
import { defaultCurrencies, defaultNodes, defaultPairs } from '../db/seeds';
56
import Logger from '../Logger';
7+
import { derivePairId } from '../utils/utils';
8+
import migrations from './migrations';
69
import * as Models from './models';
710
import * as db from './types';
811

@@ -14,6 +17,7 @@ type Models = {
1417
ReputationEvent: ModelCtor<db.ReputationEventInstance>;
1518
Order: ModelCtor<db.OrderInstance>;
1619
Trade: ModelCtor<db.TradeInstance>;
20+
Password: ModelCtor<db.PasswordInstance>;
1721
};
1822

1923
function loadModels(sequelize: Sequelize): Models {
@@ -25,6 +29,7 @@ function loadModels(sequelize: Sequelize): Models {
2529
ReputationEvent: Models.ReputationEvent(sequelize),
2630
SwapDeal: Models.SwapDeal(sequelize),
2731
Trade: Models.Trade(sequelize),
32+
Password: Models.Password(sequelize),
2833
};
2934

3035
models.Currency.hasMany(models.Pair, {
@@ -118,6 +123,8 @@ class DB {
118123
public sequelize: Sequelize;
119124
public models: Models;
120125

126+
private static VERSION = 1;
127+
121128
/**
122129
* @param storage the file path for the sqlite database file, if ':memory:' or not specified the db is stored in memory
123130
*/
@@ -136,19 +143,49 @@ class DB {
136143
* @param initDb whether to intialize a new database with default values if no database exists
137144
*/
138145
public init = async (network = XuNetwork.SimNet, initDb = false): Promise<void> => {
146+
const isNewDb = await this.isNewDb();
147+
139148
try {
140149
await this.sequelize.authenticate();
141150
this.logger.info(`connected to database ${this.storage ? this.storage : 'in memory'}`);
142151
} catch (err) {
143152
this.logger.error('unable to connect to the database', err);
144153
throw err;
145154
}
146-
const { Node, Currency, Pair, ReputationEvent, SwapDeal, Order, Trade } = this.models;
155+
156+
if (isNewDb) {
157+
await this.sequelize.query(`PRAGMA user_version=${DB.VERSION};`);
158+
}
159+
160+
// version is useful for tracking migrations & upgrades to the xud database when
161+
// the database schema is modified or restructured
162+
let version: number;
163+
const userVersionPragma = (await this.sequelize.query('PRAGMA user_version;'));
164+
assert(Array.isArray(userVersionPragma) && Array.isArray(userVersionPragma[0]));
165+
const userVersion = userVersionPragma[0][0].user_version;
166+
assert(typeof userVersion === 'number');
167+
version = userVersion;
168+
this.logger.trace(`db version is ${version}`);
169+
170+
if (version <= DB.VERSION) {
171+
// if our db is not the latest version, we call each migration procedure necessary
172+
// to bring us from our current version up to the latest version.
173+
for (let n = version; n < DB.VERSION; n += 1) {
174+
this.logger.info(`migrating db from version ${n} to version ${n + 1}`);
175+
await migrations[n](this.sequelize);
176+
await this.sequelize.query(`PRAGMA user_version=${n + 1};`);
177+
this.logger.info(`migration to version ${n + 1} complete`);
178+
}
179+
}
180+
181+
const { Node, Currency, Pair, ReputationEvent, SwapDeal, Order, Trade, Password } = this.models;
147182
// sync schemas with the database in phases, according to FKs dependencies
148183
await Promise.all([
149184
Node.sync(),
150185
Currency.sync(),
186+
Password.sync(),
151187
]);
188+
152189
// Pair is dependent on Currency, ReputationEvent is dependent on Node
153190
await Promise.all([
154191
Pair.sync(),
@@ -199,6 +236,26 @@ class DB {
199236
}
200237
}
201238

239+
/**
240+
* Checks whether the database is new, in other words whether we are not
241+
* loading a preexisting database from disk.
242+
*/
243+
private isNewDb = async () => {
244+
if (this.storage && this.storage !== ':memory:') {
245+
// check if database file exists
246+
try {
247+
await fs.access(this.storage);
248+
return false;
249+
} catch (err) {
250+
if (err.code !== 'ENOENT') {
251+
// we ignore errors due to file not existing, otherwise throw
252+
throw err;
253+
}
254+
}
255+
}
256+
return true;
257+
}
258+
202259
public close = () => {
203260
return this.sequelize.close();
204261
}

Diff for: lib/db/migrations.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Sequelize, { DataTypes } from 'sequelize';
2+
3+
/**
4+
* An ordered array of functions that will migrate the database from one
5+
* version to the next. The 1st element (index 0) will migrate from version
6+
* 0 to 1, the 2nd element will migrate from version 1 to 2, and so on...
7+
* Each migration must be called in order and allowed to complete before
8+
* calling the next.
9+
*/
10+
const migrations: ((sequelize: Sequelize.Sequelize) => Promise<void>)[] = [];
11+
12+
migrations[0] = async (sequelize: Sequelize.Sequelize) => {
13+
await sequelize.getQueryInterface().createTable('passwords', {
14+
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
15+
encryptedPassword: { type: DataTypes.STRING, allowNull: false },
16+
currency: { type: DataTypes.STRING(5), allowNull: true },
17+
swapClient: { type: DataTypes.TINYINT, allowNull: false },
18+
createdAt: { type: DataTypes.BIGINT, allowNull: false },
19+
});
20+
};
21+
22+
export default migrations;

Diff for: lib/db/models/Password.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { DataTypes, ModelAttributes, ModelOptions, Sequelize } from 'sequelize';
2+
import { PasswordInstance } from '../types';
3+
4+
export default function Password(sequelize: Sequelize) {
5+
const attributes: ModelAttributes<PasswordInstance> = {
6+
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
7+
encryptedPassword: { type: DataTypes.STRING, allowNull: false },
8+
currency: { type: DataTypes.STRING(5), allowNull: true },
9+
swapClient: { type: DataTypes.TINYINT, allowNull: false },
10+
createdAt: { type: DataTypes.BIGINT, allowNull: false },
11+
};
12+
13+
const options: ModelOptions = {
14+
tableName: 'passwords',
15+
timestamps: true,
16+
updatedAt: false,
17+
};
18+
19+
const Password = sequelize.define<PasswordInstance>('Password', attributes, options);
20+
return Password;
21+
}

Diff for: lib/db/models/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { default as Pair } from './Pair';
55
export { default as ReputationEvent } from './ReputationEvent';
66
export { default as SwapDeal } from './SwapDeal';
77
export { default as Trade } from './Trade';
8+
export { default as Password } from './Password';

Diff for: lib/db/types.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BelongsToGetAssociationMixin, Model } from 'sequelize';
2-
import { ReputationEvent } from '../constants/enums';
2+
import { ReputationEvent, SwapClientType } from '../constants/enums';
33
import { Currency, Order, Pair } from '../orderbook/types';
44
import { Address, NodeConnectionInfo } from '../p2p/types';
55
import { SwapDeal } from '../swaps/types';
@@ -110,3 +110,17 @@ export type ReputationEventAttributes = ReputationEventCreationAttributes & {
110110
};
111111

112112
export interface ReputationEventInstance extends Model<ReputationEventAttributes, ReputationEventCreationAttributes>, ReputationEventAttributes {}
113+
114+
/* Passwords */
115+
export type PasswordCreationAttributes = {
116+
encryptedPassword: string;
117+
currency?: string;
118+
swapClient: SwapClientType;
119+
};
120+
121+
export type PasswordAttributes = PasswordCreationAttributes & {
122+
createdAt: number;
123+
id: number;
124+
};
125+
126+
export interface PasswordInstance extends Model<PasswordAttributes, PasswordCreationAttributes>, PasswordAttributes {}

Diff for: lib/grpc/GrpcService.ts

+14
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,20 @@ class GrpcService {
877877
}
878878
}
879879

880+
public changePassword: grpc.handleUnaryCall<xudrpc.ChangePasswordRequest, xudrpc.ChangePasswordResponse> = async (call, callback) => {
881+
if (!this.isReady(this.service, callback)) {
882+
return;
883+
}
884+
try {
885+
await this.service.changePassword(call.request.toObject());
886+
887+
const response = new xudrpc.ChangePasswordResponse();
888+
callback(null, response);
889+
} catch (err) {
890+
callback(getGrpcError(err), null);
891+
}
892+
}
893+
880894
public shutdown: grpc.handleUnaryCall<xudrpc.ShutdownRequest, xudrpc.ShutdownResponse> = (_, callback) => {
881895
if (!this.isReady(this.service, callback)) {
882896
return;

Diff for: lib/grpc/getGrpcError.ts

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const getGrpcError = (err: any) => {
6767
break;
6868
case serviceErrorCodes.NODE_ALREADY_EXISTS:
6969
case serviceErrorCodes.NODE_DOES_NOT_EXIST:
70+
case serviceErrorCodes.NO_ENCRYPT_MODE_ENABLED:
7071
code = status.UNIMPLEMENTED;
7172
break;
7273
case p2pErrorCodes.POOL_CLOSED:

0 commit comments

Comments
 (0)