Skip to content

Commit

Permalink
feat: change master password (#2007)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
sangaman authored Nov 30, 2020
1 parent 22c474a commit 7ecdd7e
Show file tree
Hide file tree
Showing 32 changed files with 1,375 additions and 481 deletions.
29 changes: 29 additions & 0 deletions docs/api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions lib/Xud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,16 @@ class Xud extends EventEmitter {
const nodeKeyPath = NodeKey.getPath(this.config.xudir, this.config.instanceid);
const nodeKeyExists = await fs.access(nodeKeyPath).then(() => true).catch(() => false);

this.swapClientManager = new SwapClientManager(this.config, loggers, this.unitConverter);
await this.swapClientManager.init(this.db.models);
this.swapClientManager = new SwapClientManager(this.config, loggers, this.unitConverter, this.db.models);
await this.swapClientManager.init();

let nodeKey: NodeKey | undefined;
if (this.config.noencrypt) {
if (nodeKeyExists) {
nodeKey = await NodeKey.fromFile(nodeKeyPath);
} else {
nodeKey = await NodeKey.generate();
await nodeKey.toFile(nodeKeyPath);
nodeKey = await NodeKey.generate(nodeKeyPath);
await nodeKey.toFile();
}

// we need to initialize connext every time xud starts, even in noencrypt mode
Expand Down
14 changes: 7 additions & 7 deletions lib/cli/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ export const callback = (argv: Arguments, formatOutput?: Function, displayJson?:
}
} else {
const responseObj = response.toObject();
if (Object.keys(responseObj).length === 0) {
console.log('success');
} else {
if (!argv.json && formatOutput) {
formatOutput(responseObj, argv);
if (argv.json || !formatOutput) {
if (Object.keys(responseObj).length === 0) {
console.log('success');
} else {
displayJson
? displayJson(responseObj, argv)
: console.log(JSON.stringify(responseObj, undefined, 2));
? displayJson(responseObj, argv)
: console.log(JSON.stringify(responseObj, undefined, 2));
}
} else {
formatOutput(responseObj, argv);
}
}
};
Expand Down
51 changes: 51 additions & 0 deletions lib/cli/commands/changepass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import readline from 'readline';
import { Arguments } from 'yargs';
import { ChangePasswordRequest } from '../../proto/xudrpc_pb';
import { callback, loadXudClient } from '../command';

export const command = 'changepass';

export const describe = 'change the password for an existing xud instance';

export const builder = {};

const formatOutput = () => {
console.log('The master xud password was succesfully changed.');
console.log('Passwords for lnd wallets will be changed the next time xud is restarted and unlocked.');
};

export const handler = (argv: Arguments<any>) => {
const rl = readline.createInterface({
input: process.stdin,
terminal: true,
});

console.log(`\
You are changing the master password for xud and underlying wallets.\
`);
process.stdout.write('Enter old password: ');
rl.question('', (oldPassword) => {
process.stdout.write('\nEnter new password: ');
rl.question('', (password1) => {
process.stdout.write('\nRe-enter password: ');
rl.question('', async (password2) => {
process.stdout.write('\n\n');
rl.close();
if (password1 === password2) {
const request = new ChangePasswordRequest();
request.setNewPassword(password1);
request.setOldPassword(oldPassword);

const client = await loadXudClient(argv);
// wait up to 3 seconds for rpc server to listen before call in case xud was just started
client.waitForReady(Date.now() + 3000, () => {
client.changePassword(request, callback(argv, formatOutput));
});
} else {
process.exitCode = 1;
console.error('Passwords do not match, please try again');
}
});
});
});
};
61 changes: 59 additions & 2 deletions lib/db/DB.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { derivePairId } from '../utils/utils';
import assert from 'assert';
import { promises as fs } from 'fs';
import { ModelCtor, Sequelize } from 'sequelize';
import { XuNetwork } from '../constants/enums';
import { defaultCurrencies, defaultNodes, defaultPairs } from '../db/seeds';
import Logger from '../Logger';
import { derivePairId } from '../utils/utils';
import migrations from './migrations';
import * as Models from './models';
import * as db from './types';

Expand All @@ -14,6 +17,7 @@ type Models = {
ReputationEvent: ModelCtor<db.ReputationEventInstance>;
Order: ModelCtor<db.OrderInstance>;
Trade: ModelCtor<db.TradeInstance>;
Password: ModelCtor<db.PasswordInstance>;
};

function loadModels(sequelize: Sequelize): Models {
Expand All @@ -25,6 +29,7 @@ function loadModels(sequelize: Sequelize): Models {
ReputationEvent: Models.ReputationEvent(sequelize),
SwapDeal: Models.SwapDeal(sequelize),
Trade: Models.Trade(sequelize),
Password: Models.Password(sequelize),
};

models.Currency.hasMany(models.Pair, {
Expand Down Expand Up @@ -118,6 +123,8 @@ class DB {
public sequelize: Sequelize;
public models: Models;

private static VERSION = 1;

/**
* @param storage the file path for the sqlite database file, if ':memory:' or not specified the db is stored in memory
*/
Expand All @@ -136,19 +143,49 @@ class DB {
* @param initDb whether to intialize a new database with default values if no database exists
*/
public init = async (network = XuNetwork.SimNet, initDb = false): Promise<void> => {
const isNewDb = await this.isNewDb();

try {
await this.sequelize.authenticate();
this.logger.info(`connected to database ${this.storage ? this.storage : 'in memory'}`);
} catch (err) {
this.logger.error('unable to connect to the database', err);
throw err;
}
const { Node, Currency, Pair, ReputationEvent, SwapDeal, Order, Trade } = this.models;

if (isNewDb) {
await this.sequelize.query(`PRAGMA user_version=${DB.VERSION};`);
}

// version is useful for tracking migrations & upgrades to the xud database when
// the database schema is modified or restructured
let version: number;
const userVersionPragma = (await this.sequelize.query('PRAGMA user_version;'));
assert(Array.isArray(userVersionPragma) && Array.isArray(userVersionPragma[0]));
const userVersion = userVersionPragma[0][0].user_version;
assert(typeof userVersion === 'number');
version = userVersion;
this.logger.trace(`db version is ${version}`);

if (version <= DB.VERSION) {
// if our db is not the latest version, we call each migration procedure necessary
// to bring us from our current version up to the latest version.
for (let n = version; n < DB.VERSION; n += 1) {
this.logger.info(`migrating db from version ${n} to version ${n + 1}`);
await migrations[n](this.sequelize);
await this.sequelize.query(`PRAGMA user_version=${n + 1};`);
this.logger.info(`migration to version ${n + 1} complete`);
}
}

const { Node, Currency, Pair, ReputationEvent, SwapDeal, Order, Trade, Password } = this.models;
// sync schemas with the database in phases, according to FKs dependencies
await Promise.all([
Node.sync(),
Currency.sync(),
Password.sync(),
]);

// Pair is dependent on Currency, ReputationEvent is dependent on Node
await Promise.all([
Pair.sync(),
Expand Down Expand Up @@ -199,6 +236,26 @@ class DB {
}
}

/**
* Checks whether the database is new, in other words whether we are not
* loading a preexisting database from disk.
*/
private isNewDb = async () => {
if (this.storage && this.storage !== ':memory:') {
// check if database file exists
try {
await fs.access(this.storage);
return false;
} catch (err) {
if (err.code !== 'ENOENT') {
// we ignore errors due to file not existing, otherwise throw
throw err;
}
}
}
return true;
}

public close = () => {
return this.sequelize.close();
}
Expand Down
22 changes: 22 additions & 0 deletions lib/db/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Sequelize, { DataTypes } from 'sequelize';

/**
* An ordered array of functions that will migrate the database from one
* version to the next. The 1st element (index 0) will migrate from version
* 0 to 1, the 2nd element will migrate from version 1 to 2, and so on...
* Each migration must be called in order and allowed to complete before
* calling the next.
*/
const migrations: ((sequelize: Sequelize.Sequelize) => Promise<void>)[] = [];

migrations[0] = async (sequelize: Sequelize.Sequelize) => {
await sequelize.getQueryInterface().createTable('passwords', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
encryptedPassword: { type: DataTypes.STRING, allowNull: false },
currency: { type: DataTypes.STRING(5), allowNull: true },
swapClient: { type: DataTypes.TINYINT, allowNull: false },
createdAt: { type: DataTypes.BIGINT, allowNull: false },
});
};

export default migrations;
21 changes: 21 additions & 0 deletions lib/db/models/Password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DataTypes, ModelAttributes, ModelOptions, Sequelize } from 'sequelize';
import { PasswordInstance } from '../types';

export default function Password(sequelize: Sequelize) {
const attributes: ModelAttributes<PasswordInstance> = {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
encryptedPassword: { type: DataTypes.STRING, allowNull: false },
currency: { type: DataTypes.STRING(5), allowNull: true },
swapClient: { type: DataTypes.TINYINT, allowNull: false },
createdAt: { type: DataTypes.BIGINT, allowNull: false },
};

const options: ModelOptions = {
tableName: 'passwords',
timestamps: true,
updatedAt: false,
};

const Password = sequelize.define<PasswordInstance>('Password', attributes, options);
return Password;
}
1 change: 1 addition & 0 deletions lib/db/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { default as Pair } from './Pair';
export { default as ReputationEvent } from './ReputationEvent';
export { default as SwapDeal } from './SwapDeal';
export { default as Trade } from './Trade';
export { default as Password } from './Password';
16 changes: 15 additions & 1 deletion lib/db/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BelongsToGetAssociationMixin, Model } from 'sequelize';
import { ReputationEvent } from '../constants/enums';
import { ReputationEvent, SwapClientType } from '../constants/enums';
import { Currency, Order, Pair } from '../orderbook/types';
import { Address, NodeConnectionInfo } from '../p2p/types';
import { SwapDeal } from '../swaps/types';
Expand Down Expand Up @@ -110,3 +110,17 @@ export type ReputationEventAttributes = ReputationEventCreationAttributes & {
};

export interface ReputationEventInstance extends Model<ReputationEventAttributes, ReputationEventCreationAttributes>, ReputationEventAttributes {}

/* Passwords */
export type PasswordCreationAttributes = {
encryptedPassword: string;
currency?: string;
swapClient: SwapClientType;
};

export type PasswordAttributes = PasswordCreationAttributes & {
createdAt: number;
id: number;
};

export interface PasswordInstance extends Model<PasswordAttributes, PasswordCreationAttributes>, PasswordAttributes {}
14 changes: 14 additions & 0 deletions lib/grpc/GrpcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,20 @@ class GrpcService {
}
}

public changePassword: grpc.handleUnaryCall<xudrpc.ChangePasswordRequest, xudrpc.ChangePasswordResponse> = async (call, callback) => {
if (!this.isReady(this.service, callback)) {
return;
}
try {
await this.service.changePassword(call.request.toObject());

const response = new xudrpc.ChangePasswordResponse();
callback(null, response);
} catch (err) {
callback(getGrpcError(err), null);
}
}

public shutdown: grpc.handleUnaryCall<xudrpc.ShutdownRequest, xudrpc.ShutdownResponse> = (_, callback) => {
if (!this.isReady(this.service, callback)) {
return;
Expand Down
1 change: 1 addition & 0 deletions lib/grpc/getGrpcError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const getGrpcError = (err: any) => {
break;
case serviceErrorCodes.NODE_ALREADY_EXISTS:
case serviceErrorCodes.NODE_DOES_NOT_EXIST:
case serviceErrorCodes.NO_ENCRYPT_MODE_ENABLED:
code = status.UNIMPLEMENTED;
break;
case p2pErrorCodes.POOL_CLOSED:
Expand Down
Loading

0 comments on commit 7ecdd7e

Please sign in to comment.