Skip to content

Commit

Permalink
Merge pull request #462 from KeychainMDIP/bush/encrypt-wallet
Browse files Browse the repository at this point in the history
feat: Add optional encrypted JSON wallet
  • Loading branch information
Bushstar authored Dec 9, 2024
2 parents 6bf0e72 + 0f22b05 commit 96ac062
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/keymaster/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"./lib": "./src/keymaster-lib.js",
"./sdk": "./src/keymaster-sdk.js",
"./db/json": "./src/db-wallet-json.js",
"./db/json/enc": "./src/db-wallet-json-enc.js",
"./db/web": "./src/db-wallet-web.js"
},
"scripts": {
Expand Down
84 changes: 84 additions & 0 deletions packages/keymaster/src/db-wallet-json-enc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from 'fs';
import crypto from 'crypto';

const dataFolder = 'data';
const walletName = `${dataFolder}/wallet-enc.json`;

const algorithm = 'aes-256-cbc'; // Algorithm
const keyLength = 32; // 256 bit AES-256
const ivLength = 16; // 128-bit AES block size
const saltLength = 16; // 128-bit salt
const iterations = 200000; // PBKDF2 iterations
const digest = 'sha512'; // PBKDF2 hash function

let passphrase;

export function setPassphrase(pp) {
passphrase = pp;
}

export function saveWallet(wallet, overwrite = false) {
if (fs.existsSync(walletName) && !overwrite) {
return false;
}

if (!fs.existsSync(dataFolder)) {
fs.mkdirSync(dataFolder, { recursive: true });
}

if (!passphrase) {
throw new Error('KC_ENCRYPTED_PASSPHRASE not set');
}

const walletJson = JSON.stringify(wallet, null, 4);
const salt = crypto.randomBytes(saltLength);
const key = crypto.pbkdf2Sync(passphrase, salt, iterations, keyLength, digest);
const iv = crypto.randomBytes(ivLength);
const cipher = crypto.createCipheriv(algorithm, key, iv);

let encrypted = cipher.update(walletJson, 'utf8', 'base64');
encrypted += cipher.final('base64');

const encryptedData = {
salt: salt.toString('base64'),
iv: iv.toString('base64'),
data: encrypted
};

fs.writeFileSync(walletName, JSON.stringify(encryptedData, null, 4));

return true;
}

export function loadWallet() {
if (!fs.existsSync(walletName)) {
return null;
}

if (!passphrase) {
throw new Error('KC_ENCRYPTED_PASSPHRASE not set');
}

const encryptedJson = fs.readFileSync(walletName, 'utf8');
const encryptedData = JSON.parse(encryptedJson);

if (!encryptedData || !encryptedData.salt || !encryptedData.iv || !encryptedData.data) {
throw new Error('Wallet not encrypted');
}

const salt = Buffer.from(encryptedData.salt, 'base64');
const iv = Buffer.from(encryptedData.iv, 'base64');
const encryptedJSON = encryptedData.data;
const key = crypto.pbkdf2Sync(passphrase, salt, iterations, keyLength, digest);
const decipher = crypto.createDecipheriv(algorithm, key, iv);

let decrypted;
try {
decrypted = decipher.update(encryptedJSON, 'base64', 'utf8');
decrypted += decipher.final('utf8');
} catch (err) {
throw new Error('Incorrect passphrase.');
}

return JSON.parse(decrypted);
}
14 changes: 10 additions & 4 deletions packages/keymaster/src/db-wallet-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ export function saveWallet(wallet, overwrite = false) {
}

export function loadWallet() {
if (fs.existsSync(walletName)) {
const walletJson = fs.readFileSync(walletName);
return JSON.parse(walletJson);
if (!fs.existsSync(walletName)) {
return null;
}

return null;
const walletJson = fs.readFileSync(walletName);
const walletData = JSON.parse(walletJson);

if (walletData && walletData.salt && walletData.iv && walletData.data) {
throw new Error('Wallet encrypted but KC_ENCRYPTED_PASSPHRASE not set');
}

return walletData;
}
5 changes: 2 additions & 3 deletions packages/keymaster/src/keymaster-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ export async function start(options = {}) {
if (options.wallet) {
db = options.wallet;

if (!db.loadWallet) {
if (!db.loadWallet || !db.saveWallet) {
throw new InvalidParameterError('options.wallet');
}
}
else {
} else {
throw new InvalidParameterError('options.wallet');
}

Expand Down
3 changes: 3 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ KC_GATEKEEPER_REGISTRIES=hyperswarm,TBTC,TFTC
KC_GATEKEEPER_PORT=4224
KC_GATEKEEPER_GC_INTERVAL=60

# Wallet
KC_ENCRYPTED_WALLET=false

# CLI
KC_GATEKEEPER_URL=http://localhost:4224
# KC_KEYMASTER_URL=http://localhost:4226
Expand Down
43 changes: 40 additions & 3 deletions scripts/keychain-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as gatekeeper_sdk from '@mdip/gatekeeper/sdk';
import * as keymaster_lib from '@mdip/keymaster/lib';
import * as keymaster_sdk from '@mdip/keymaster/sdk';
import * as db_wallet from '@mdip/keymaster/db/json';
import * as db_wallet_enc from '@mdip/keymaster/db/json/enc';
import * as cipher from '@mdip/cipher/node';

dotenv.config();
Expand All @@ -14,6 +15,8 @@ let keymaster;
const gatekeeperURL = process.env.KC_GATEKEEPER_URL || 'http://localhost:4224';
const keymasterURL = process.env.KC_KEYMASTER_URL;

const keymasterPassphrase = process.env.KC_ENCRYPTED_PASSPHRASE;

const UPDATE_OK = "OK";
const UPDATE_FAILED = "Update failed";

Expand Down Expand Up @@ -741,7 +744,7 @@ program
program
.command('list-schemas')
.description('List schemas owned by current ID')
.action(async (file, name) => {
.action(async () => {
try {
const schemas = await keymaster.listSchemas();
console.log(JSON.stringify(schemas, null, 4));
Expand Down Expand Up @@ -935,6 +938,32 @@ program
}
});

program
.command('encrypt-wallet')
.description('Encrypt wallet')
.action(async () => {
try {
if (!keymasterPassphrase) {
console.error('KC_ENCRYPTED_PASSPHRASE not set');
return;
}

db_wallet_enc.setPassphrase(keymasterPassphrase);
const wallet = db_wallet.loadWallet();

if (wallet === null) {
await keymaster.newWallet();
} else {
const result = db_wallet_enc.saveWallet(wallet);
if (!result) {
console.error('Encrypted wallet file already exists');
}
}
} catch (error) {
console.error(error.message);
}
});

program
.command('perf-test [N]')
.description('Performance test to create N credentials')
Expand Down Expand Up @@ -973,6 +1002,14 @@ program
}
});

function getDBWallet() {
if (keymasterPassphrase) {
db_wallet_enc.setPassphrase(keymasterPassphrase);
return db_wallet_enc;
}
return db_wallet;
}

async function run() {
if (keymasterURL) {
keymaster = keymaster_sdk;
Expand All @@ -993,8 +1030,8 @@ async function run() {
});
await keymaster.start({
gatekeeper: gatekeeper_sdk,
wallet: db_wallet,
cipher
wallet: getDBWallet(),
cipher,
});
program.parse(process.argv);
await keymaster.stop();
Expand Down
122 changes: 120 additions & 2 deletions tests/keymaster.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import mockFs from 'mock-fs';
import fs from 'fs';
import canonicalize from 'canonicalize';

import * as keymaster from '@mdip/keymaster/lib';
import * as gatekeeper from '@mdip/gatekeeper/lib';
import * as cipher from '@mdip/cipher/node';
import * as db_json from '@mdip/gatekeeper/db/json';
import * as wallet from '@mdip/keymaster/db/json';
import * as wallet_enc from '@mdip/keymaster/db/json/enc';
import { copyJSON } from '@mdip/common/utils';
import { InvalidDIDError, ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors';

Expand Down Expand Up @@ -91,8 +93,10 @@ describe('start', () => {

describe('loadWallet', () => {

afterEach(() => {
afterEach(async () => {
mockFs.restore();
wallet_enc.setPassphrase(undefined);
await keymaster.start({ gatekeeper, wallet, cipher });
});

it('should create a wallet on first load', async () => {
Expand All @@ -115,12 +119,76 @@ describe('loadWallet', () => {

expect(wallet2).toStrictEqual(wallet1);
});

it('loading non-existing encrypted wallet returns null', async () => {
mockFs({});

const wallet = wallet_enc.loadWallet();
expect(wallet).toBe(null);
});

it('regular wallet should throw when loading encrypted wallet', async () => {
mockFs({});
const mockWallet = { salt: 1, iv: 1, data: 1 };

const ok = await keymaster.saveWallet(mockWallet);

try {
await keymaster.loadWallet();
throw new ExpectedExceptionError();
} catch (error) {
expect(ok).toBe(true);
expect(error.message).toBe('Wallet encrypted but KC_ENCRYPTED_PASSPHRASE not set');
}
});

it('wallet should throw when passphrase not set', async () => {
mockFs({});
const mockWallet = { mock: 1 };

await keymaster.start({ gatekeeper, wallet: wallet_enc, cipher });

wallet_enc.setPassphrase('passphrase');
const ok = await keymaster.saveWallet(mockWallet);
wallet_enc.setPassphrase(undefined);

try {
await keymaster.loadWallet();
throw new ExpectedExceptionError();
} catch (error) {
expect(ok).toBe(true);
expect(error.message).toBe('KC_ENCRYPTED_PASSPHRASE not set');
}
});

it('load with incorrect passphrase throws', async () => {
mockFs({});
const mockWallet = { mock: 0 };

await keymaster.start({ gatekeeper, wallet: wallet_enc, cipher });

wallet_enc.setPassphrase('passphrase');

const ok = await keymaster.saveWallet(mockWallet);
expect(ok).toBe(true);

wallet_enc.setPassphrase('incorrect');

try {
await keymaster.loadWallet();
throw new ExpectedExceptionError();
} catch (e) {
expect(e.message).toBe('Incorrect passphrase.');
}
});
});

describe('saveWallet', () => {

afterEach(() => {
afterEach(async () => {
mockFs.restore();
wallet_enc.setPassphrase(undefined);
await keymaster.start({ gatekeeper, wallet, cipher });
});

it('should save a wallet', async () => {
Expand Down Expand Up @@ -197,6 +265,56 @@ describe('saveWallet', () => {
expect(wallet).toStrictEqual(mockWallet);
}
});

it('should not overwrite an existing wallet if specified', async () => {
mockFs({});

await keymaster.start({ gatekeeper, wallet: wallet_enc, cipher });
wallet_enc.setPassphrase('passphrase');

const mockWallet1 = { mock: 1 };
const mockWallet2 = { mock: 2 };

await keymaster.saveWallet(mockWallet1);
const ok = await keymaster.saveWallet(mockWallet2, false);
const walletData = await keymaster.loadWallet();

expect(ok).toBe(false);
expect(walletData).toStrictEqual(mockWallet1);
});

it('wallet should throw when passphrase not set', async () => {
mockFs({});
const mockWallet = { mock: 1 };
await keymaster.start({ gatekeeper, wallet: wallet_enc, cipher });

try {
await keymaster.saveWallet(mockWallet);
throw new ExpectedExceptionError();
} catch (error) {
expect(error.message).toBe('KC_ENCRYPTED_PASSPHRASE not set');
}
});

it('encrypted wallet should throw when loading unencrypted wallet', async () => {
mockFs({
'data': {}
});

const walletFile = 'data/wallet-enc.json';
const mockWallet = { mock: 1 };
fs.writeFileSync(walletFile, JSON.stringify(mockWallet, null, 4));

await keymaster.start({ gatekeeper, wallet: wallet_enc, cipher });
wallet_enc.setPassphrase('passphrase');

try {
await keymaster.loadWallet();
throw new ExpectedExceptionError();
} catch (error) {
expect(error.message).toBe('Wallet not encrypted');
}
});
});

describe('decryptMnemonic', () => {
Expand Down

0 comments on commit 96ac062

Please sign in to comment.