diff --git a/packages/keymaster/package.json b/packages/keymaster/package.json index d4ceb9a5..dc556262 100644 --- a/packages/keymaster/package.json +++ b/packages/keymaster/package.json @@ -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": { diff --git a/packages/keymaster/src/db-wallet-json-enc.js b/packages/keymaster/src/db-wallet-json-enc.js new file mode 100644 index 00000000..65422935 --- /dev/null +++ b/packages/keymaster/src/db-wallet-json-enc.js @@ -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); +} diff --git a/packages/keymaster/src/db-wallet-json.js b/packages/keymaster/src/db-wallet-json.js index 4316d19f..196fb499 100644 --- a/packages/keymaster/src/db-wallet-json.js +++ b/packages/keymaster/src/db-wallet-json.js @@ -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; } diff --git a/packages/keymaster/src/keymaster-lib.js b/packages/keymaster/src/keymaster-lib.js index 05f2f40e..2d37827f 100644 --- a/packages/keymaster/src/keymaster-lib.js +++ b/packages/keymaster/src/keymaster-lib.js @@ -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'); } diff --git a/sample.env b/sample.env index c1dff6ca..93000ecb 100644 --- a/sample.env +++ b/sample.env @@ -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 diff --git a/scripts/keychain-cli.js b/scripts/keychain-cli.js index 038aede7..05e289e3 100644 --- a/scripts/keychain-cli.js +++ b/scripts/keychain-cli.js @@ -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(); @@ -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"; @@ -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)); @@ -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') @@ -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; @@ -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(); diff --git a/tests/keymaster.test.js b/tests/keymaster.test.js index 365c2206..cb06a70a 100644 --- a/tests/keymaster.test.js +++ b/tests/keymaster.test.js @@ -1,4 +1,5 @@ import mockFs from 'mock-fs'; +import fs from 'fs'; import canonicalize from 'canonicalize'; import * as keymaster from '@mdip/keymaster/lib'; @@ -6,6 +7,7 @@ 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'; @@ -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 () => { @@ -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 () => { @@ -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', () => {