diff --git a/.github/workflows/build-react-wallet-webapp.yml b/.github/workflows/build-react-wallet-webapp.yml index fd7305843..b36ad6230 100644 --- a/.github/workflows/build-react-wallet-webapp.yml +++ b/.github/workflows/build-react-wallet-webapp.yml @@ -4,7 +4,7 @@ on: push: jobs: - build-android-webapp: + build-react-wallet-webapp: runs-on: ubuntu-latest steps: @@ -22,13 +22,14 @@ jobs: - name: Compile TypeScript run: npm run build - - name: Install dependencies - run: | - cd ./apps/react-wallet - npm ci + - name: Install react-wallet dependencies + working-directory: ./apps/react-wallet + run: npm ci - - name: Build extension - run: | - cd ./apps/react-wallet - npm run build + - name: Typecheck react-wallet webapp + working-directory: ./apps/react-wallet + run: npm run typecheck + - name: Build react-wallet webapp + working-directory: ./apps/react-wallet + run: npm run build diff --git a/apps/chrome-extension/src/components/WalletTab.tsx b/apps/chrome-extension/src/components/WalletTab.tsx index c64607dab..c5db030fd 100644 --- a/apps/chrome-extension/src/components/WalletTab.tsx +++ b/apps/chrome-extension/src/components/WalletTab.tsx @@ -8,6 +8,7 @@ import { useSnackbar } from "../contexts/SnackbarProvider"; import WarningModal from "../modals/WarningModal"; import MnemonicModal from "../modals/MnemonicModal"; import WalletChrome from "@mdip/keymaster/wallet/chrome"; +import { MdipWalletBundle } from "@mdip/keymaster/types"; const WalletTab = () => { const [open, setOpen] = useState(false); @@ -19,6 +20,7 @@ const WalletTab = () => { const [checkResultMessage, setCheckResultMessage] = useState(""); const { keymaster, + walletProvider, initialiseWallet, handleWalletUploadFile, pendingMnemonic, @@ -46,7 +48,9 @@ const WalletTab = () => { async function createNewWallet() { const chromeWallet = new WalletChrome(); + const providerWallet = new WalletChrome("mdip-wallet-provider"); await chrome.storage.local.remove([chromeWallet.walletName]); + await chrome.storage.local.remove([providerWallet.walletName]); await chrome.runtime.sendMessage({ action: "CLEAR_ALL_STATE"}); await chrome.runtime.sendMessage({ action: "CLEAR_PASSPHRASE"}); await initialiseWallet(); @@ -122,11 +126,11 @@ const WalletTab = () => { }; async function showMnemonic() { - if (!keymaster) { + if (!walletProvider) { return; } try { - const response = await keymaster.decryptMnemonic(); + const response = await walletProvider.decryptMnemonic(); setMnemonicString(response); } catch (error: any) { setError(error); @@ -163,18 +167,25 @@ const WalletTab = () => { } async function downloadWallet() { - if (!keymaster) { + if (!keymaster || !walletProvider) { return; } try { - const wallet = await keymaster.exportEncryptedWallet(); - const walletJSON = JSON.stringify(wallet, null, 4); + const wallet = await keymaster.loadWallet(); + const provider = await walletProvider.backupWallet(); + const bundle: MdipWalletBundle = { + version: 1, + type: "mdip-wallet-bundle", + keymaster: wallet, + provider, + }; + const walletJSON = JSON.stringify(bundle, null, 4); const blob = new Blob([walletJSON], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = 'mdip-wallet.json'; + link.download = 'mdip-wallet-bundle.json'; link.click(); URL.revokeObjectURL(url); @@ -209,7 +220,7 @@ const WalletTab = () => { } try { await keymaster.backupWallet(); - setSuccess("Wallet backup successful"); + setSuccess("Wallet metadata backup successful"); } catch (error: any) { setError(error); } diff --git a/apps/chrome-extension/src/contexts/WalletProvider.tsx b/apps/chrome-extension/src/contexts/WalletProvider.tsx index ce6cb81bf..8aadc9461 100644 --- a/apps/chrome-extension/src/contexts/WalletProvider.tsx +++ b/apps/chrome-extension/src/contexts/WalletProvider.tsx @@ -14,17 +14,36 @@ import Keymaster from "@mdip/keymaster"; import SearchClient from "@mdip/keymaster/search"; import CipherWeb from "@mdip/cipher/web"; import WalletChrome from "@mdip/keymaster/wallet/chrome"; -import { isLegacyV0, isV1WithEnc } from '@mdip/keymaster/wallet/typeGuards'; -import { StoredWallet, WalletBase } from "@mdip/keymaster/types"; +import MnemonicHdWalletProvider from "@mdip/keymaster/wallet/mnemonic-hd"; +import { + isLegacyV0, + isV1Decrypted, + isV1WithEnc, + isV2Wallet, +} from "@mdip/keymaster/wallet/typeGuards"; +import { + MdipWalletBundle, + KeymasterStore, + MnemonicHdWalletProviderInterface, + MnemonicHdWalletState, + StoredWallet, + WalletFile, + WalletProviderStore, +} from "@mdip/keymaster/types"; import PassphraseModal from "../modals/PassphraseModal"; import WarningModal from "../modals/WarningModal"; import MnemonicModal from "../modals/MnemonicModal"; -import { encMnemonic } from '@mdip/keymaster/encryption'; +import { encMnemonic } from "@mdip/keymaster/encryption"; import WalletJsonMemory from "@mdip/keymaster/wallet/json-memory"; const gatekeeper = new GatekeeperClient(); const cipher = new CipherWeb(); +const KEYMASTER_STORE_NAME = "mdip-keymaster"; +const WALLET_PROVIDER_STORE_NAME = "mdip-wallet-provider"; + +type UploadAction = "upload-legacy-plain" | "upload-legacy-encrypted" | "upload-bundle"; + interface WalletContextValue { pendingMnemonic: string; setPendingMnemonic: Dispatch>; @@ -37,6 +56,7 @@ interface WalletContextValue { reloadBrowserWallet: () => Promise; refreshFlag: number; keymaster: Keymaster | null; + walletProvider: MnemonicHdWalletProviderInterface | null; } const WalletContext = createContext(null); @@ -45,13 +65,64 @@ let search: SearchClient | undefined; // eslint-disable-next-line sonarjs/no-hardcoded-passwords const INCORRECT_PASSPHRASE = "Incorrect passphrase"; +const INCOMPLETE_WALLET = "Wallet data is incomplete. Restore from an mdip-wallet-bundle or reset the wallet."; + +function createMetadataStore() { + return new WalletChrome(KEYMASTER_STORE_NAME); +} + +function createProviderStore(): WalletProviderStore { + return new WalletChrome(WALLET_PROVIDER_STORE_NAME) as unknown as WalletProviderStore; +} + +function createMemoryProviderStore(): WalletProviderStore { + return new WalletJsonMemory() as unknown as WalletProviderStore; +} + +function createMnemonicWalletProvider( + passphrase: string, + store: WalletProviderStore = createProviderStore(), +) { + return new MnemonicHdWalletProvider({ + store, + cipher, + passphrase, + }); +} + +function isMdipWalletBundle(wallet: unknown): wallet is MdipWalletBundle { + if (!wallet || typeof wallet !== "object") { + return false; + } + + const bundle = wallet as Partial; + return bundle.version === 1 + && bundle.type === "mdip-wallet-bundle" + && isV2Wallet(bundle.keymaster) + && !!bundle.provider + && bundle.provider.version === 1 + && bundle.provider.type === "mnemonic-hd" + && !!bundle.provider.rootPublicJwk; +} + +async function verifyMnemonicAgainstProviderState( + providerState: MnemonicHdWalletState, + mnemonic: string, +) { + const hdKey = cipher.generateHDKey(mnemonic); + const { publicJwk } = cipher.generateJwk(hdKey.privateKey!); + + if (cipher.hashJSON(publicJwk) !== cipher.hashJSON(providerState.rootPublicJwk)) { + throw new Error("Mnemonic does not match wallet."); + } +} export function WalletProvider({ children, isBrowser }: { children: ReactNode, isBrowser: boolean }) { const [passphraseErrorText, setPassphraseErrorText] = useState(""); const [pendingMnemonic, setPendingMnemonic] = useState(""); const [pendingWallet, setPendingWallet] = useState(null); const [modalAction, setModalAction] = useState(null); - const [uploadAction, setUploadAction] = useState(null); + const [uploadAction, setUploadAction] = useState(null); const [isReady, setIsReady] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetSetup, setShowResetSetup] = useState(false); @@ -62,29 +133,34 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i const [refreshFlag, setRefreshFlag] = useState(0); const keymasterRef = useRef(null); - - const walletChrome = new WalletChrome(); + const walletProviderRef = useRef(null); useEffect(() => { const initWallet = async () => { await initialiseServices(); await initialiseWallet(); - } + }; initWallet(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function initialiseWallet() { - const walletData = await walletChrome.loadWallet(); - - let response = await chrome.runtime.sendMessage({ + const walletStore = createMetadataStore(); + const providerStore = createProviderStore(); + const walletData = await walletStore.loadWallet(); + const providerData = await providerStore.loadWallet(); + const hasIncompleteState = + (!!providerData && !walletData) + || (!!walletData && isV2Wallet(walletData) && !providerData); + + const response = await chrome.runtime.sendMessage({ action: "GET_PASSPHRASE", }); const pass = response?.passphrase || ""; - if (!pendingMnemonic && pass) { - let res = await rebuildKeymaster(pass); + if (!pendingMnemonic && pass && !hasIncompleteState) { + const res = await buildKeymaster(pass); if (res) { return; } @@ -92,11 +168,18 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i await chrome.runtime.sendMessage({ action: "CLEAR_PASSPHRASE" }); } - if (!walletData || pendingMnemonic || isLegacyV0(walletData)) { - // eslint-disable-next-line sonarjs/no-duplicate-string - setModalAction('set-passphrase'); + if (hasIncompleteState) { + setPassphraseErrorText(INCOMPLETE_WALLET); + setModalAction("decrypt"); + return; + } + + if (!walletData || pendingMnemonic || isLegacyV0(walletData) || isV1Decrypted(walletData)) { + setPassphraseErrorText(""); + setModalAction("set-passphrase"); } else { - setModalAction('decrypt'); + setPassphraseErrorText(""); + setModalAction("decrypt"); } } @@ -109,75 +192,130 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i search = await SearchClient.create({ url: searchServerUrl as string }); } - const buildKeymaster = async (wallet: WalletBase, passphrase: string) => { - const instance = new Keymaster({gatekeeper, wallet, cipher, search, passphrase}); + function createKeymaster( + passphrase: string, + store: KeymasterStore = createMetadataStore(), + providerStore: WalletProviderStore = createProviderStore(), + ) { + const walletProvider = createMnemonicWalletProvider(passphrase, providerStore); + const instance = new Keymaster({ + gatekeeper, + store, + walletProvider, + cipher, + search, + }); - if (pendingMnemonic) { - await instance.newWallet(pendingMnemonic, true); - await instance.recoverWallet(); - } else { - try { - // check pass & convert to v1 if needed - await instance.loadWallet(); - } catch { - setPassphraseErrorText(INCORRECT_PASSPHRASE); - return false; - } - } + return { instance, walletProvider }; + } + async function activateWallet( + keymaster: Keymaster, + walletProvider: MnemonicHdWalletProviderInterface, + passphrase: string, + ) { setModalAction(null); setPendingWallet(null); setPendingMnemonic(""); + setRecoveredMnemonic(""); setUploadAction(null); setPassphraseErrorText(""); - keymasterRef.current = instance; - setRefreshFlag(r => r + 1); + keymasterRef.current = keymaster; + walletProviderRef.current = walletProvider; + setRefreshFlag((value) => value + 1); setIsReady(true); await chrome.runtime.sendMessage({ action: "STORE_PASSPHRASE", passphrase, }); + } + + const buildKeymaster = async (passphrase: string) => { + const { instance, walletProvider } = createKeymaster(passphrase); + + try { + if (pendingMnemonic) { + await instance.newWallet(pendingMnemonic, true); + } else { + await instance.loadWallet(); + } + } catch { + setPassphraseErrorText(INCORRECT_PASSPHRASE); + return false; + } + await activateWallet(instance, walletProvider, passphrase); return true; }; - async function rebuildKeymaster(passphrase: string) { - return await buildKeymaster(walletChrome, passphrase); + async function persistWalletData(wallet: WalletFile, providerState: MnemonicHdWalletState) { + const providerStore = createProviderStore(); + const walletStore = createMetadataStore(); + + const providerOk = await providerStore.saveWallet(providerState, true); + if (!providerOk) { + throw new Error("save provider wallet failed"); + } + + const walletOk = await walletStore.saveWallet(wallet, true); + if (!walletOk) { + throw new Error("save wallet failed"); + } + } + + async function importLegacyWallet(wallet: StoredWallet, passphrase: string) { + const memoryStore = new WalletJsonMemory(); + const memoryProviderStore = createMemoryProviderStore(); + const { instance, walletProvider } = createKeymaster(passphrase, memoryStore, memoryProviderStore); + + await memoryStore.saveWallet(wallet, true); + const normalized = await instance.loadWallet(); + const providerState = await walletProvider.backupWallet(); + await persistWalletData(normalized, providerState); + } + + async function importWalletBundle(bundle: MdipWalletBundle, passphrase: string) { + const memoryStore = new WalletJsonMemory(); + const memoryProviderStore = createMemoryProviderStore(); + const { instance, walletProvider } = createKeymaster(passphrase, memoryStore, memoryProviderStore); + + await memoryStore.saveWallet(bundle.keymaster, true); + await walletProvider.saveWallet(bundle.provider, true); + const normalized = await instance.loadWallet(); + const providerState = await walletProvider.backupWallet(); + await persistWalletData(normalized, providerState); } async function handlePassphraseSubmit(passphrase: string) { setPassphraseErrorText(""); - const walletMemory = new WalletJsonMemory(); - if (uploadAction && pendingWallet) { - if (modalAction === 'decrypt') { - await walletMemory.saveWallet(pendingWallet as StoredWallet, true); - - try { - const km = new Keymaster({ gatekeeper, wallet: walletMemory, cipher, search, passphrase }); - // check pass - await km.loadWallet(); - await walletChrome.saveWallet(pendingWallet as StoredWallet, true); - } catch { - setPassphraseErrorText(INCORRECT_PASSPHRASE); - return; + try { + if (uploadAction === "upload-bundle" && isMdipWalletBundle(pendingWallet)) { + await importWalletBundle(pendingWallet, passphrase); + } else { + await importLegacyWallet(pendingWallet as StoredWallet, passphrase); } - } else { // upload-plain-v0 - await walletChrome.saveWallet(pendingWallet as StoredWallet, true); + } catch { + setPassphraseErrorText( + modalAction === "decrypt" ? INCORRECT_PASSPHRASE : "Failed to import wallet." + ); + return; } } - await rebuildKeymaster(passphrase); + await buildKeymaster(passphrase); } async function handlePassphraseClose() { setPendingWallet(null); setPendingMnemonic(""); + setRecoveredMnemonic(""); setPassphraseErrorText(""); - const walletData = await walletChrome.loadWallet(); - if (walletData) { + const walletData = await createMetadataStore().loadWallet(); + const providerData = await createProviderStore().loadWallet(); + if (walletData || providerData) { setModalAction(null); } } @@ -187,7 +325,7 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i return; } - let response = await chrome.runtime.sendMessage({ + const response = await chrome.runtime.sendMessage({ action: "GET_PASSPHRASE", }); const pass = response?.passphrase || ""; @@ -195,21 +333,36 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i return; } - await rebuildKeymaster(pass); + await buildKeymaster(pass); } async function handleWalletUploadFile(uploaded: unknown) { setPendingWallet(uploaded); - if (isLegacyV0(uploaded)) { - setUploadAction('upload-plain-v0'); - setModalAction('set-passphrase'); - } else if (isV1WithEnc(uploaded)) { - setUploadAction('upload-enc-v1'); - setModalAction('decrypt'); - } else { - window.alert('Unsupported wallet type'); + if (isMdipWalletBundle(uploaded)) { + setUploadAction("upload-bundle"); + setModalAction("decrypt"); + return; + } + + if (isLegacyV0(uploaded) || isV1Decrypted(uploaded)) { + setUploadAction("upload-legacy-plain"); + setModalAction("set-passphrase"); + return; + } + + if (isV1WithEnc(uploaded)) { + setUploadAction("upload-legacy-encrypted"); + setModalAction("decrypt"); + return; } + + if (isV2Wallet(uploaded)) { + window.alert("Standalone keymaster metadata is not enough. Upload an mdip-wallet-bundle instead."); + return; + } + + window.alert("Unsupported wallet type"); } function handleStartReset() { @@ -219,11 +372,10 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i function handleStartRecover() { setMnemonicErrorText(""); + setRecoveredMnemonic(""); setShowRecoverMnemonic(true); setPassphraseErrorText(""); - // only nullify modalAction if we are uploading a wallet, otherwise - // leave passphrase modal open in case the user cancels if (uploadAction !== null) { setModalAction(null); } @@ -240,38 +392,47 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i async function handleResetPassphraseSubmit(newPassphrase: string) { try { - const walletWeb = new WalletChrome(); - const km = new Keymaster({ gatekeeper, wallet: walletWeb, cipher, search, passphrase: newPassphrase }); - await km.newWallet(undefined, true); + const { instance } = createKeymaster(newPassphrase); + await instance.newWallet(undefined, true); setShowResetSetup(false); - await rebuildKeymaster(newPassphrase); + await buildKeymaster(newPassphrase); } catch { - setPassphraseErrorText('Failed to reset wallet. Try again.'); + setPassphraseErrorText("Failed to reset wallet. Try again."); } } async function handleRecoverMnemonicSubmit(mnemonic: string) { setMnemonicErrorText(""); + try { - const walletWeb = new WalletChrome(); - let stored = pendingWallet && isV1WithEnc(pendingWallet) + const walletStore = createMetadataStore(); + const providerStore = createProviderStore(); + const storedWallet = pendingWallet && isV1WithEnc(pendingWallet) ? pendingWallet - : await walletWeb.loadWallet(); + : await walletStore.loadWallet(); + + if (isV1WithEnc(storedWallet)) { + const hdkey = cipher.generateHDKey(mnemonic); + const { publicJwk, privateJwk } = cipher.generateJwk(hdkey.privateKey!); + cipher.decryptMessage(publicJwk, privateJwk, storedWallet.enc); + } else { + const providerState = isMdipWalletBundle(pendingWallet) + ? pendingWallet.provider + : await providerStore.loadWallet(); + + if (!providerState) { + setMnemonicErrorText("Recovery not available for this wallet type."); + return; + } - if (!isV1WithEnc(stored)) { - setMnemonicErrorText('Recovery not available for this wallet type.'); - return; + await verifyMnemonicAgainstProviderState(providerState, mnemonic); } - const hdkey = cipher.generateHDKey(mnemonic); - const { publicJwk, privateJwk } = cipher.generateJwk(hdkey.privateKey!); - cipher.decryptMessage(publicJwk, privateJwk, stored.enc); - setRecoveredMnemonic(mnemonic); setShowRecoverMnemonic(false); setShowRecoverSetup(true); } catch { - setMnemonicErrorText('Mnemonic is incorrect. Try again.'); + setMnemonicErrorText("Mnemonic is incorrect. Try again."); } } @@ -279,30 +440,56 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i if (!recoveredMnemonic) { return; } + try { - const walletWeb = new WalletChrome(); - const base = pendingWallet && isV1WithEnc(pendingWallet) + const walletStore = createMetadataStore(); + const providerStore = createProviderStore(); + const storedWallet = pendingWallet && isV1WithEnc(pendingWallet) ? pendingWallet - : await walletWeb.loadWallet(); + : await walletStore.loadWallet(); + + if (isV1WithEnc(storedWallet)) { + const mnemonicEnc = await encMnemonic(recoveredMnemonic, newPassphrase); + const updatedWallet = { + version: storedWallet.version, + seed: { mnemonicEnc }, + enc: storedWallet.enc, + } satisfies StoredWallet; + + await importLegacyWallet(updatedWallet, newPassphrase); + } else { + const providerState = isMdipWalletBundle(pendingWallet) + ? pendingWallet.provider + : await providerStore.loadWallet(); + + if (!providerState) { + setPassphraseErrorText("Recovery not available for this wallet type."); + return; + } - if (!isV1WithEnc(base)) { - setPassphraseErrorText('Recovery not available for this wallet type.'); - return; + const recoveryProvider = createMnemonicWalletProvider(newPassphrase, createMemoryProviderStore()); + await recoveryProvider.saveWallet(providerState, true); + await recoveryProvider.changePassphrase(recoveredMnemonic, newPassphrase); + const updatedProviderState = await recoveryProvider.backupWallet(); + + if (isMdipWalletBundle(pendingWallet)) { + await persistWalletData(pendingWallet.keymaster, updatedProviderState); + } else { + const wallet = await walletStore.loadWallet(); + if (!wallet || !isV2Wallet(wallet)) { + setPassphraseErrorText("Recovery not available for this wallet type."); + return; + } + + await persistWalletData(wallet, updatedProviderState); + } } - const mnemonicEnc = await encMnemonic(recoveredMnemonic, newPassphrase); - const updated = { - version: base.version, - seed: { mnemonicEnc }, - enc: base.enc - }; - - await walletWeb.saveWallet(updated, true); setRecoveredMnemonic(""); setShowRecoverSetup(false); - await rebuildKeymaster(newPassphrase); + await buildKeymaster(newPassphrase); } catch { - setPassphraseErrorText('Failed to update passphrase. Try again.'); + setPassphraseErrorText("Failed to update passphrase. Try again."); } } @@ -318,24 +505,27 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i refreshFlag, isBrowser, keymaster: keymasterRef.current, + walletProvider: walletProviderRef.current, }; return ( <> /tests/common/pino.mock.ts', '^\\.\\/typeGuards\\.js$': '/packages/keymaster/src/db/typeGuards.ts', '^\\.\\/db\\/typeGuards\\.js$': '/packages/keymaster/src/db/typeGuards.ts', + '^\\.\\/provider\\/mnemonic-hd\\.js$': '/packages/keymaster/src/provider/mnemonic-hd.ts', + '^\\.\\./encryption\\.js$': '/packages/keymaster/src/encryption.ts', + '^\\.\\./db\\/typeGuards\\.js$': '/packages/keymaster/src/db/typeGuards.ts', '^\\.\\/sync-mapping\\.js$': '/services/mediators/hyperswarm/src/sync-mapping.ts', '^\\.\\/sync-persistence\\.js$': '/services/mediators/hyperswarm/src/sync-persistence.ts', '^\\.\\/abstract-json\\.js$': '/packages/gatekeeper/src/db/abstract-json.ts', diff --git a/packages/keymaster/package.json b/packages/keymaster/package.json index 314782b91..8d02c4a71 100644 --- a/packages/keymaster/package.json +++ b/packages/keymaster/package.json @@ -88,6 +88,10 @@ "require": "./dist/cjs/db/chrome.cjs", "types": "./dist/types/db/chrome.d.ts" }, + "./wallet/mnemonic-hd": { + "import": "./dist/esm/provider/mnemonic-hd.js", + "types": "./dist/types/provider/mnemonic-hd.d.ts" + }, "./wallet/typeGuards": { "import": "./dist/esm/db/typeGuards.js", "require": "./dist/cjs/db/typeGuards.cjs", @@ -135,6 +139,9 @@ "wallet/chrome": [ "./dist/types/db/chrome.d.ts" ], + "wallet/mnemonic-hd": [ + "./dist/types/provider/mnemonic-hd.d.ts" + ], "wallet/typeGuards": [ "./dist/types/db/typeGuards.d.ts" ], diff --git a/packages/keymaster/src/db/cache.ts b/packages/keymaster/src/db/cache.ts index 1761f2b16..2afc4a5f7 100644 --- a/packages/keymaster/src/db/cache.ts +++ b/packages/keymaster/src/db/cache.ts @@ -1,12 +1,11 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; -export default class WalletCache implements WalletBase { - private baseWallet: WalletBase; - private cachedWallet: StoredWallet; +export default class WalletCache implements KeymasterStore { + private baseWallet: KeymasterStore; + private cachedWallet: StoredWallet | null | undefined = undefined; - constructor(baseWallet: WalletBase) { + constructor(baseWallet: KeymasterStore) { this.baseWallet = baseWallet; - this.cachedWallet = null; } async saveWallet(wallet: StoredWallet, overwrite: boolean = false): Promise { @@ -15,10 +14,10 @@ export default class WalletCache implements WalletBase { } async loadWallet(): Promise { - if (!this.cachedWallet) { + if (this.cachedWallet === undefined) { this.cachedWallet = await this.baseWallet.loadWallet(); } - return this.cachedWallet; + return this.cachedWallet ?? null; } } diff --git a/packages/keymaster/src/db/chrome.ts b/packages/keymaster/src/db/chrome.ts index becd1c7b9..48553f63b 100644 --- a/packages/keymaster/src/db/chrome.ts +++ b/packages/keymaster/src/db/chrome.ts @@ -1,6 +1,6 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; -export default class WalletChrome implements WalletBase { +export default class WalletChrome implements KeymasterStore { walletName: string; constructor(walletName: string = 'mdip-keymaster') { diff --git a/packages/keymaster/src/db/json-memory.ts b/packages/keymaster/src/db/json-memory.ts index ccf5a0c04..f6f1b6ab5 100644 --- a/packages/keymaster/src/db/json-memory.ts +++ b/packages/keymaster/src/db/json-memory.ts @@ -1,6 +1,6 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; -export default class WalletJsonMemory implements WalletBase { +export default class WalletJsonMemory implements KeymasterStore { walletCache: string | null = null; async saveWallet(wallet: StoredWallet, overwrite: boolean = false): Promise { diff --git a/packages/keymaster/src/db/json.ts b/packages/keymaster/src/db/json.ts index 35db7e4a6..781092d69 100644 --- a/packages/keymaster/src/db/json.ts +++ b/packages/keymaster/src/db/json.ts @@ -1,7 +1,7 @@ import fs from 'fs'; -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; -export default class WalletJson implements WalletBase { +export default class WalletJson implements KeymasterStore { private readonly dataFolder: string; walletName: string; diff --git a/packages/keymaster/src/db/mongo.ts b/packages/keymaster/src/db/mongo.ts index aa4e070aa..e11609912 100644 --- a/packages/keymaster/src/db/mongo.ts +++ b/packages/keymaster/src/db/mongo.ts @@ -1,15 +1,15 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; import { MongoClient, Db, Collection } from 'mongodb' -export default class WalletMongo implements WalletBase { +export default class WalletMongo implements KeymasterStore { private client: MongoClient; private db?: Db; private collection?: Collection; private dbName = 'keymaster' private readonly collectionName: string; - public static async create(): Promise { - const wallet = new WalletMongo(); + public static async create(walletKey: string = 'wallet'): Promise { + const wallet = new WalletMongo(walletKey); await wallet.connect(); return wallet; } @@ -30,7 +30,7 @@ export default class WalletMongo implements WalletBase { await this.client.close(); } - async saveWallet(wallet: Exclude, overwrite: boolean = false): Promise { + async saveWallet(wallet: StoredWallet, overwrite: boolean = false): Promise { if (!this.collection) { throw new Error('Not connected to MongoDB. Call connect() first or use WalletMongo.create().') } @@ -40,7 +40,7 @@ export default class WalletMongo implements WalletBase { return false; } - await this.collection.replaceOne({}, wallet, { upsert: true }); + await this.collection.replaceOne({}, wallet as Record, { upsert: true }); return true; } diff --git a/packages/keymaster/src/db/postgres.ts b/packages/keymaster/src/db/postgres.ts index db7e981d5..bef7c0a87 100644 --- a/packages/keymaster/src/db/postgres.ts +++ b/packages/keymaster/src/db/postgres.ts @@ -1,11 +1,11 @@ import { Pool } from 'pg'; -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; interface WalletRow { data: StoredWallet | string; } -export default class WalletPostgres implements WalletBase { +export default class WalletPostgres implements KeymasterStore { private readonly url: string; private readonly walletKey: string; private pool: Pool | null; diff --git a/packages/keymaster/src/db/redis.ts b/packages/keymaster/src/db/redis.ts index cf3324748..85f34a432 100644 --- a/packages/keymaster/src/db/redis.ts +++ b/packages/keymaster/src/db/redis.ts @@ -1,7 +1,7 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; import { Redis } from 'ioredis' -export default class WalletRedis implements WalletBase { +export default class WalletRedis implements KeymasterStore { private readonly walletKey: string; private readonly url: string; private redis: Redis | null diff --git a/packages/keymaster/src/db/sqlite.ts b/packages/keymaster/src/db/sqlite.ts index fd3c90f94..25b42bd12 100644 --- a/packages/keymaster/src/db/sqlite.ts +++ b/packages/keymaster/src/db/sqlite.ts @@ -1,8 +1,8 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; import sqlite3 from 'sqlite3'; import { open, Database } from 'sqlite'; -export default class WalletSQLite implements WalletBase { +export default class WalletSQLite implements KeymasterStore { private readonly walletName: string; private db: Database | null; diff --git a/packages/keymaster/src/db/typeGuards.ts b/packages/keymaster/src/db/typeGuards.ts index 8f77040d7..f074c68ab 100644 --- a/packages/keymaster/src/db/typeGuards.ts +++ b/packages/keymaster/src/db/typeGuards.ts @@ -1,13 +1,21 @@ -import { WalletFile, WalletEncFile } from "../types.js"; +import { + LegacyWalletEncFile, + LegacyWalletFile, + WalletFile, +} from "../types.js"; -export function isV1WithEnc(obj: any): obj is WalletEncFile { +export function isV2Wallet(obj: any): obj is WalletFile { + return !!obj && obj.version === 2 && typeof obj.provider?.type === 'string' && typeof obj.provider?.walletFingerprint === 'string' && !!obj.ids; +} + +export function isV1WithEnc(obj: any): obj is LegacyWalletEncFile { return !!obj && obj.version === 1 && typeof obj.enc === 'string' && obj.seed?.mnemonicEnc; } -export function isV1Decrypted(obj: any): obj is WalletFile { +export function isV1Decrypted(obj: any): obj is LegacyWalletFile { return !!obj && obj.version === 1 && obj.seed?.mnemonicEnc && !('enc' in obj); } -export function isLegacyV0(obj: any): obj is WalletFile { +export function isLegacyV0(obj: any): obj is LegacyWalletFile { return !!obj && (!obj.version || obj.version === 0) && !!obj.seed?.hdkey && typeof obj.seed.mnemonic === 'string'; } diff --git a/packages/keymaster/src/db/web.ts b/packages/keymaster/src/db/web.ts index 119ff1700..32ca11930 100644 --- a/packages/keymaster/src/db/web.ts +++ b/packages/keymaster/src/db/web.ts @@ -1,6 +1,6 @@ -import { StoredWallet, WalletBase } from '../types.js'; +import { KeymasterStore, StoredWallet } from '../types.js'; -export default class WalletWeb implements WalletBase { +export default class WalletWeb implements KeymasterStore { walletName: string; constructor(walletName: string = 'mdip-keymaster') { diff --git a/packages/keymaster/src/index.ts b/packages/keymaster/src/index.ts index b28cf7553..63a245860 100644 --- a/packages/keymaster/src/index.ts +++ b/packages/keymaster/src/index.ts @@ -5,5 +5,6 @@ export { default as WalletJsonMemory } from './db/json-memory.js'; export { default as WalletWeb } from './db/web.js'; export { default as WalletCache } from './db/cache.js'; export { default as WalletChrome } from './db/chrome.js'; +export { default as MnemonicHdWalletProvider } from './provider/mnemonic-hd.js'; export * from './db/typeGuards.js'; export * from './types.js'; diff --git a/packages/keymaster/src/keymaster-client.ts b/packages/keymaster/src/keymaster-client.ts index a75042a82..e9f561885 100644 --- a/packages/keymaster/src/keymaster-client.ts +++ b/packages/keymaster/src/keymaster-client.ts @@ -28,7 +28,6 @@ import { ViewPollResult, WaitUntilReadyOptions, WalletFile, - WalletEncFile, } from './types.js' import { Buffer } from 'buffer'; @@ -158,19 +157,19 @@ export default class KeymasterClient implements KeymasterInterface { } } - async backupWallet(): Promise { + async backupWallet(): Promise { try { const response = await axios.post(`${this.API}/wallet/backup`); - return response.data.ok; + return response.data.did; } catch (error) { throwError(error); } } - async recoverWallet(): Promise { + async recoverWallet(did?: string): Promise { try { - const response = await axios.post(`${this.API}/wallet/recover`); + const response = await axios.post(`${this.API}/wallet/recover`, { did }); return response.data.wallet; } catch (error) { @@ -198,16 +197,6 @@ export default class KeymasterClient implements KeymasterInterface { } } - async decryptMnemonic(): Promise { - try { - const response = await axios.get(`${this.API}/wallet/mnemonic`); - return response.data.mnemonic; - } - catch (error) { - throwError(error); - } - } - async listRegistries(): Promise { try { const response = await axios.get(`${this.API}/registries`); @@ -1410,14 +1399,4 @@ export default class KeymasterClient implements KeymasterInterface { throwError(error); } } - - async exportEncryptedWallet(): Promise { - try { - const response = await axios.get(`${this.API}/export/wallet/encrypted`); - return response.data.wallet; - } - catch (error) { - throwError(error); - } - } } diff --git a/packages/keymaster/src/keymaster.ts b/packages/keymaster/src/keymaster.ts index 2e795ddda..523db2478 100644 --- a/packages/keymaster/src/keymaster.ts +++ b/packages/keymaster/src/keymaster.ts @@ -42,25 +42,24 @@ import { StoredWallet, VerifiableCredential, ViewPollResult, - WalletBase, WalletFile, - WalletEncFile, + KeymasterStore, + WalletProvider, SearchEngine, - Seed, } from '@mdip/keymaster/types'; import { + isV2Wallet, isV1WithEnc, isV1Decrypted, isLegacyV0 } from './db/typeGuards.js'; +import MnemonicHdWalletProvider from './provider/mnemonic-hd.js'; import { Cipher, - EcdsaJwkPair, EcdsaJwkPrivate, - EcdsaJwkPublic, + EcdsaJwkPublic } from '@mdip/cipher/types'; import { isValidDID } from '@mdip/ipfs/utils'; -import { decMnemonic, encMnemonic } from "./encryption.js"; const DefaultSchema = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -93,9 +92,9 @@ export enum NoticeTags { } export default class Keymaster implements KeymasterInterface { - private readonly passphrase: string; private gatekeeper: GatekeeperInterface; - private db: WalletBase; + private store: KeymasterStore; + private walletProvider: WalletProvider; private cipher: Cipher; private searchEngine: SearchEngine | undefined; private readonly defaultRegistry: string; @@ -104,14 +103,23 @@ export default class Keymaster implements KeymasterInterface { private readonly maxDataLength: number; private _walletCache?: WalletFile; private _walletMutationLock: Promise = Promise.resolve(); - private _hdkeyCache?: any; constructor(options: KeymasterOptions) { if (!options || !options.gatekeeper || !options.gatekeeper.createDID) { throw new InvalidParameterError('options.gatekeeper'); } - if (!options.wallet || !options.wallet.loadWallet || !options.wallet.saveWallet) { - throw new InvalidParameterError('options.wallet'); + if (!options.store || !options.store.loadWallet || !options.store.saveWallet) { + throw new InvalidParameterError('options.store'); + } + if (!options.walletProvider || + !options.walletProvider.type || + !options.walletProvider.getFingerprint || + !options.walletProvider.resetWallet || + !options.walletProvider.createIdKey || + !options.walletProvider.signDigest || + !options.walletProvider.encrypt || + !options.walletProvider.decrypt) { + throw new InvalidParameterError('options.walletProvider'); } if (!options.cipher || !options.cipher.verifySig) { throw new InvalidParameterError('options.cipher'); @@ -119,13 +127,9 @@ export default class Keymaster implements KeymasterInterface { if (options.search && !options.search.search) { throw new InvalidParameterError('options.search'); } - if (!options.passphrase) { - throw new InvalidParameterError('options.passphrase'); - } - - this.passphrase = options.passphrase; this.gatekeeper = options.gatekeeper; - this.db = options.wallet; + this.store = options.store; + this.walletProvider = options.walletProvider; this.cipher = options.cipher; this.searchEngine = options.search; @@ -139,6 +143,49 @@ export default class Keymaster implements KeymasterInterface { return this.gatekeeper.listRegistries(); } + private async getWalletProviderIdentity() { + return { + type: this.walletProvider.type, + walletFingerprint: await this.walletProvider.getFingerprint(), + }; + } + + private isMnemonicHdWalletProvider(provider: WalletProvider): provider is MnemonicHdWalletProvider { + return provider instanceof MnemonicHdWalletProvider; + } + + private supportsKeyRotation( + provider: WalletProvider + ): provider is WalletProvider & { rotateKey(keyRef: string): Promise<{ publicJwk: EcdsaJwkPublic }> } { + return 'rotateKey' in provider && typeof provider.rotateKey === 'function'; + } + + private async normalizeStoredWallet(stored: StoredWallet): Promise { + if (isV2Wallet(stored)) { + const provider = await this.getWalletProviderIdentity(); + if (stored.provider.type !== provider.type || stored.provider.walletFingerprint !== provider.walletFingerprint) { + throw new KeymasterError('Wallet provider does not match stored metadata.'); + } + + return stored; + } + + if (isLegacyV0(stored) || isV1Decrypted(stored) || isV1WithEnc(stored)) { + if (!this.isMnemonicHdWalletProvider(this.walletProvider)) { + throw new KeymasterError('Legacy wallet migration requires MnemonicHdWalletProvider.'); + } + + const migrated = await this.walletProvider.migrateLegacyWallet(stored); + const ok = await this.store.saveWallet(migrated, true); + if (!ok) { + throw new KeymasterError('save wallet failed'); + } + return migrated; + } + + throw new KeymasterError('Unsupported wallet version.'); + } + private async mutateWallet( mutator: (wallet: WalletFile) => void | Promise ): Promise { @@ -158,8 +205,7 @@ export default class Keymaster implements KeymasterInterface { return; } - const reenc = await this.encryptWalletForStorage(decrypted); - const ok = await this.db.saveWallet(reenc, true); + const ok = await this.store.saveWallet(decrypted, true); if (!ok) { throw new KeymasterError('save wallet failed'); } @@ -177,14 +223,14 @@ export default class Keymaster implements KeymasterInterface { return this._walletCache; } - let stored = await this.db.loadWallet() as WalletFile | null; + const stored = await this.store.loadWallet(); if (!stored) { - stored = await this.newWallet(); + this._walletCache = await this.newWallet(); + return this._walletCache; } - const upgraded: WalletFile = await this.upgradeWallet(stored); - this._walletCache = await this.decryptWallet(upgraded); + this._walletCache = await this.normalizeStoredWallet(stored); return this._walletCache; } @@ -192,12 +238,11 @@ export default class Keymaster implements KeymasterInterface { wallet: StoredWallet, overwrite = true ): Promise { - let upgraded: WalletFile = await this.upgradeWallet(wallet); - let toStore: WalletEncFile = await this.encryptWallet(upgraded); + const normalized = await this.normalizeStoredWallet(wallet); - const ok = await this.db.saveWallet(toStore, overwrite); + const ok = await this.store.saveWallet(normalized, overwrite); if (ok) { - this._walletCache = await this.decryptWalletFromStorage(toStore); + this._walletCache = normalized; } return ok; } @@ -206,25 +251,21 @@ export default class Keymaster implements KeymasterInterface { mnemonic?: string, overwrite = false ): Promise { - try { - if (!mnemonic) { - mnemonic = this.cipher.generateMnemonic(); - } - - this._hdkeyCache = this.cipher.generateHDKey(mnemonic); - } catch { - throw new InvalidParameterError('mnemonic'); + if (this.isMnemonicHdWalletProvider(this.walletProvider)) { + await this.walletProvider.newWallet(mnemonic, overwrite); + } else if (mnemonic) { + throw new KeymasterError('Wallet provider does not support mnemonic initialization.'); + } else { + await this.walletProvider.resetWallet(overwrite); } - const mnemonicEnc = await encMnemonic(mnemonic, this.passphrase); const wallet: WalletFile = { - version: 1, - seed: { mnemonicEnc }, - counter: 0, - ids: {} + version: 2, + provider: await this.getWalletProviderIdentity(), + ids: {}, }; - const ok = await this.saveWallet(wallet, overwrite) + const ok = await this.saveWallet(wallet, overwrite); if (!ok) { throw new KeymasterError('save wallet failed'); } @@ -232,15 +273,6 @@ export default class Keymaster implements KeymasterInterface { return wallet; } - async decryptMnemonic(): Promise { - const wallet = await this.loadWallet(); - return this.getMnemonicForDerivation(wallet); - } - - async getMnemonicForDerivation(wallet: WalletFile): Promise { - return decMnemonic(wallet.seed.mnemonicEnc!, this.passphrase!); - } - async checkWallet(): Promise { const wallet = await this.loadWallet(); @@ -248,9 +280,6 @@ export default class Keymaster implements KeymasterInterface { let invalid = 0; let deleted = 0; - // Validate keys - await this.resolveSeedBank(); - for (const name of Object.keys(wallet.ids)) { try { const doc = await this.resolveDID(wallet.ids[name].did); @@ -416,165 +445,49 @@ export default class Keymaster implements KeymasterInterface { return { idsRemoved, ownedRemoved, heldRemoved, namesRemoved }; } - async resolveSeedBank(): Promise { - const keypair = await this.hdKeyPair(); - - const operation: Operation = { - type: "create", - created: new Date(0).toISOString(), - mdip: { - version: 1, - type: "agent", - registry: this.defaultRegistry, - }, - publicJwk: keypair.publicJwk, - }; - - const msgHash = this.cipher.hashJSON(operation); - const signature = this.cipher.signHash(msgHash, keypair.privateJwk); - const signed: Operation = { - ...operation, - signature: { - signed: new Date(0).toISOString(), - hash: msgHash, - value: signature - } - } - const did = await this.gatekeeper.createDID(signed); - return this.gatekeeper.resolveDID(did); - } - - async updateSeedBank(doc: MdipDocument): Promise { - const keypair = await this.hdKeyPair(); - const did = doc.didDocument?.id; - if (!did) { - throw new InvalidParameterError('seed bank missing DID'); - } - const current = await this.gatekeeper.resolveDID(did); - const previd = current.didDocumentMetadata?.versionId; - - const operation: Operation = { - type: "update", - did, - previd, - doc, - }; - - const msgHash = this.cipher.hashJSON(operation); - const signature = this.cipher.signHash(msgHash, keypair.privateJwk); - const signed = { - ...operation, - signature: { - signer: did, - signed: new Date().toISOString(), - hash: msgHash, - value: signature, - } - }; - - return await this.gatekeeper.updateDID(signed); - } - async backupWallet(registry = this.defaultRegistry, wallet?: WalletFile): Promise { - if (!wallet) { wallet = await this.loadWallet(); } - const keypair = await this.hdKeyPair(); - const seedBank = await this.resolveSeedBank(); - const msg = JSON.stringify(wallet); - const backup = this.cipher.encryptMessage(keypair.publicJwk, keypair.privateJwk, msg); - - const operation: Operation = { - type: "create", - created: new Date().toISOString(), - mdip: { - version: 1, - type: "asset", - registry: registry, - }, - controller: seedBank.didDocument?.id, - data: { backup: backup }, - }; - - const msgHash = this.cipher.hashJSON(operation); - const signature = this.cipher.signHash(msgHash, keypair.privateJwk); - - const signed: Operation = { - ...operation, - signature: { - signer: seedBank.didDocument?.id, - signed: new Date().toISOString(), - hash: msgHash, - value: signature, - } - }; - - const backupDID = await this.gatekeeper.createDID(signed); - - if (seedBank.didDocumentData && typeof seedBank.didDocumentData === 'object' && !Array.isArray(seedBank.didDocumentData)) { - const data = seedBank.didDocumentData as { wallet?: string }; - data.wallet = backupDID; - await this.updateSeedBank(seedBank); - } - + const backupDID = await this.createAsset({ backup: wallet }, { registry }); + await this.mutateWallet((current) => { + current.backupDid = backupDID; + }); return backupDID; } async recoverWallet(did?: string): Promise { try { if (!did) { - const seedBank = await this.resolveSeedBank(); - if (seedBank.didDocumentData && typeof seedBank.didDocumentData === 'object' && !Array.isArray(seedBank.didDocumentData)) { - const data = seedBank.didDocumentData as { wallet?: string }; - did = data.wallet; - } - if (!did) { - throw new InvalidParameterError('No backup DID found'); - } + const wallet = await this.loadWallet(); + did = wallet.backupDid; + } + + if (!did) { + throw new InvalidParameterError('No backup DID found'); } - const keypair = await this.hdKeyPair(); const data = await this.resolveAsset(did); if (!data) { throw new InvalidParameterError('No asset data found'); } - const castData = data as { backup?: string }; + const castData = data as { backup?: WalletFile | string }; - if (typeof castData.backup !== 'string') { - throw new InvalidParameterError('Asset "backup" is missing or not a string'); + if (castData.backup == null) { + throw new InvalidParameterError('Asset "backup" is missing'); } - const backup = this.cipher.decryptMessage(keypair.publicJwk, keypair.privateJwk, castData.backup); - let wallet = JSON.parse(backup); - - if (isV1Decrypted(wallet)) { - const mnemonic = await this.decryptMnemonic(); - // Backup might have a different mnemonic passphase so re-encrypt - wallet.seed.mnemonicEnc = await encMnemonic(mnemonic, this.passphrase); + const backup = typeof castData.backup === 'string' ? JSON.parse(castData.backup) : castData.backup; + const recovered = await this.normalizeStoredWallet(backup); + const ok = await this.store.saveWallet(recovered, true); + if (!ok) { + throw new KeymasterError('save wallet failed'); } - await this.mutateWallet(async (current) => { - // Clear all existing properties from the current wallet - // This ensures a clean slate before restoring the recovered wallet - for (const k in current) { - delete current[k as keyof StoredWallet]; - } - - // Upgrade the recovered wallet to the latest version if needed - wallet = await this.upgradeWallet(wallet); - - // Decrypt the wallet if needed - wallet = isV1WithEnc(wallet) ? await this.decryptWalletFromStorage(wallet) : wallet; - - // Copy all properties from the recovered wallet into the cleared current wallet - // This effectively replaces the current wallet with the recovered one - Object.assign(current, wallet); - }); - - return this.loadWallet(); + this._walletCache = recovered; + return recovered; } catch { // If we can't recover the wallet, just return the current one @@ -650,12 +563,6 @@ export default class Keymaster implements KeymasterInterface { return idInfo; } - async hdKeyPair(): Promise { - const wallet = await this.loadWallet(); - const hdkey = await this.getHDKeyFromCacheOrMnemonic(wallet); - return this.cipher.generateJwk(hdkey.privateKey!); - } - getPublicKeyJwk(doc: MdipDocument): EcdsaJwkPublic { // TBD Return the right public key, not just the first one if (!doc.didDocument) { @@ -672,26 +579,62 @@ export default class Keymaster implements KeymasterInterface { return publicKeyJwk; } - async fetchKeyPair(name?: string): Promise { - const wallet = await this.loadWallet(); - const id = await this.fetchIdInfo(name); - const hdkey = await this.getHDKeyFromCacheOrMnemonic(wallet); + private async getConfirmedPublicKeyJwk(id: IDInfo): Promise { const doc = await this.resolveDID(id.did, { confirm: true }); - const confirmedPublicKeyJwk = this.getPublicKeyJwk(doc); + return this.getPublicKeyJwk(doc); + } - for (let i = id.index; i >= 0; i--) { - const path = `m/44'/0'/${id.account}'/0/${i}`; - const didkey = hdkey.derive(path); - const keypair = this.cipher.generateJwk(didkey.privateKey!); + private parseVersionedKeyRef(keyRef: string): { baseKeyRef: string; version?: number } { + const hashIndex = keyRef.lastIndexOf('#'); + if (hashIndex < 0) { + return { baseKeyRef: keyRef }; + } - if (keypair.publicJwk.x === confirmedPublicKeyJwk.x && - keypair.publicJwk.y === confirmedPublicKeyJwk.y - ) { - return keypair; - } + const baseKeyRef = keyRef.slice(0, hashIndex); + const versionPart = keyRef.slice(hashIndex + 1); + const version = Number(versionPart); + + if (!Number.isInteger(version) || version < 0) { + throw new KeymasterError(`Unsupported keyRef: ${keyRef}`); } - return null; + return { baseKeyRef, version }; + } + + private incrementKeyRefVersion(keyRef: string): string { + const { baseKeyRef, version } = this.parseVersionedKeyRef(keyRef); + const nextVersion = typeof version === 'number' ? version + 1 : 1; + return `${baseKeyRef}#${nextVersion}`; + } + + private decrementKeyRefVersion(keyRef: string): string { + const { baseKeyRef, version } = this.parseVersionedKeyRef(keyRef); + const currentVersion = typeof version === 'number' ? version : 1; + + if (currentVersion <= 0) { + throw new KeymasterError(`Unsupported keyRef: ${keyRef}`); + } + + return `${baseKeyRef}#${currentVersion - 1}`; + } + + private async getActiveKeyRef(id: IDInfo): Promise { + if (!this.supportsKeyRotation(this.walletProvider)) { + return id.keyRef; + } + + const currentDoc = await this.resolveDID(id.did); + if (currentDoc.didDocumentMetadata?.confirmed !== false) { + return id.keyRef; + } + + const currentPublicJwk = this.getPublicKeyJwk(currentDoc); + const confirmedPublicJwk = await this.getConfirmedPublicKeyJwk(id); + if (this.cipher.hashJSON(currentPublicJwk) === this.cipher.hashJSON(confirmedPublicJwk)) { + return id.keyRef; + } + + return this.decrementKeyRefVersion(id.keyRef); } async createAsset( @@ -920,16 +863,16 @@ export default class Keymaster implements KeymasterInterface { } = options; const id = await this.fetchIdInfo(); - const senderKeypair = await this.fetchKeyPair(); - if (!senderKeypair) { - throw new KeymasterError('No valid sender keypair'); - } + const senderKeyRef = await this.getActiveKeyRef(id); + const senderPublicJwk = await this.getConfirmedPublicKeyJwk(id); const doc = await this.resolveDID(receiver, { confirm: true }); const receivePublicJwk = this.getPublicKeyJwk(doc); - const cipher_sender = encryptForSender ? this.cipher.encryptMessage(senderKeypair.publicJwk, senderKeypair.privateJwk, msg) : null; - const cipher_receiver = this.cipher.encryptMessage(receivePublicJwk, senderKeypair.privateJwk, msg); + const cipher_sender = encryptForSender + ? await this.walletProvider.encrypt(senderKeyRef, senderPublicJwk, msg) + : null; + const cipher_receiver = await this.walletProvider.encrypt(senderKeyRef, receivePublicJwk, msg); const cipher_hash = includeHash ? this.cipher.hashMessage(msg) : null; const encrypted: EncryptedMessage = { @@ -943,28 +886,7 @@ export default class Keymaster implements KeymasterInterface { return await this.createAsset({ encrypted }, options); } - private async decryptWithDerivedKeys(wallet: WalletFile, id: IDInfo, senderPublicJwk: EcdsaJwkPublic, ciphertext: string): Promise { - const hdkey = await this.getHDKeyFromCacheOrMnemonic(wallet); - - // Try all private keys for this ID, starting with the most recent and working backward - let index = id.index; - while (index >= 0) { - const path = `m/44'/0'/${id.account}'/0/${index}`; - const didkey = hdkey.derive(path); - const receiverKeypair = this.cipher.generateJwk(didkey.privateKey!); - try { - return this.cipher.decryptMessage(senderPublicJwk, receiverKeypair.privateJwk, ciphertext); - } - catch { - index -= 1; - } - } - - throw new KeymasterError("ID can't decrypt ciphertext"); - } - async decryptMessage(did: string): Promise { - const wallet = await this.loadWallet(); const id = await this.fetchIdInfo(); const asset = await this.resolveAsset(did); @@ -983,7 +905,7 @@ export default class Keymaster implements KeymasterInterface { const senderPublicJwk = this.getPublicKeyJwk(doc); const ciphertext = (crypt.sender === id.did && crypt.cipher_sender) ? crypt.cipher_sender : crypt.cipher_receiver; - return await this.decryptWithDerivedKeys(wallet, id, senderPublicJwk, ciphertext!); + return await this.walletProvider.decrypt(id.keyRef, senderPublicJwk, ciphertext!); } async encryptJSON( @@ -1008,7 +930,7 @@ export default class Keymaster implements KeymasterInterface { async addSignature( obj: T, - controller?: string + controller?: string, ): Promise { if (obj == null) { throw new InvalidParameterError('obj'); @@ -1016,15 +938,10 @@ export default class Keymaster implements KeymasterInterface { // Fetches current ID if name is missing const id = await this.fetchIdInfo(controller); - const keypair = await this.fetchKeyPair(controller); - - if (!keypair) { - throw new KeymasterError('addSignature: no keypair'); - } - try { const msgHash = this.cipher.hashJSON(obj); - const signature = this.cipher.signHash(msgHash, keypair.privateJwk); + const signingKeyRef = await this.getActiveKeyRef(id); + const signature = await this.walletProvider.signDigest(signingKeyRef, msgHash); return { ...obj, @@ -1376,14 +1293,13 @@ export default class Keymaster implements KeymasterInterface { ): Promise { let did = ''; await this.mutateWallet(async (wallet) => { - const account = wallet.counter; - const index = 0; - const signed = await this.createIdOperation(name, account, options); + this.validateName(name, wallet); + const createdKey = await this.walletProvider.createIdKey(); + const signed = await this.createIdOperation(name, options, createdKey); did = await this.gatekeeper.createDID(signed); - wallet.ids[name] = { did, account, index }; - wallet.counter += 1; + wallet.ids[name] = { did, keyRef: createdKey.keyRef }; wallet.current = name; }); @@ -1392,18 +1308,15 @@ export default class Keymaster implements KeymasterInterface { async createIdOperation( name: string, - account: number = 0, - options: { registry?: string } = {} + options: { registry?: string } = {}, + createdKey?: { keyRef: string; publicJwk: EcdsaJwkPublic }, ): Promise { const { registry = this.defaultRegistry } = options; const wallet = await this.loadWallet(); this.validateName(name, wallet); - const hdkey = await this.getHDKeyFromCacheOrMnemonic(wallet); - const path = `m/44'/0'/${account}'/0/0`; - const didkey = hdkey.derive(path); - const keypair = this.cipher.generateJwk(didkey.privateKey!); + const { keyRef, publicJwk } = createdKey ?? await this.walletProvider.createIdKey(); const block = await this.gatekeeper.getBlock(registry); const blockid = block?.hash; @@ -1417,11 +1330,11 @@ export default class Keymaster implements KeymasterInterface { type: 'agent', registry }, - publicJwk: keypair.publicJwk, + publicJwk, }; const msgHash = this.cipher.hashJSON(operation); - const signature = this.cipher.signHash(msgHash, keypair.privateJwk); + const signature = await this.walletProvider.signDigest(keyRef, msgHash); const signed: Operation = { ...operation, signature: { @@ -1475,20 +1388,17 @@ export default class Keymaster implements KeymasterInterface { const wallet = await this.loadWallet(); const name = id || wallet.current; const idInfo = await this.fetchIdInfo(name, wallet); - const keypair = await this.hdKeyPair(); const data = { name: name, id: idInfo, }; - const msg = JSON.stringify(data); - const backup = this.cipher.encryptMessage(keypair.publicJwk, keypair.privateJwk, msg); const doc = await this.resolveDID(idInfo.did); const registry = doc.mdip?.registry; if (!registry) { throw new InvalidParameterError('no registry found for agent DID'); } - const vaultDid = await this.createAsset({ backup: backup }, { registry, controller: name }); + const vaultDid = await this.createAsset({ backup: data }, { registry, controller: name }); if (doc.didDocumentData) { const docData = doc.didDocumentData as { vault: string }; @@ -1500,21 +1410,20 @@ export default class Keymaster implements KeymasterInterface { async recoverId(did: string): Promise { try { - const keypair = await this.hdKeyPair(); - const doc = await this.resolveDID(did); const docData = doc.didDocumentData as { vault?: string }; if (!docData.vault) { throw new InvalidDIDError('didDocumentData missing vault'); } - const vault = await this.resolveAsset(docData.vault) as { backup?: string }; - if (typeof vault.backup !== 'string') { + const vault = await this.resolveAsset(docData.vault) as { backup?: { name: string; id: IDInfo } | string }; + if (vault.backup == null) { throw new InvalidDIDError('backup not found in vault'); } - const backup = this.cipher.decryptMessage(keypair.publicJwk, keypair.privateJwk, vault.backup); - const data = JSON.parse(backup) as { name: string; id: IDInfo }; + const data = typeof vault.backup === 'string' + ? JSON.parse(vault.backup) as { name: string; id: IDInfo } + : vault.backup; await this.mutateWallet((wallet) => { if (wallet.ids[data.name]) { @@ -1522,7 +1431,6 @@ export default class Keymaster implements KeymasterInterface { } wallet.ids[data.name] = data.id; wallet.current = data.name; - wallet.counter += 1; }); return data.name; @@ -1540,14 +1448,13 @@ export default class Keymaster implements KeymasterInterface { await this.mutateWallet(async (wallet) => { const id = wallet.ids[wallet.current!]; - const nextIndex = id.index + 1; - - const hdkey = await this.getHDKeyFromCacheOrMnemonic(wallet); - const path = `m/44'/0'/${id.account}'/0/${nextIndex}`; - const didkey = hdkey.derive(path); - const keypair = this.cipher.generateJwk(didkey.privateKey!); + if (!this.supportsKeyRotation(this.walletProvider)) { + throw new KeymasterError('Wallet provider does not support key rotation.'); + } const doc = await this.resolveDID(id.did); + const currentKeyRef = id.keyRef; + const { publicJwk } = await this.walletProvider.rotateKey(currentKeyRef); if (!doc.didDocumentMetadata?.confirmed) { throw new KeymasterError('Cannot rotate keys'); @@ -1557,8 +1464,12 @@ export default class Keymaster implements KeymasterInterface { } const vmethod = doc.didDocument.verificationMethod[0]; - vmethod.id = `#key-${nextIndex + 1}`; - vmethod.publicKeyJwk = keypair.publicJwk; + if (!vmethod.publicKeyJwk) { + throw new KeymasterError('DID Document missing verificationMethod'); + } + const currentKeyCount = doc.didDocument.verificationMethod.length || 1; + vmethod.id = `#key-${currentKeyCount + 1}`; + vmethod.publicKeyJwk = publicJwk; doc.didDocument.authentication = [vmethod.id]; ok = await this.updateDID(doc); @@ -1566,7 +1477,7 @@ export default class Keymaster implements KeymasterInterface { throw new KeymasterError('Cannot rotate keys'); } - id.index = nextIndex; // persist in same mutation + id.keyRef = this.incrementKeyRefVersion(currentKeyRef); }); return ok; @@ -1741,16 +1652,14 @@ export default class Keymaster implements KeymasterInterface { const msg = JSON.stringify(signed); const id = await this.fetchIdInfo(); - const senderKeypair = await this.fetchKeyPair(); - if (!senderKeypair) { - throw new KeymasterError('No valid sender keypair'); - } + const senderKeyRef = await this.getActiveKeyRef(id); + const senderPublicJwk = await this.getConfirmedPublicKeyJwk(id); const holder = credential.credentialSubject.id; const holderDoc = await this.resolveDID(holder, { confirm: true }); const receivePublicJwk = this.getPublicKeyJwk(holderDoc); - const cipher_sender = this.cipher.encryptMessage(senderKeypair.publicJwk, senderKeypair.privateJwk, msg); - const cipher_receiver = this.cipher.encryptMessage(receivePublicJwk, senderKeypair.privateJwk, msg); + const cipher_sender = await this.walletProvider.encrypt(senderKeyRef, senderPublicJwk, msg); + const cipher_receiver = await this.walletProvider.encrypt(senderKeyRef, receivePublicJwk, msg); const msgHash = this.cipher.hashMessage(msg); const doc = await this.resolveDID(did); @@ -2796,7 +2705,7 @@ export default class Keymaster implements KeymasterInterface { async createGroupVault(options: GroupVaultOptions = {}): Promise { const id = await this.fetchIdInfo(); - const idKeypair = await this.fetchKeyPair(); + const idPublicJwk = await this.getConfirmedPublicKeyJwk(id); // version defaults to 1. To make version undefined (unit testing), set options.version to 0 const version = typeof options.version === 'undefined' ? 1 @@ -2804,8 +2713,8 @@ export default class Keymaster implements KeymasterInterface { const salt = this.cipher.generateRandomSalt(); const vaultKeypair = this.cipher.generateRandomJwk(); const keys = {}; - const config = this.cipher.encryptMessage(idKeypair!.publicJwk, vaultKeypair.privateJwk, JSON.stringify(options)); - const publicJwk = options.secretMembers ? idKeypair!.publicJwk : vaultKeypair.publicJwk; // If secret, encrypt for the owner only + const config = this.cipher.encryptMessage(idPublicJwk, vaultKeypair.privateJwk, JSON.stringify(options)); + const publicJwk = options.secretMembers ? idPublicJwk : vaultKeypair.publicJwk; // If secret, encrypt for the owner only const members = this.cipher.encryptMessage(publicJwk, vaultKeypair.privateJwk, JSON.stringify({})); const items = this.cipher.encryptMessage(vaultKeypair.publicJwk, vaultKeypair.privateJwk, JSON.stringify({})); const sha256 = this.cipher.hashJSON({}); @@ -2854,7 +2763,6 @@ export default class Keymaster implements KeymasterInterface { } private async decryptGroupVault(groupVault: GroupVault) { - const wallet = await this.loadWallet(); const id = await this.fetchIdInfo(); const myMemberId = this.generateSaltedId(groupVault, id.did); const myVaultKey = groupVault.keys[myMemberId]; @@ -2863,13 +2771,13 @@ export default class Keymaster implements KeymasterInterface { throw new KeymasterError('No access to group vault'); } - const privKeyJSON = await this.decryptWithDerivedKeys(wallet, id, groupVault.publicJwk, myVaultKey); + const privKeyJSON = await this.walletProvider.decrypt(id.keyRef, groupVault.publicJwk, myVaultKey); const privateJwk = JSON.parse(privKeyJSON) as EcdsaJwkPrivate; let config: GroupVaultOptions = {}; let isOwner = false; try { - const configJSON = await this.decryptWithDerivedKeys(wallet, id, groupVault.publicJwk, groupVault.config); + const configJSON = await this.walletProvider.decrypt(id.keyRef, groupVault.publicJwk, groupVault.config); config = JSON.parse(configJSON); isOwner = true; } @@ -2881,7 +2789,7 @@ export default class Keymaster implements KeymasterInterface { if (config.secretMembers) { try { - const membersJSON = await this.decryptWithDerivedKeys(wallet, id, groupVault.publicJwk, groupVault.members); + const membersJSON = await this.walletProvider.decrypt(id.keyRef, groupVault.publicJwk, groupVault.members); members = JSON.parse(membersJSON); } catch { @@ -2970,7 +2878,8 @@ export default class Keymaster implements KeymasterInterface { async addGroupVaultMember(vaultId: string, memberId: string): Promise { const owner = await this.checkGroupVaultOwner(vaultId); - const idKeypair = await this.fetchKeyPair(); + const id = await this.fetchIdInfo(); + const idPublicJwk = await this.getConfirmedPublicKeyJwk(id); const groupVault = await this.getGroupVault(vaultId); const { privateJwk, config, members } = await this.decryptGroupVault(groupVault); const memberDoc = await this.resolveDID(memberId, { confirm: true }); @@ -2982,7 +2891,7 @@ export default class Keymaster implements KeymasterInterface { } members[memberDID] = { added: new Date().toISOString() }; - const publicJwk = config.secretMembers ? idKeypair!.publicJwk : groupVault.publicJwk; + const publicJwk = config.secretMembers ? idPublicJwk : groupVault.publicJwk; groupVault.members = this.cipher.encryptMessage(publicJwk, privateJwk, JSON.stringify(members)); await this.addMemberKey(groupVault, memberDID, privateJwk); @@ -2992,7 +2901,8 @@ export default class Keymaster implements KeymasterInterface { async removeGroupVaultMember(vaultId: string, memberId: string): Promise { const owner = await this.checkGroupVaultOwner(vaultId); - const idKeypair = await this.fetchKeyPair(); + const id = await this.fetchIdInfo(); + const idPublicJwk = await this.getConfirmedPublicKeyJwk(id); const groupVault = await this.getGroupVault(vaultId); const { privateJwk, config, members } = await this.decryptGroupVault(groupVault); const memberDoc = await this.resolveDID(memberId, { confirm: true }); @@ -3004,7 +2914,7 @@ export default class Keymaster implements KeymasterInterface { } delete members[memberDID]; - const publicJwk = config.secretMembers ? idKeypair!.publicJwk : groupVault.publicJwk; + const publicJwk = config.secretMembers ? idPublicJwk : groupVault.publicJwk; groupVault.members = this.cipher.encryptMessage(publicJwk, privateJwk, JSON.stringify(members)); const memberKeyId = this.generateSaltedId(groupVault, memberDID); @@ -3569,11 +3479,6 @@ export default class Keymaster implements KeymasterInterface { return this.cleanupNotices(); } - async exportEncryptedWallet(): Promise { - const wallet = await this.loadWallet(); - return this.encryptWalletForStorage(wallet); - } - private async isBallot(ballotDid: string): Promise { let payload: any; try { @@ -3591,84 +3496,4 @@ export default class Keymaster implements KeymasterInterface { await this.addName(fallbackName, did); } catch { } } - - private async getHDKeyFromCacheOrMnemonic(wallet: WalletFile) { - if (this._hdkeyCache) { - return this._hdkeyCache; - } - - const mnemonic = await this.getMnemonicForDerivation(wallet); - return this.cipher.generateHDKey(mnemonic); - } - - private async encryptWalletForStorage(decrypted: WalletFile): Promise { - const { version, seed, ...rest } = decrypted; - - const safeSeed: Seed = { mnemonicEnc: seed.mnemonicEnc }; - - const hdkey = await this.getHDKeyFromCacheOrMnemonic(decrypted); - const { publicJwk, privateJwk } = this.cipher.generateJwk(hdkey.privateKey!); - - const plaintext = JSON.stringify(rest); - const enc = this.cipher.encryptMessage(publicJwk, privateJwk, plaintext); - - return { version: version!, seed: safeSeed, enc }; - } - - private async decryptWalletFromStorage(stored: WalletEncFile): Promise { - let mnemonic: string; - try { - mnemonic = await decMnemonic(stored.seed.mnemonicEnc!, this.passphrase); - } catch { - throw new KeymasterError('Incorrect passphrase.'); - } - - this._hdkeyCache = this.cipher.generateHDKey(mnemonic); - const { publicJwk, privateJwk } = this.cipher.generateJwk(this._hdkeyCache.privateKey!); - - const plaintext = this.cipher.decryptMessage(publicJwk, privateJwk, stored.enc); - const data = JSON.parse(plaintext); - - const wallet: WalletFile = { version: stored.version, seed: stored.seed, ...data }; - return wallet; - } - - private async decryptWallet(wallet: WalletFile): Promise { - if (isV1WithEnc(wallet)) { - wallet = await this.decryptWalletFromStorage(wallet); - } - - if (!isV1Decrypted(wallet)) { - throw new KeymasterError("Unsupported wallet version."); - } - - return wallet; - } - - private async encryptWallet(wallet: WalletFile): Promise { - if (isV1Decrypted(wallet)) { - return this.encryptWalletForStorage(wallet); - } - return wallet; - } - - private async upgradeWallet(wallet: any): Promise { - if (isLegacyV0(wallet)) { - const hdkey = this.cipher.generateHDKeyJSON(wallet.seed.hdkey!); - const keypair = this.cipher.generateJwk(hdkey.privateKey!); - const plaintext = this.cipher.decryptMessage(keypair.publicJwk, keypair.privateJwk, wallet.seed.mnemonic!); - const mnemonicEnc = await encMnemonic(plaintext, this.passphrase); - const { seed, version, ...rest } = wallet; - const newWallet = { version: 1, seed: { mnemonicEnc }, ...rest }; - this._hdkeyCache = this.cipher.generateHDKey(plaintext); - wallet = await this.encryptWallet(newWallet); - await this.db.saveWallet(wallet, true); - } - - if (wallet.version !== 1) { - throw new KeymasterError("Unsupported wallet version."); - } - - return wallet; - } } diff --git a/packages/keymaster/src/node.ts b/packages/keymaster/src/node.ts index d88d14789..63ed54a98 100644 --- a/packages/keymaster/src/node.ts +++ b/packages/keymaster/src/node.ts @@ -4,4 +4,5 @@ export { default as WalletJson } from './db/json.js'; export { default as WalletRedis } from './db/redis.js'; export { default as WalletMongo } from './db/mongo.js'; export { default as WalletSQLite } from './db/sqlite.js'; +export { default as MnemonicHdWalletProvider } from './provider/mnemonic-hd.js'; export { default as WalletPostgres } from './db/postgres.js'; diff --git a/packages/keymaster/src/provider/mnemonic-hd.ts b/packages/keymaster/src/provider/mnemonic-hd.ts new file mode 100644 index 000000000..3983c1ad5 --- /dev/null +++ b/packages/keymaster/src/provider/mnemonic-hd.ts @@ -0,0 +1,492 @@ +import { + InvalidParameterError, + KeymasterError, +} from '@mdip/common/errors'; +import type { + Cipher, + EcdsaJwkPair, + EcdsaJwkPublic, +} from '@mdip/cipher/types'; +import { decMnemonic, encMnemonic } from '../encryption.js'; +import { + isLegacyV0, + isV1Decrypted, + isV1WithEnc, +} from '../db/typeGuards.js'; +import type { + IDInfo, + LegacyStoredWallet, + LegacyWalletFile, + MnemonicHdWalletProviderInterface, + MnemonicHdKeyState, + MnemonicHdWalletState, + WalletFile, + WalletProviderKey, + WalletProviderStore, +} from '../types.js'; + +interface MnemonicHdWalletProviderOptions { + store: WalletProviderStore; + cipher: Cipher; + passphrase: string; +} + +function range(endInclusive: number): number[] { + return Array.from({ length: endInclusive + 1 }, (_, index) => index); +} + +export default class MnemonicHdWalletProvider implements MnemonicHdWalletProviderInterface { + readonly type = 'mnemonic-hd'; + + private readonly store: WalletProviderStore; + private readonly cipher: Cipher; + private passphrase: string; + private stateCache?: MnemonicHdWalletState; + private hdKeyCache?: any; + private mutationLock: Promise = Promise.resolve(); + + constructor(options: MnemonicHdWalletProviderOptions) { + if (!options?.store?.loadWallet || !options.store.saveWallet) { + throw new InvalidParameterError('options.store'); + } + if (!options?.cipher?.verifySig) { + throw new InvalidParameterError('options.cipher'); + } + if (!options?.passphrase) { + throw new InvalidParameterError('options.passphrase'); + } + + this.store = options.store; + this.cipher = options.cipher; + this.passphrase = options.passphrase; + } + + async newWallet(mnemonic?: string, overwrite = false): Promise { + + try { + if (!mnemonic) { + mnemonic = this.cipher.generateMnemonic(); + } + + this.hdKeyCache = this.cipher.generateHDKey(mnemonic); + } catch { + throw new InvalidParameterError('mnemonic'); + } + + const rootPublicJwk = this.getRootKeyPair().publicJwk; + + const mnemonicEnc = await encMnemonic(mnemonic, this.passphrase); + const state: MnemonicHdWalletState = { + version: 1, + type: 'mnemonic-hd', + rootPublicJwk, + mnemonicEnc, + nextAccount: 0, + rootKeyRef: 'root', + keys: { + root: { + kind: 'root', + currentIndex: 0, + knownIndices: [0], + }, + }, + }; + + const ok = await this.store.saveWallet(state, overwrite); + if (!ok) { + throw new KeymasterError('save wallet failed'); + } + + this.stateCache = state; + } + + async resetWallet(overwrite = false): Promise { + await this.newWallet(undefined, overwrite); + } + + async decryptMnemonic(): Promise { + const state = await this.loadState(); + return this.decryptProviderMnemonic(state.mnemonicEnc); + } + + async changePassphrase(mnemonic: string, newPassphrase: string): Promise { + let hdKey; + try { + hdKey = this.cipher.generateHDKey(mnemonic); + } catch { + throw new InvalidParameterError('mnemonic'); + } + + const { publicJwk } = this.cipher.generateJwk(hdKey.privateKey!); + const state = await this.loadState(); + if (this.cipher.hashJSON(publicJwk) !== this.cipher.hashJSON(state.rootPublicJwk)) { + throw new KeymasterError('Mnemonic does not match wallet.'); + } + + const mnemonicEnc = await encMnemonic(mnemonic, newPassphrase); + await this.mutateState((current) => { + current.rootPublicJwk = publicJwk; + current.mnemonicEnc = mnemonicEnc; + }); + + this.passphrase = newPassphrase; + this.hdKeyCache = hdKey; + } + + async backupWallet(): Promise { + const state = this.stateCache ?? await this.store.loadWallet(); + if (!state) { + throw new KeymasterError('Wallet provider not initialized.'); + } + + this.stateCache = state; + return this.cloneState(state); + } + + async saveWallet(wallet: MnemonicHdWalletState, overwrite = false): Promise { + if (wallet.version !== 1 || wallet.type !== this.type || !wallet.rootPublicJwk) { + throw new InvalidParameterError('wallet'); + } + + const state = this.cloneState(wallet); + const ok = await this.store.saveWallet(state, overwrite); + if (!ok) { + return false; + } + + this.stateCache = state; + this.hdKeyCache = undefined; + return true; + } + + async getFingerprint(): Promise { + await this.getHdKey(); + const publicJwk = this.getRootKeyPair().publicJwk; + return this.cipher.hashJSON({ + type: this.type, + publicJwk, + }); + } + + async createIdKey(): Promise { + let created!: WalletProviderKey; + + await this.mutateState(async (state) => { + await this.getHdKey(); + const account = state.nextAccount; + created = this.deriveNextIdKey(account); + state.keys[this.makeBaseIdKeyRef(account)] = { + kind: 'id', + account, + currentIndex: 0, + knownIndices: [0], + }; + state.nextAccount += 1; + }); + + return created; + } + + async signDigest(keyRef: string, digest: string): Promise { + await this.getHdKey(); + const keyPair = this.findKeyPairForRef(keyRef); + return this.cipher.signHash(digest, keyPair.privateJwk); + } + + async encrypt( + keyRef: string, + receiver: EcdsaJwkPublic, + plaintext: string, + ): Promise { + await this.getHdKey(); + const keyPair = this.findKeyPairForRef(keyRef); + return this.cipher.encryptMessage(receiver, keyPair.privateJwk, plaintext); + } + + async decrypt(keyRef: string, sender: EcdsaJwkPublic, ciphertext: string): Promise { + const state = await this.loadState(); + await this.getHdKey(); + const { baseKeyRef, version } = this.parseKeyRef(keyRef); + const entry = this.getIdKeyState(state, baseKeyRef); + const maxIndex = typeof version === 'number' ? version : entry.currentIndex; + + for (const index of [...entry.knownIndices] + .filter((knownIndex) => knownIndex <= maxIndex) + .sort((a, b) => b - a)) { + const keyPair = this.deriveIdKeyPair(entry.account!, index); + try { + return this.cipher.decryptMessage(sender, keyPair.privateJwk, ciphertext); + } catch { + } + } + + throw new KeymasterError("ID can't decrypt ciphertext"); + } + + async rotateKey(keyRef: string): Promise<{ publicJwk: EcdsaJwkPublic }> { + let publicJwk!: EcdsaJwkPublic; + + await this.mutateState(async (state) => { + await this.getHdKey(); + const { baseKeyRef } = this.parseKeyRef(keyRef); + const entry = this.getIdKeyState(state, baseKeyRef); + const nextIndex = entry.currentIndex + 1; + entry.currentIndex = nextIndex; + if (!entry.knownIndices.includes(nextIndex)) { + entry.knownIndices.push(nextIndex); + } + + publicJwk = this.deriveIdKeyPair(entry.account!, nextIndex).publicJwk; + }); + + return { publicJwk }; + } + + async migrateLegacyWallet(wallet: LegacyStoredWallet): Promise { + const decrypted = await this.normalizeLegacyWallet(wallet); + const mnemonic = await this.decryptProviderMnemonic(decrypted.seed.mnemonicEnc!); + const state = await this.buildStateFromLegacyWallet(decrypted, mnemonic); + this.stateCache = state; + const walletFingerprint = await this.getFingerprint(); + + const { seed, counter, version, ids, ...rest } = decrypted; + const migratedIds = Object.entries(ids).reduce>((acc, [name, legacy]) => { + const { account, index, ...info } = legacy; + acc[name] = { + ...info, + keyRef: this.makeIdKeyRef(account, index), + }; + return acc; + }, {}); + + const metadata: WalletFile = { + version: 2, + provider: { + type: this.type, + walletFingerprint, + }, + ids: migratedIds, + ...rest, + }; + return metadata; + } + + private async buildStateFromLegacyWallet(wallet: LegacyWalletFile, mnemonic: string): Promise { + const mnemonicEnc = await encMnemonic(mnemonic, this.passphrase); + this.hdKeyCache = this.cipher.generateHDKey(mnemonic); + const state: MnemonicHdWalletState = { + version: 1, + type: 'mnemonic-hd', + rootPublicJwk: this.getRootKeyPair().publicJwk, + mnemonicEnc, + nextAccount: wallet.counter, + rootKeyRef: 'root', + keys: { + root: { + kind: 'root', + currentIndex: 0, + knownIndices: [0], + }, + }, + }; + + for (const legacy of Object.values(wallet.ids)) { + state.keys[this.makeBaseIdKeyRef(legacy.account)] = { + kind: 'id', + account: legacy.account, + currentIndex: legacy.index, + knownIndices: range(legacy.index), + }; + state.nextAccount = Math.max(state.nextAccount, legacy.account + 1); + } + + this.hdKeyCache = this.cipher.generateHDKey(mnemonic); + const ok = await this.store.saveWallet(state, true); + if (!ok) { + throw new KeymasterError('save wallet failed'); + } + + return state; + } + + private async normalizeLegacyWallet(wallet: LegacyStoredWallet): Promise { + if (isLegacyV0(wallet)) { + const hdkey = this.cipher.generateHDKeyJSON(wallet.seed.hdkey!); + const keypair = this.cipher.generateJwk(hdkey.privateKey!); + const plaintext = this.cipher.decryptMessage(keypair.publicJwk, keypair.privateJwk, wallet.seed.mnemonic!); + const mnemonicEnc = await encMnemonic(plaintext, this.passphrase); + const { seed, version, ...rest } = wallet; + return { + version: 1, + seed: { mnemonicEnc }, + ...rest, + }; + } + + if (isV1WithEnc(wallet)) { + const mnemonic = await this.decryptProviderMnemonic(wallet.seed.mnemonicEnc!); + this.hdKeyCache = this.cipher.generateHDKey(mnemonic); + const root = this.getRootKeyPair(); + const plaintext = this.cipher.decryptMessage(root.publicJwk, root.privateJwk, wallet.enc); + const data = JSON.parse(plaintext); + return { + version: 1, + seed: { mnemonicEnc: wallet.seed.mnemonicEnc }, + ...data, + }; + } + + if (isV1Decrypted(wallet)) { + return wallet; + } + + throw new KeymasterError('Unsupported wallet version.'); + } + + private async decryptProviderMnemonic(mnemonicEnc: MnemonicHdWalletState['mnemonicEnc']): Promise { + try { + return await decMnemonic(mnemonicEnc, this.passphrase); + } catch { + throw new KeymasterError('Incorrect passphrase.'); + } + } + + private async mutateState(mutator: (state: MnemonicHdWalletState) => void | Promise): Promise { + const run = async () => { + const state = await this.loadState(); + const before = JSON.stringify(state); + await mutator(state); + const after = JSON.stringify(state); + + if (before === after) { + return; + } + + const ok = await this.store.saveWallet(state, true); + if (!ok) { + throw new KeymasterError('save wallet failed'); + } + + this.stateCache = state; + }; + + const chained = this.mutationLock.then(run, run); + this.mutationLock = chained.catch(() => { }); + return chained; + } + + private async loadState(): Promise { + if (this.stateCache) { + return this.stateCache; + } + + const state = await this.store.loadWallet(); + if (!state) { + await this.newWallet(); + return this.stateCache!; + } + + this.stateCache = state; + return state; + } + + private async getHdKey() { + if (this.hdKeyCache) { + return this.hdKeyCache; + } + + const state = await this.loadState(); + const mnemonic = await this.decryptProviderMnemonic(state.mnemonicEnc); + this.hdKeyCache = this.cipher.generateHDKey(mnemonic); + return this.hdKeyCache; + } + + private getRootKeyPair(): EcdsaJwkPair { + if (!this.hdKeyCache) { + throw new KeymasterError('HD wallet cache not loaded'); + } + + return this.cipher.generateJwk(this.hdKeyCache.privateKey!); + } + + private deriveNextIdKey(account: number): WalletProviderKey { + const keyRef = this.makeIdKeyRef(account, 0); + const publicJwk = this.deriveIdKeyPair(account, 0).publicJwk; + + return { keyRef, publicJwk }; + } + + private makeBaseIdKeyRef(account: number): string { + return `hd:${account}`; + } + + private makeIdKeyRef(account: number, index: number): string { + return `${this.makeBaseIdKeyRef(account)}#${index}`; + } + + private parseKeyRef(keyRef: string): { baseKeyRef: string; version?: number } { + const hashIndex = keyRef.lastIndexOf('#'); + if (hashIndex < 0) { + return { baseKeyRef: keyRef }; + } + + const baseKeyRef = keyRef.slice(0, hashIndex); + const versionPart = keyRef.slice(hashIndex + 1); + const version = Number(versionPart); + + if (!Number.isInteger(version) || version < 0) { + throw new KeymasterError(`Unknown keyRef: ${keyRef}`); + } + + return { baseKeyRef, version }; + } + + private findKeyPairForRef(keyRef: string): EcdsaJwkPair { + if (keyRef === 'root') { + return this.getRootKeyPair(); + } + + const state = this.stateCache; + if (!state) { + throw new KeymasterError('Wallet provider not initialized.'); + } + + const { baseKeyRef, version } = this.parseKeyRef(keyRef); + const entry = this.getIdKeyState(state, baseKeyRef); + const index = typeof version === 'number' ? version : entry.currentIndex; + + if (!entry.knownIndices.includes(index)) { + throw new KeymasterError(`Unknown keyRef: ${keyRef}`); + } + + return this.deriveIdKeyPair(entry.account!, index); + } + + private getIdKeyState(state: MnemonicHdWalletState, keyRef: string): MnemonicHdKeyState { + const entry = state.keys[keyRef]; + if (!entry || entry.kind !== 'id' || typeof entry.account !== 'number') { + throw new KeymasterError(`Unknown keyRef: ${keyRef}`); + } + + if (!entry.knownIndices?.length) { + entry.knownIndices = [entry.currentIndex]; + } + + return entry; + } + + private deriveIdKeyPair(account: number, index: number): EcdsaJwkPair { + if (!this.hdKeyCache) { + throw new KeymasterError('HD wallet cache not loaded'); + } + + const path = `m/44'/0'/${account}'/0/${index}`; + const didkey = this.hdKeyCache.derive(path); + return this.cipher.generateJwk(didkey.privateKey!); + } + + private cloneState(state: MnemonicHdWalletState): MnemonicHdWalletState { + return JSON.parse(JSON.stringify(state)) as MnemonicHdWalletState; + } + +} diff --git a/packages/keymaster/src/types.ts b/packages/keymaster/src/types.ts index 6f00a2603..2fc798051 100644 --- a/packages/keymaster/src/types.ts +++ b/packages/keymaster/src/types.ts @@ -10,7 +10,7 @@ export interface HDKey { xpub: string; } -export interface Seed { +export interface LegacySeed { // v0 legacy mnemonic?: string; hdkey?: HDKey; @@ -24,6 +24,17 @@ export interface Seed { } export interface IDInfo { + did: string; + // Provider-managed key reference. Rotating providers may encode a key version. + keyRef: string; + held?: string[]; + owned?: string[]; + dmail?: Record; + notices?: Record; + [key: string]: any; // Allow custom metadata fields +} + +export interface LegacyIDInfo { did: string; account: number; index: number; @@ -31,23 +42,45 @@ export interface IDInfo { owned?: string[]; dmail?: Record; notices?: Record; - [key: string]: any; // Allow custom metadata fields + [key: string]: any; } -export interface WalletEncFile { +export interface LegacyWalletEncFile { version: number; - seed: Seed; + seed: LegacySeed; enc: string } +export interface WalletProviderIdentity { + type: string; + walletFingerprint: string; +} + export interface WalletFile { + version: 2; + provider: WalletProviderIdentity; + ids: Record; + current?: string; + names?: Record; + [key: string]: any; +} + +export interface LegacyWalletFile { version?: number; - seed: Seed; + seed: LegacySeed; counter: number; - ids: Record; + ids: Record; current?: string; names?: Record; - [key: string]: any; // Allow custom metadata fields + [key: string]: any; +} + +export type LegacyStoredWallet = LegacyWalletFile | LegacyWalletEncFile; + +export interface KeymasterBackupV2 { + version: 1; + provider: WalletProviderIdentity; + store: WalletFile; } export interface CheckWalletResult { @@ -220,21 +253,80 @@ export interface GroupVaultLogin { password: string; } -export type StoredWallet = WalletFile | WalletEncFile | null; +export type StoredWallet = WalletFile | KeymasterBackupV2 | LegacyStoredWallet; + +export interface MnemonicHdKeyState { + kind: 'root' | 'id'; + account?: number; + currentIndex: number; + knownIndices: number[]; +} + +export interface MnemonicHdWalletState { + version: 1; + type: 'mnemonic-hd'; + rootPublicJwk: EcdsaJwkPublic; + mnemonicEnc: { + salt: string; + iv: string; + data: string; + }; + nextAccount: number; + rootKeyRef: string; + keys: Record; +} + +export interface MdipWalletBundle { + version: 1; + type: 'mdip-wallet-bundle'; + keymaster: WalletFile; + provider: MnemonicHdWalletState; +} -export interface WalletBase { +export interface KeymasterStore { saveWallet(wallet: StoredWallet, overwrite?: boolean): Promise; loadWallet(): Promise; } +export interface WalletProviderStore { + saveWallet(wallet: MnemonicHdWalletState, overwrite?: boolean): Promise; + loadWallet(): Promise; +} + +export interface WalletProviderKey { + // Provider-managed key reference for the created ID key version. + keyRef: string; + publicJwk: EcdsaJwkPublic; +} + +export interface WalletProvider { + readonly type: string; + getFingerprint(): Promise; + resetWallet(overwrite?: boolean): Promise; + createIdKey(): Promise; + signDigest(keyRef: string, digest: string): Promise; + encrypt(keyRef: string, receiver: EcdsaJwkPublic, plaintext: string): Promise; + decrypt(keyRef: string, sender: EcdsaJwkPublic, ciphertext: string): Promise; +} + +export interface MnemonicHdWalletProviderInterface extends WalletProvider { + rotateKey(keyRef: string): Promise<{ publicJwk: EcdsaJwkPublic }>; + newWallet(mnemonic?: string, overwrite?: boolean): Promise; + migrateLegacyWallet(wallet: LegacyStoredWallet): Promise; + backupWallet(): Promise; + saveWallet(wallet: MnemonicHdWalletState, overwrite?: boolean): Promise; + decryptMnemonic(): Promise; + changePassphrase(mnemonic: string, newPassphrase: string): Promise; +} + export interface SearchEngine { search(query: object): Promise; } export interface KeymasterOptions { - passphrase: string; gatekeeper: GatekeeperInterface; - wallet: WalletBase; + store: KeymasterStore; + walletProvider: WalletProvider; cipher: Cipher; search?: SearchEngine; defaultRegistry?: string; @@ -305,12 +397,10 @@ export interface KeymasterInterface { loadWallet(): Promise; saveWallet(wallet: StoredWallet, overwrite?: boolean): Promise; newWallet(mnemonic?: string, overwrite?: boolean): Promise; - backupWallet(): Promise; - recoverWallet(): Promise; + backupWallet(): Promise; + recoverWallet(did?: string): Promise; checkWallet(): Promise; fixWallet(): Promise; - decryptMnemonic(): Promise; - exportEncryptedWallet(): Promise; // IDs listIds(): Promise; diff --git a/python/keymaster_sdk/src/keymaster_sdk/keymaster_sdk.py b/python/keymaster_sdk/src/keymaster_sdk/keymaster_sdk.py index b0a82afd2..339092627 100644 --- a/python/keymaster_sdk/src/keymaster_sdk/keymaster_sdk.py +++ b/python/keymaster_sdk/src/keymaster_sdk/keymaster_sdk.py @@ -128,18 +128,16 @@ def backup_wallet(): "POST", f"{_keymaster_api}/wallet/backup", ) - return response["ok"] + return response["did"] -def recover_wallet(): - response = proxy_request( - "POST", - f"{_keymaster_api}/wallet/recover", - ) +def recover_wallet(did=None): + payload = {"did": did} if did else {} + response = proxy_request("POST", f"{_keymaster_api}/wallet/recover", json=payload) return response["wallet"] -def new_wallet(mnemonic, overwrite=False): +def new_wallet(mnemonic=None, overwrite=False): response = proxy_request( "POST", f"{_keymaster_api}/wallet/new", @@ -165,11 +163,10 @@ def fix_wallet(): def decrypt_mnemonic(): - response = proxy_request( - "GET", - f"{_keymaster_api}/wallet/mnemonic", + raise KeymasterError( + "decrypt_mnemonic is no longer available through the generic keymaster API. " + "Mnemonic access now belongs to the mnemonic wallet provider." ) - return response["mnemonic"] def list_registries(): diff --git a/python/keymaster_sdk/tests/test_keymaster_sdk.py b/python/keymaster_sdk/tests/test_keymaster_sdk.py index b41d0c5c6..50895e305 100644 --- a/python/keymaster_sdk/tests/test_keymaster_sdk.py +++ b/python/keymaster_sdk/tests/test_keymaster_sdk.py @@ -3,7 +3,6 @@ import random import string import base64 -from unittest.mock import ANY from copy import deepcopy # Test vars @@ -179,31 +178,29 @@ def test_accept_remove_revoke_credential(): def test_wallet(): wallet = keymaster.load_wallet() - assert "seed" in wallet, "seed not present in wallet" - assert "mnemonicEnc" in wallet["seed"], "mnemonicEnc not present in wallet" - assert "data" in wallet["seed"]["mnemonicEnc"], "data not present in mnemonicEnc" - assert "iv" in wallet["seed"]["mnemonicEnc"], "iv not present in mnemonicEnc" - assert "salt" in wallet["seed"]["mnemonicEnc"], "salt not present in mnemonicEnc" + assert_equal(wallet["version"], 2) + assert_equal(wallet["provider"]["type"], "mnemonic-hd") + assert "walletFingerprint" in wallet["provider"], "walletFingerprint not present in wallet provider" + assert "ids" in wallet, "ids not present in wallet" response = keymaster.save_wallet(wallet) assert_equal(response, True) - did = keymaster.backup_wallet() - doc = keymaster.resolve_did(did) - assert_equal(doc["didDocument"]["id"], did) - - mnemonic1 = keymaster.decrypt_mnemonic() - assert_equal(len(mnemonic1.split()), 12) - - new_wallet = keymaster.new_wallet(mnemonic1, True) + backup_did = keymaster.backup_wallet() + doc = keymaster.resolve_did(backup_did) + assert_equal(doc["didDocument"]["id"], backup_did) - mnemonic2 = keymaster.decrypt_mnemonic() - assert_equal(mnemonic1, mnemonic2) + empty_wallet = { + "version": wallet["version"], + "provider": deepcopy(wallet["provider"]), + "ids": {}, + } + response = keymaster.save_wallet(empty_wallet) + assert_equal(response, True) - recovered = keymaster.recover_wallet() + recovered = keymaster.recover_wallet(backup_did) expected = deepcopy(wallet) - expected["seed"]["mnemonicEnc"] = ANY assert_equal(expected, recovered) @@ -329,9 +326,10 @@ def test_rotate_keys(): alice = generate_id() keymaster.create_id(alice, local_options) + before = keymaster.load_wallet() keymaster.rotate_keys() - wallet = keymaster.load_wallet() - assert_equal(wallet["ids"][alice]["index"], 1) + after = keymaster.load_wallet() + assert before["ids"][alice]["keyRef"] != after["ids"][alice]["keyRef"] def test_signature(): diff --git a/sample.env b/sample.env index 78e8025f3..fffe43a0e 100644 --- a/sample.env +++ b/sample.env @@ -27,7 +27,7 @@ KC_GATEKEEPER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready # API paths to skip rate limit # Keymaster KC_KEYMASTER_PORT=4226 KC_KEYMASTER_DB=json -KC_ENCRYPTED_PASSPHRASE= +KC_WALLET_PROVIDER_PASSPHRASE= KC_WALLET_CACHE=false KC_DEFAULT_REGISTRY=hyperswarm KC_KEYMASTER_SERVE_CLIENT=true @@ -39,9 +39,6 @@ KC_KEYMASTER_RATE_LIMIT_MAX_REQUESTS=600 # Number of requests per window KC_KEYMASTER_RATE_LIMIT_WHITELIST= # Whitelist as CSV (127.0.0.1,10.0.0.0/8,2001:db8::/32) KC_KEYMASTER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready # API paths to skip rate limiter on -# Search server -KC_SEARCH_SERVER_DB=sqlite # sqlite | postgres - # Postgres KC_POSTGRES_PORT=5432 KC_POSTGRES_DB=mdip @@ -53,17 +50,17 @@ KC_POSTGRES_URL=postgresql://mdip:mdip@localhost:5432/mdip KC_REACT_WALLET_PORT=4228 # Search Server -SEARCH_SERVER_PORT=4002 -SEARCH_SERVER_GATEKEEPER_URL=http://gatekeeper:4224 -SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 -SEARCH_SERVER_DB=sqlite -SEARCH_SERVER_TRUST_PROXY=false -SEARCH_SERVER_RATE_LIMIT_ENABLED=false -SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 -SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute -SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 -SEARCH_SERVER_RATE_LIMIT_WHITELIST= -SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready +KC_SEARCH_SERVER_PORT=4002 +KC_SEARCH_SERVER_GATEKEEPER_URL=http://gatekeeper:4224 +KC_SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 +KC_SEARCH_SERVER_DB=sqlite +KC_SEARCH_SERVER_TRUST_PROXY=false +KC_SEARCH_SERVER_RATE_LIMIT_ENABLED=false +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute +KC_SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 +KC_SEARCH_SERVER_RATE_LIMIT_WHITELIST= +KC_SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready # Hyperswarm KC_HYPR_DB=sqlite # Sync store backend for hyperswarm mediator: sqlite | postgres diff --git a/scripts/keychain-cli.js b/scripts/keychain-cli.js index d4ec653f8..280408c07 100644 --- a/scripts/keychain-cli.js +++ b/scripts/keychain-cli.js @@ -184,10 +184,10 @@ program program .command('backup-wallet-file ') - .description('Backup wallet to file') + .description('Backup wallet metadata to file') .action(async (file) => { try { - const wallet = await keymaster.exportEncryptedWallet(); + const wallet = await keymaster.loadWallet(); fs.writeFileSync(file, JSON.stringify(wallet, null, 4)); console.log(UPDATE_OK); } @@ -198,7 +198,7 @@ program program .command('restore-wallet-file ') - .description('Restore wallet from backup file') + .description('Restore wallet metadata from backup file') .action(async (file) => { try { const contents = fs.readFileSync(file).toString(); @@ -211,22 +211,9 @@ program } }); -program - .command('show-mnemonic') - .description('Show recovery phrase for wallet') - .action(async () => { - try { - const mnenomic = await keymaster.decryptMnemonic(); - console.log(mnenomic); - } - catch (error) { - console.error(error.error || error); - } - }); - program .command('backup-wallet-did') - .description('Backup wallet to encrypted DID and seed bank') + .description('Backup wallet metadata to DID') .action(async () => { try { const did = await keymaster.backupWallet(); @@ -239,7 +226,7 @@ program program .command('recover-wallet-did [did]') - .description('Recover wallet from seed bank or encrypted DID') + .description('Recover wallet metadata from backup DID') .action(async (did) => { try { const wallet = await keymaster.recoverWallet(did); diff --git a/services/keymaster/server/README.md b/services/keymaster/server/README.md index a7412ec0e..8eb6350e8 100644 --- a/services/keymaster/server/README.md +++ b/services/keymaster/server/README.md @@ -11,7 +11,7 @@ This service is also useful when clients share a wallet, such as the `kc` CLI an | `KC_GATEKEEPER_URL` | http://localhost:4224 | MDIP gatekeeper service URL | | `KC_KEYMASTER_PORT` | 4226 | Service port | | `KC_KEYMASTER_DB` | json | Wallet database adapter, must be `redis`, `json`, `mongodb`, `sqlite`, or `postgres` | -| `KC_ENCRYPTED_PASSPHRASE` | (no default) | If specified, the wallet will be encrypted and decrypted with this passphrase | +| `KC_WALLET_PROVIDER_PASSPHRASE` | (no default) | Passphrase for the built-in mnemonic wallet provider state | | `KC_WALLET_CACHE` | false | Use wallet cache to increase performance (but understand security implications) | | `KC_DEFAULT_REGISTRY` | hyperswarm | Default registry to use when creating DIDs | | `KC_KEYMASTER_TRUST_PROXY` | false | If true, trust upstream proxy headers when determining client IP (`req.ip`) | diff --git a/services/keymaster/server/src/config.js b/services/keymaster/server/src/config.js index b7e69aeee..9d41cde0f 100644 --- a/services/keymaster/server/src/config.js +++ b/services/keymaster/server/src/config.js @@ -66,7 +66,7 @@ const config = { keymasterPort: process.env.KC_KEYMASTER_PORT ? parseInt(process.env.KC_KEYMASTER_PORT) : 4226, nodeID: process.env.KC_NODE_ID || '', db: process.env.KC_KEYMASTER_DB || 'json', - keymasterPassphrase: process.env.KC_ENCRYPTED_PASSPHRASE || '', + walletProviderPassphrase: process.env.KC_WALLET_PROVIDER_PASSPHRASE || process.env.KC_ENCRYPTED_PASSPHRASE || '', walletCache: process.env.KC_WALLET_CACHE ? process.env.KC_WALLET_CACHE === 'true' : false, defaultRegistry: process.env.KC_DEFAULT_REGISTRY, keymasterTrustProxy: parseBoolean(process.env.KC_KEYMASTER_TRUST_PROXY, false), diff --git a/services/keymaster/server/src/keymaster-api.ts b/services/keymaster/server/src/keymaster-api.ts index 047a24a25..38fd78321 100644 --- a/services/keymaster/server/src/keymaster-api.ts +++ b/services/keymaster/server/src/keymaster-api.ts @@ -5,9 +5,9 @@ import { fileURLToPath } from 'url'; import rateLimit from 'express-rate-limit'; import GatekeeperClient from '@mdip/gatekeeper/client'; -import Keymaster from '@mdip/keymaster'; +import Keymaster, { MnemonicHdWalletProvider } from '@mdip/keymaster'; import SearchClient from '@mdip/keymaster/search'; -import { WalletBase } from '@mdip/keymaster/types'; +import { KeymasterStore, WalletProviderStore } from '@mdip/keymaster/types'; import WalletJson from '@mdip/keymaster/wallet/json'; import WalletRedis from '@mdip/keymaster/wallet/redis'; import WalletMongo from '@mdip/keymaster/wallet/mongo'; @@ -298,20 +298,15 @@ v1router.get('/registries', async (req, res) => { * wallet: * type: object * properties: - * seed: + * version: + * type: integer + * provider: * type: object * properties: - * mnemonic: + * type: + * type: string + * walletFingerprint: * type: string - * hdkey: - * type: object - * properties: - * xpriv: - * type: string - * xpub: - * type: string - * counter: - * type: integer * ids: * type: object * additionalProperties: @@ -319,10 +314,8 @@ v1router.get('/registries', async (req, res) => { * properties: * did: * type: string - * account: - * type: integer - * index: - * type: integer + * keyRef: + * type: string * owned: * type: array * items: @@ -367,20 +360,15 @@ v1router.get('/wallet', async (req, res) => { * wallet: * type: object * properties: - * seed: + * version: + * type: integer + * provider: * type: object * properties: - * mnemonic: + * type: + * type: string + * walletFingerprint: * type: string - * hdkey: - * type: object - * properties: - * xpriv: - * type: string - * xpub: - * type: string - * counter: - * type: integer * ids: * type: object * additionalProperties: @@ -388,10 +376,8 @@ v1router.get('/wallet', async (req, res) => { * properties: * did: * type: string - * account: - * type: integer - * index: - * type: integer + * keyRef: + * type: string * owned: * type: array * items: @@ -464,20 +450,15 @@ v1router.put('/wallet', async (req, res) => { * wallet: * type: object * properties: - * seed: + * version: + * type: integer + * provider: * type: object * properties: - * mnemonic: + * type: + * type: string + * walletFingerprint: * type: string - * hdkey: - * type: object - * properties: - * xpriv: - * type: string - * xpub: - * type: string - * counter: - * type: integer * ids: * type: object * additionalProperties: @@ -485,10 +466,8 @@ v1router.put('/wallet', async (req, res) => { * properties: * did: * type: string - * account: - * type: integer - * index: - * type: integer + * keyRef: + * type: string * owned: * type: array * items: @@ -532,7 +511,7 @@ v1router.post('/wallet/new', async (req, res) => { * schema: * type: object * properties: - * ok: + * did: * type: string * description: The DID associated with the wallet backup. * 500: @@ -547,8 +526,8 @@ v1router.post('/wallet/new', async (req, res) => { */ v1router.post('/wallet/backup', async (req, res) => { try { - const ok = await keymaster.backupWallet(); - res.json({ ok }); + const did = await keymaster.backupWallet(); + res.json({ did }); } catch (error: any) { res.status(500).send({ error: error.toString() }); } @@ -570,20 +549,15 @@ v1router.post('/wallet/backup', async (req, res) => { * wallet: * type: object * properties: - * seed: + * version: + * type: integer + * provider: * type: object * properties: - * mnemonic: + * type: + * type: string + * walletFingerprint: * type: string - * hdkey: - * type: object - * properties: - * xpriv: - * type: string - * xpub: - * type: string - * counter: - * type: integer * ids: * type: object * additionalProperties: @@ -591,10 +565,8 @@ v1router.post('/wallet/backup', async (req, res) => { * properties: * did: * type: string - * account: - * type: integer - * index: - * type: integer + * keyRef: + * type: string * owned: * type: array * items: @@ -617,7 +589,7 @@ v1router.post('/wallet/backup', async (req, res) => { */ v1router.post('/wallet/recover', async (req, res) => { try { - const wallet = await keymaster.recoverWallet(); + const wallet = await keymaster.recoverWallet(req.body?.did); res.json({ wallet }); } catch (error: any) { res.status(500).send({ error: error.toString() }); @@ -712,99 +684,6 @@ v1router.post('/wallet/fix', async (req, res) => { }); -/** - * @swagger - * /wallet/mnemonic: - * get: - * summary: Decrypt and retrieve the wallet's mnemonic phrase. - * responses: - * 200: - * description: The mnemonic phrase. - * content: - * application/json: - * schema: - * type: object - * properties: - * mnemonic: - * type: string - * 500: - * description: Internal server error. - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -v1router.get('/wallet/mnemonic', async (req, res) => { - try { - const mnemonic = await keymaster.decryptMnemonic(); - res.json({ mnemonic }); - } catch (error: any) { - res.status(500).send({ error: error.toString() }); - } -}); - -/** - * @swagger - * /export/wallet/encrypted: - * get: - * summary: Export the wallet in encrypted form. - * description: > - * Returns the wallet in its encrypted format, which includes the encrypted mnemonic - * and encrypted wallet data. This format is secure for storage or backup purposes. - * responses: - * 200: - * description: The encrypted wallet object. - * content: - * application/json: - * schema: - * type: object - * properties: - * wallet: - * type: object - * properties: - * version: - * type: integer - * description: The wallet format version. - * seed: - * type: object - * properties: - * mnemonicEnc: - * type: object - * properties: - * salt: - * type: string - * description: Base64-encoded salt used for key derivation. - * iv: - * type: string - * description: Base64-encoded initialization vector for AES-GCM encryption. - * data: - * type: string - * description: Base64-encoded encrypted mnemonic. - * enc: - * type: string - * description: Encrypted wallet data (IDs, names, etc.). - * 500: - * description: Internal server error. - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -v1router.get('/export/wallet/encrypted', async (req, res) => { - try { - const wallet = await keymaster.exportEncryptedWallet(); - res.json({ wallet }); - } catch (error: any) { - res.status(500).send({ error: error.toString() }); - } -}); - /** * @swagger * /did/{id}: @@ -6277,7 +6156,7 @@ async function waitForNodeId() { } async function initWallet() { - let wallet: WalletBase; + let wallet: KeymasterStore; if (config.db === 'redis') { wallet = await WalletRedis.create(); @@ -6300,6 +6179,29 @@ async function initWallet() { return wallet; } +async function initWalletProviderStore() { + let wallet: WalletProviderStore; + const storeName = 'wallet-provider'; + + if (config.db === 'redis') { + wallet = await WalletRedis.create(storeName) as unknown as WalletProviderStore; + } else if (config.db === 'mongodb') { + wallet = await WalletMongo.create(storeName) as unknown as WalletProviderStore; + } else if (config.db === 'sqlite') { + wallet = await WalletSQLite.create('wallet-provider.db') as unknown as WalletProviderStore; + } else if (config.db === 'json') { + wallet = new WalletJson('wallet-provider.json') as unknown as WalletProviderStore; + } else { + throw new InvalidParameterError(`db=${config.db}`); + } + + if (config.walletCache) { + wallet = new WalletCache(wallet as unknown as KeymasterStore) as unknown as WalletProviderStore; + } + + return wallet; +} + const port = config.keymasterPort; const server = app.listen(port, async () => { @@ -6328,9 +6230,15 @@ const server = app.listen(port, async () => { } const wallet = await initWallet(); + const walletProviderStore = await initWalletProviderStore(); const cipher = new CipherNode(); const defaultRegistry = config.defaultRegistry; - keymaster = new Keymaster({ gatekeeper, wallet, cipher, search, defaultRegistry, passphrase: config.keymasterPassphrase }); + const walletProvider = new MnemonicHdWalletProvider({ + store: walletProviderStore, + cipher, + passphrase: config.walletProviderPassphrase, + }); + keymaster = new Keymaster({ gatekeeper, store: wallet, walletProvider, cipher, search, defaultRegistry }); log.info(`Keymaster server running on port ${port}`); log.info(`Keymaster server persisting to ${config.db}`); diff --git a/services/search-server/README.md b/services/search-server/README.md index 46bcee07e..dbb3a5342 100644 --- a/services/search-server/README.md +++ b/services/search-server/README.md @@ -23,31 +23,31 @@ Then edit the `.env` file to set your desired configuration: ```env # The port the server will run on -SEARCH_SERVER_PORT=4002 +KC_SEARCH_SERVER_PORT=4002 # URL where your Gatekeeper service is running -SEARCH_SERVER_GATEKEEPER_URL=http://localhost:4224 +KC_SEARCH_SERVER_GATEKEEPER_URL=http://localhost:4224 # How often (in ms) to poll Gatekeeper for new or updated DIDs. -SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 +KC_SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 # Database adapter: sqlite | postgres | memory -SEARCH_SERVER_DB=sqlite +KC_SEARCH_SERVER_DB=sqlite -# Used when SEARCH_SERVER_DB=postgres +# Used when KC_SEARCH_SERVER_DB=postgres # Falls back to KC_POSTGRES_URL when unset -SEARCH_SERVER_POSTGRES_URL=postgresql://mdip:mdip@localhost:5432/mdip +KC_SEARCH_SERVER_POSTGRES_URL=postgresql://mdip:mdip@localhost:5432/mdip # Trust proxy headers when determining req.ip -SEARCH_SERVER_TRUST_PROXY=false +KC_SEARCH_SERVER_TRUST_PROXY=false # API rate limiting -SEARCH_SERVER_RATE_LIMIT_ENABLED=false -SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 -SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute -SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 -SEARCH_SERVER_RATE_LIMIT_WHITELIST= -SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready +KC_SEARCH_SERVER_RATE_LIMIT_ENABLED=false +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute +KC_SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 +KC_SEARCH_SERVER_RATE_LIMIT_WHITELIST= +KC_SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready # Logging KC_LOG_LEVEL=info diff --git a/services/search-server/sample.env b/services/search-server/sample.env index f3e18baf5..c46d55388 100644 --- a/services/search-server/sample.env +++ b/services/search-server/sample.env @@ -1,13 +1,13 @@ -SEARCH_SERVER_PORT=4002 -SEARCH_SERVER_GATEKEEPER_URL=http://localhost:4224 -SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 -SEARCH_SERVER_DB=sqlite -SEARCH_SERVER_POSTGRES_URL=postgresql://mdip:mdip@localhost:5432/mdip -SEARCH_SERVER_TRUST_PROXY=false -SEARCH_SERVER_RATE_LIMIT_ENABLED=false -SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 -SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute -SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 -SEARCH_SERVER_RATE_LIMIT_WHITELIST= -SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready +KC_SEARCH_SERVER_PORT=4002 +KC_SEARCH_SERVER_GATEKEEPER_URL=http://localhost:4224 +KC_SEARCH_SERVER_REFRESH_INTERVAL_MS=5000 +KC_SEARCH_SERVER_DB=sqlite +KC_SEARCH_SERVER_POSTGRES_URL=postgresql://mdip:mdip@localhost:5432/mdip +KC_SEARCH_SERVER_TRUST_PROXY=false +KC_SEARCH_SERVER_RATE_LIMIT_ENABLED=false +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE=1 +KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT=minute +KC_SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS=600 +KC_SEARCH_SERVER_RATE_LIMIT_WHITELIST= +KC_SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS=/api/v1/ready KC_LOG_LEVEL=info diff --git a/services/search-server/src/config.ts b/services/search-server/src/config.ts index 9bb569e7e..e4854f33b 100644 --- a/services/search-server/src/config.ts +++ b/services/search-server/src/config.ts @@ -57,23 +57,23 @@ function parseCsv(value: string | undefined): string[] { .filter(Boolean); } -const configuredSkipPaths = parseCsv(process.env.SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS); +const configuredSkipPaths = parseCsv(process.env.KC_SEARCH_SERVER_RATE_LIMIT_SKIP_PATHS); const config = { - port: parsePositiveInteger(process.env.SEARCH_SERVER_PORT, 4002), - gatekeeperURL: process.env.SEARCH_SERVER_GATEKEEPER_URL || 'http://localhost:4224', - refreshIntervalMs: parsePositiveInteger(process.env.SEARCH_SERVER_REFRESH_INTERVAL_MS, 5000), - db: process.env.SEARCH_SERVER_DB || 'sqlite', - postgresURL: process.env.SEARCH_SERVER_POSTGRES_URL + port: parsePositiveInteger(process.env.KC_SEARCH_SERVER_PORT, 4002), + gatekeeperURL: process.env.KC_SEARCH_SERVER_GATEKEEPER_URL || 'http://localhost:4224', + refreshIntervalMs: parsePositiveInteger(process.env.KC_SEARCH_SERVER_REFRESH_INTERVAL_MS, 5000), + db: process.env.KC_SEARCH_SERVER_DB || 'sqlite', + postgresURL: process.env.KC_SEARCH_SERVER_POSTGRES_URL || process.env.KC_POSTGRES_URL || 'postgresql://mdip:mdip@localhost:5432/mdip', - trustProxy: parseBoolean(process.env.SEARCH_SERVER_TRUST_PROXY, false), + trustProxy: parseBoolean(process.env.KC_SEARCH_SERVER_TRUST_PROXY, false), jsonLimit: '2mb', - rateLimitEnabled: parseBoolean(process.env.SEARCH_SERVER_RATE_LIMIT_ENABLED, false), - rateLimitWindowValue: parsePositiveInteger(process.env.SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE, 1), - rateLimitWindowUnit: parseWindowUnit(process.env.SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT), - rateLimitMaxRequests: parsePositiveInteger(process.env.SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS, 600), - rateLimitWhitelist: parseCsv(process.env.SEARCH_SERVER_RATE_LIMIT_WHITELIST), + rateLimitEnabled: parseBoolean(process.env.KC_SEARCH_SERVER_RATE_LIMIT_ENABLED, false), + rateLimitWindowValue: parsePositiveInteger(process.env.KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_VALUE, 1), + rateLimitWindowUnit: parseWindowUnit(process.env.KC_SEARCH_SERVER_RATE_LIMIT_WINDOW_UNIT), + rateLimitMaxRequests: parsePositiveInteger(process.env.KC_SEARCH_SERVER_RATE_LIMIT_MAX_REQUESTS, 600), + rateLimitWhitelist: parseCsv(process.env.KC_SEARCH_SERVER_RATE_LIMIT_WHITELIST), rateLimitSkipPaths: configuredSkipPaths.length > 0 ? configuredSkipPaths : DEFAULT_RATE_LIMIT_SKIP_PATHS, }; diff --git a/start-node-ci b/start-node-ci index ef43ad06c..282659825 100755 --- a/start-node-ci +++ b/start-node-ci @@ -39,6 +39,15 @@ while true; do if [[ "$RETRY_COUNT" -ge "$MAX_RETRIES" ]]; then echo "❌ Timed out waiting for containers to be 'Up'" docker compose ps + + RUNNING_SERVICES=$(docker compose "${PROFILE_ARGS[@]}" ps --services --status running "${SERVICES[@]}") + for service in "${SERVICES[@]}"; do + if ! grep -qx "$service" <<< "$RUNNING_SERVICES"; then + echo "----- logs: $service -----" + docker compose logs --no-color "$service" || true + fi + done + exit 1 fi diff --git a/tests/cli-tests/generate_test_env.sh b/tests/cli-tests/generate_test_env.sh index 76014874d..beebdda04 100755 --- a/tests/cli-tests/generate_test_env.sh +++ b/tests/cli-tests/generate_test_env.sh @@ -15,8 +15,8 @@ cat > "$ENV_FILE" < Add a member to a group vault add-name Add a name for a DID backup-id Backup the current ID to its registry - backup-wallet-did Backup wallet to encrypted DID and seed bank - backup-wallet-file Backup wallet to file + backup-wallet-did Backup wallet metadata to DID + backup-wallet-file Backup wallet metadata to file bind-credential Create bound credential for a user check-wallet Validate DIDs in wallet clone-asset [options] Clone an asset @@ -89,7 +89,7 @@ Commands: publish-credential Publish the existence of a credential to the current user manifest publish-poll Publish results to poll, hiding ballots recover-id Recovers the ID from the DID - recover-wallet-did [did] Recover wallet from seed bank or encrypted DID + recover-wallet-did [did] Recover wallet metadata from backup DID remove-group-member Remove a member from a group remove-group-vault-item Remove an item from a group vault remove-group-vault-member Remove a member from a group vault @@ -99,14 +99,13 @@ Commands: resolve-did [confirm] Return document associated with DID resolve-did-version Return specified version of document associated with DID resolve-id Resolves the current ID - restore-wallet-file Restore wallet from backup file + restore-wallet-file Restore wallet metadata from backup file reveal-credential Reveal a credential to the current user manifest reveal-poll Publish results to poll, revealing ballots revoke-credential Revokes a verifiable credential revoke-did Permanently revoke a DID rotate-keys Generates new set of keys for current ID set-property [value] Assign a key-value pair to an asset - show-mnemonic Show recovery phrase for wallet show-wallet Show wallet sign-file Sign a JSON file test-group [member] Determine if a member is in a group diff --git a/tests/keymaster/asset.test.ts b/tests/keymaster/asset.test.ts index 565729684..5f119f4bc 100644 --- a/tests/keymaster/asset.test.ts +++ b/tests/keymaster/asset.test.ts @@ -2,13 +2,12 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -26,9 +25,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createAsset', () => { diff --git a/tests/keymaster/challenge.test.ts b/tests/keymaster/challenge.test.ts index e856c4509..f0846a50d 100644 --- a/tests/keymaster/challenge.test.ts +++ b/tests/keymaster/challenge.test.ts @@ -2,14 +2,13 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { mockSchema } from './helper.ts'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -27,9 +26,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createChallenge', () => { diff --git a/tests/keymaster/client.test.ts b/tests/keymaster/client.test.ts index 54f4b3c04..4b6051e9e 100644 --- a/tests/keymaster/client.test.ts +++ b/tests/keymaster/client.test.ts @@ -1,7 +1,7 @@ import nock from 'nock'; import KeymasterClient from '@mdip/keymaster/client'; import { ExpectedExceptionError } from '@mdip/common/errors'; -import {Seed, WalletEncFile, WalletFile} from "@mdip/keymaster/types"; +import { WalletFile } from "@mdip/keymaster/types"; const KeymasterURL = 'http://keymaster.org'; const ServerError = { message: 'Server error' }; @@ -13,7 +13,6 @@ const Endpoints = { wallet_recover: '/api/v1/wallet/recover', wallet_check: '/api/v1/wallet/check', wallet_fix: '/api/v1/wallet/fix', - wallet_mnemonic: '/api/v1/wallet/mnemonic', registries: '/api/v1/registries', ids: '/api/v1/ids', ids_current: '/api/v1/ids/current', @@ -43,7 +42,6 @@ const Endpoints = { groupVaults: `/api/v1/groupVaults`, dmail: '/api/v1/dmail', notices: '/api/v1/notices', - export_wallet_encrypted: '/api/v1/export/wallet/encrypted', }; const mockConsole = { @@ -193,7 +191,14 @@ describe('loadWallet', () => { }); describe('saveWallet', () => { - const mockWallet: WalletFile = { seed: {} as Seed, counter: 0, ids: {} }; + const mockWallet: WalletFile = { + version: 2, + provider: { + type: 'mnemonic-hd', + walletFingerprint: 'fingerprint', + }, + ids: {}, + }; it('should save wallet', async () => { nock(KeymasterURL) @@ -258,12 +263,12 @@ describe('backupWallet', () => { it('should backup wallet', async () => { nock(KeymasterURL) .post(Endpoints.wallet_backup) - .reply(200, { ok: true }); + .reply(200, { did: 'did:test:backup' }); const keymaster = await KeymasterClient.create({ url: KeymasterURL }); - const ok = await keymaster.backupWallet(); + const did = await keymaster.backupWallet(); - expect(ok).toStrictEqual(true); + expect(did).toStrictEqual('did:test:backup'); }); it('should throw exception on backupWallet server error', async () => { @@ -372,37 +377,6 @@ describe('fixWallet', () => { }); }); -describe('decryptMnemonic', () => { - const mockMnemonic = 'mock mnemonic phrase'; - - it('should decrypt mnemonic', async () => { - nock(KeymasterURL) - .get(Endpoints.wallet_mnemonic) - .reply(200, { mnemonic: mockMnemonic }); - - const keymaster = await KeymasterClient.create({ url: KeymasterURL }); - const mnemonic = await keymaster.decryptMnemonic(); - - expect(mnemonic).toStrictEqual(mockMnemonic); - }); - - it('should throw exception on decryptMnemonic server error', async () => { - nock(KeymasterURL) - .get(Endpoints.wallet_mnemonic) - .reply(500, ServerError); - - const keymaster = await KeymasterClient.create({ url: KeymasterURL }); - - try { - await keymaster.decryptMnemonic(); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe(ServerError.message); - } - }); -}); - describe('listRegistries', () => { const mockRegistries = ['local', 'hyperswarm']; @@ -3534,44 +3508,3 @@ describe('refreshNotices', () => { } }); }); - -describe('exportEncryptedWallet', () => { - const mockEncWallet: WalletEncFile = { - version: 1, - seed: { - mnemonicEnc: { - salt: 'salt==', - iv: 'iviviviv', - data: 'ciphertext' - } - }, - enc: 'top-level-seal' - }; - - it('should export encrypted wallet', async () => { - nock(KeymasterURL) - .get(Endpoints.export_wallet_encrypted) - .reply(200, { wallet: mockEncWallet }); - - const keymaster = await KeymasterClient.create({ url: KeymasterURL }); - const wallet = await keymaster.exportEncryptedWallet(); - - expect(wallet).toStrictEqual(mockEncWallet); - }); - - it('should throw exception on exportEncryptedWallet server error', async () => { - nock(KeymasterURL) - .get(Endpoints.export_wallet_encrypted) - .reply(500, ServerError); - - const keymaster = await KeymasterClient.create({ url: KeymasterURL }); - - try { - await keymaster.exportEncryptedWallet(); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe(ServerError.message); - } - }); -}); diff --git a/tests/keymaster/credential.test.ts b/tests/keymaster/credential.test.ts index 906742fb4..d7c06fc15 100644 --- a/tests/keymaster/credential.test.ts +++ b/tests/keymaster/credential.test.ts @@ -3,15 +3,14 @@ import Keymaster from '@mdip/keymaster'; import { VerifiableCredential } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { copyJSON } from '@mdip/common/utils'; import { InvalidDIDError, ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { TestHelper, mockJson, mockSchema } from './helper.ts'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; let helper: TestHelper; @@ -30,9 +29,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); helper = new TestHelper(keymaster); }); diff --git a/tests/keymaster/crypto.test.ts b/tests/keymaster/crypto.test.ts index 9ee097db2..f89c4412f 100644 --- a/tests/keymaster/crypto.test.ts +++ b/tests/keymaster/crypto.test.ts @@ -3,13 +3,12 @@ import Keymaster from '@mdip/keymaster'; import { EncryptedMessage } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -27,9 +26,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); function generateRandomString(length: number) { diff --git a/tests/keymaster/dmail.test.ts b/tests/keymaster/dmail.test.ts index 61df0434c..19301cd71 100644 --- a/tests/keymaster/dmail.test.ts +++ b/tests/keymaster/dmail.test.ts @@ -2,14 +2,13 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster, { DmailTags } from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { DmailMessage, NoticeMessage } from '@mdip/keymaster/types'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -27,9 +26,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('verifyTagList', () => { diff --git a/tests/keymaster/document.test.ts b/tests/keymaster/document.test.ts index 1f2689a83..9adb2e1cd 100644 --- a/tests/keymaster/document.test.ts +++ b/tests/keymaster/document.test.ts @@ -2,13 +2,12 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import HeliaClient from '@mdip/ipfs/helia'; import { generateCID } from '@mdip/ipfs/utils'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -26,9 +25,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createDocument', () => { diff --git a/tests/keymaster/group-vault.test.ts b/tests/keymaster/group-vault.test.ts index f022c6c80..d02d2003d 100644 --- a/tests/keymaster/group-vault.test.ts +++ b/tests/keymaster/group-vault.test.ts @@ -7,13 +7,12 @@ import { } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError, UnknownIDError, InvalidParameterError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -31,9 +30,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createGroupVault', () => { diff --git a/tests/keymaster/group.test.ts b/tests/keymaster/group.test.ts index 4eacd648b..1f25fe3e0 100644 --- a/tests/keymaster/group.test.ts +++ b/tests/keymaster/group.test.ts @@ -2,13 +2,12 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { InvalidDIDError, ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -26,9 +25,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createGroup', () => { diff --git a/tests/keymaster/id.test.ts b/tests/keymaster/id.test.ts index dc52aa357..2e7dc9e44 100644 --- a/tests/keymaster/id.test.ts +++ b/tests/keymaster/id.test.ts @@ -5,12 +5,15 @@ import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { InvalidDIDError, ExpectedExceptionError, UnknownIDError, InvalidParameterError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import MnemonicHdWalletProvider from '../../packages/keymaster/src/provider/mnemonic-hd.ts'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; +let store: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; +let walletProvider: MnemonicHdWalletProvider; beforeAll(async () => { ipfs = new HeliaClient(); @@ -26,9 +29,9 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); + store = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster, walletProvider } = createTestKeymaster(gatekeeper, cipher, { store })); }); describe('createId', () => { @@ -60,11 +63,11 @@ describe('createId', () => { it('should create a new ID on customized default registry', async () => { const defaultRegistry = 'TFTC'; - const keymaster = new Keymaster({ gatekeeper, wallet, cipher, defaultRegistry, passphrase: 'passphrase' }); + const { keymaster: customKeymaster } = createTestKeymaster(gatekeeper, cipher, { store, defaultRegistry }); const name = 'Bob'; - const did = await keymaster.createId(name); - const doc = await keymaster.resolveDID(did); + const did = await customKeymaster.createId(name); + const doc = await customKeymaster.resolveDID(did); expect(doc.mdip!.registry).toBe(defaultRegistry); }); @@ -185,7 +188,7 @@ describe('createIdOperation', () => { it('should create operation with custom registry', async () => { const name = 'Alice'; const registry = 'TFTC'; - const operation = await keymaster.createIdOperation(name, 0, { registry }); + const operation = await keymaster.createIdOperation(name, { registry }); expect(operation.mdip!.registry).toBe(registry); expect(operation.type).toBe('create'); @@ -196,7 +199,7 @@ describe('createIdOperation', () => { it('should create operation with local registry', async () => { const name = 'Charlie'; const registry = 'local'; - const operation = await keymaster.createIdOperation(name, 0, { registry }); + const operation = await keymaster.createIdOperation(name, { registry }); expect(operation.mdip!.registry).toBe(registry); expect(operation.type).toBe('create'); @@ -207,12 +210,10 @@ describe('createIdOperation', () => { it('should create operation without modifying wallet', async () => { const name = 'Dave'; const walletBefore = await keymaster.loadWallet(); - const counterBefore = walletBefore.counter; await keymaster.createIdOperation(name); const walletAfter = await keymaster.loadWallet(); - expect(walletAfter.counter).toBe(counterBefore); expect(walletAfter.ids[name]).toBeUndefined(); expect(walletAfter.current).toBe(walletBefore.current); }); @@ -286,13 +287,7 @@ describe('createIdOperation', () => { it('should use customized default registry when none specified', async () => { const defaultRegistry = 'local'; - const customKeymaster = new Keymaster({ - gatekeeper, - wallet, - cipher, - defaultRegistry, - passphrase: 'passphrase' - }); + const { keymaster: customKeymaster } = createTestKeymaster(gatekeeper, cipher, { store, defaultRegistry }); const name = 'Henry'; const operation = await customKeymaster.createIdOperation(name); @@ -406,7 +401,17 @@ describe('backupId', () => { const vault = await keymaster.resolveDID((doc.didDocumentData! as { vault: string }).vault); expect(ok).toBe(true); - expect((vault.didDocumentData as { backup: string }).backup.length > 0).toBe(true); + expect(vault.didDocumentData).toEqual( + expect.objectContaining({ + backup: expect.objectContaining({ + name: 'Bob', + id: expect.objectContaining({ + did: expect.any(String), + keyRef: expect.any(String), + }), + }), + }) + ); }); it('should backup a non-current ID', async () => { @@ -418,7 +423,17 @@ describe('backupId', () => { const vault = await keymaster.resolveDID((doc.didDocumentData! as { vault: string }).vault); expect(ok).toBe(true); - expect((vault.didDocumentData as { backup: string }).backup.length > 0).toBe(true); + expect(vault.didDocumentData).toEqual( + expect.objectContaining({ + backup: expect.objectContaining({ + name: 'Alice', + id: expect.objectContaining({ + did: aliceDid, + keyRef: expect.any(String), + }), + }), + }) + ); }); }); @@ -428,20 +443,20 @@ describe('recoverId', () => { const did = await keymaster.createId(name); let wallet = await keymaster.loadWallet(); const bob = JSON.parse(JSON.stringify(wallet.ids['Bob'])); - const mnemonic = await keymaster.decryptMnemonic(); + const providerBackup = await walletProvider.backupWallet(); await keymaster.backupId(); // reset wallet - await keymaster.newWallet(mnemonic, true); + await keymaster.newWallet(undefined, true); wallet = await keymaster.loadWallet(); expect(wallet.ids).toStrictEqual({}); + await walletProvider.saveWallet(providerBackup, true); await keymaster.recoverId(did); wallet = await keymaster.loadWallet(); expect(wallet.ids[name]).toStrictEqual(bob); expect(wallet.current).toBe(name); - expect(wallet.counter).toBe(1); }); it('should not overwrite an id with the same name', async () => { @@ -456,22 +471,6 @@ describe('recoverId', () => { expect(error.message).toBe('Keymaster: Bob already exists in wallet'); } }); - - it('should not recover an id to a different wallet', async () => { - const did = await keymaster.createId('Bob'); - await keymaster.backupId(); - - // reset to a different wallet - await keymaster.newWallet(undefined, true); - - try { - await keymaster.recoverId(did); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe(InvalidDIDError.type); - } - }); }); describe('testAgent', () => { diff --git a/tests/keymaster/image.test.ts b/tests/keymaster/image.test.ts index 15a80b070..e7b3ee11b 100644 --- a/tests/keymaster/image.test.ts +++ b/tests/keymaster/image.test.ts @@ -3,14 +3,13 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { generateCID } from '@mdip/ipfs/utils'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -28,9 +27,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createImage', () => { diff --git a/tests/keymaster/legacy-migration-provider-gate.test.ts b/tests/keymaster/legacy-migration-provider-gate.test.ts new file mode 100644 index 000000000..89ad3fd9a --- /dev/null +++ b/tests/keymaster/legacy-migration-provider-gate.test.ts @@ -0,0 +1,102 @@ +import Keymaster from '@mdip/keymaster'; +import type { + LegacyWalletFile, + WalletProvider, + WalletProviderKey, +} from '@mdip/keymaster/types'; +import type { EcdsaJwkPublic } from '@mdip/cipher/types'; +import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; + +const gatekeeper = { + createDID: async () => 'did:test:stub', + listRegistries: async () => [], +} as any; + +const cipher = { + verifySig: () => true, +} as any; + +class DummyWalletProvider implements WalletProvider { + readonly type = 'dummy'; + called = false; + + private fail(): never { + this.called = true; + throw new Error('provider should not be called'); + } + + async getFingerprint(): Promise { + return this.fail(); + } + + async resetWallet(_overwrite: boolean = false): Promise { + return this.fail(); + } + + async createIdKey(): Promise { + return this.fail(); + } + + async getPublicKey(_keyRef: string): Promise { + return this.fail(); + } + + async signDigest(_keyRef: string, _digest: string): Promise { + return this.fail(); + } + + async encrypt(_keyRef: string, _receiver: EcdsaJwkPublic, _plaintext: string): Promise { + return this.fail(); + } + + async decrypt(_keyRef: string, _sender: EcdsaJwkPublic, _ciphertext: string): Promise { + return this.fail(); + } + + async rotateKey(_keyRef: string): Promise<{ publicJwk: EcdsaJwkPublic }> { + return this.fail(); + } + +} + +const legacyWallet: LegacyWalletFile = { + version: 1, + seed: { + mnemonicEnc: { + salt: 'salt', + iv: 'iv', + data: 'data', + }, + }, + counter: 1, + ids: { + alice: { + did: 'did:test:alice', + account: 0, + index: 0, + }, + }, + current: 'alice', +}; + +describe('legacy wallet migration provider gate', () => { + it('rejects legacy wallets when the active provider is not MnemonicHdWalletProvider', async () => { + const store = new WalletJsonMemory(); + const provider = new DummyWalletProvider(); + + await store.saveWallet(legacyWallet, true); + + const keymaster = new Keymaster({ + gatekeeper, + store, + walletProvider: provider, + cipher, + }); + + await expect(keymaster.loadWallet()).rejects.toThrow( + 'Keymaster: Legacy wallet migration requires MnemonicHdWalletProvider.' + ); + expect(provider.called).toBe(false); + expect(await store.loadWallet()).toEqual(legacyWallet); + }); +}); diff --git a/tests/keymaster/name.test.ts b/tests/keymaster/name.test.ts index ac1e4c60e..17b0b950f 100644 --- a/tests/keymaster/name.test.ts +++ b/tests/keymaster/name.test.ts @@ -2,13 +2,12 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -26,9 +25,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('addName', () => { diff --git a/tests/keymaster/notice.test.ts b/tests/keymaster/notice.test.ts index bda40d103..915d0c51c 100644 --- a/tests/keymaster/notice.test.ts +++ b/tests/keymaster/notice.test.ts @@ -5,7 +5,8 @@ import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; -import { NoticeMessage, SearchEngine } from '@mdip/keymaster/types'; +import { NoticeMessage, SearchEngine, WalletProviderStore } from '@mdip/keymaster/types'; +import { createTestKeymaster } from './testUtils.ts'; class MockSearch implements SearchEngine { private results: string[] = []; @@ -30,7 +31,8 @@ class MockSearch implements SearchEngine { let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; +let store: WalletJsonMemory; +let providerStore: WalletProviderStore; let cipher: CipherNode; let keymaster: Keymaster; let search: MockSearch; @@ -49,10 +51,9 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); search = new MockSearch(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, search, passphrase: 'passphrase' }); + ({ keymaster, store, providerStore } = createTestKeymaster(gatekeeper, cipher, { search })); }); describe('verifyNotice', () => { @@ -418,7 +419,7 @@ describe('searchNotices', () => { }); it('should return false if search engine not configured', async () => { - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher, { store, providerStore })); await keymaster.createId('Alice'); const ok = await keymaster.searchNotices(); diff --git a/tests/keymaster/poll.test.ts b/tests/keymaster/poll.test.ts index 1b4bdfe30..1ebe5644a 100644 --- a/tests/keymaster/poll.test.ts +++ b/tests/keymaster/poll.test.ts @@ -3,13 +3,12 @@ import Keymaster from '@mdip/keymaster'; import { Poll } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { InvalidDIDError, ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -27,9 +26,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('pollTemplate', () => { diff --git a/tests/keymaster/response.test.ts b/tests/keymaster/response.test.ts index cc96fb3ce..3e590a811 100644 --- a/tests/keymaster/response.test.ts +++ b/tests/keymaster/response.test.ts @@ -3,14 +3,13 @@ import Keymaster from '@mdip/keymaster'; import { ChallengeResponse } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { InvalidDIDError, ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { mockSchema } from './helper.ts'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -28,9 +27,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createResponse', () => { diff --git a/tests/keymaster/schema.test.ts b/tests/keymaster/schema.test.ts index 2231ab599..516ca8ad0 100644 --- a/tests/keymaster/schema.test.ts +++ b/tests/keymaster/schema.test.ts @@ -2,14 +2,13 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; -import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { mockSchema } from './helper.ts'; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; let cipher: CipherNode; let keymaster: Keymaster; @@ -27,9 +26,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'passphrase' }); + ({ keymaster } = createTestKeymaster(gatekeeper, cipher)); }); describe('createSchema', () => { diff --git a/tests/keymaster/testUtils.ts b/tests/keymaster/testUtils.ts index 94897a7bd..a9e577e14 100644 --- a/tests/keymaster/testUtils.ts +++ b/tests/keymaster/testUtils.ts @@ -1,5 +1,52 @@ +import Gatekeeper from '@mdip/gatekeeper'; +import Keymaster from '@mdip/keymaster'; +import type { SearchEngine, WalletProviderStore } from '@mdip/keymaster/types'; +import CipherNode from '@mdip/cipher/node'; +import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; +import MnemonicHdWalletProvider from '../../packages/keymaster/src/provider/mnemonic-hd.ts'; + type CryptoLike = typeof globalThis.crypto | undefined; +export const TEST_PASSPHRASE = 'passphrase'; + +export function createProviderStore(): WalletProviderStore { + return new WalletJsonMemory() as unknown as WalletProviderStore; +} + +export function createMnemonicWalletProvider( + cipher: CipherNode, + passphrase: string = TEST_PASSPHRASE, + store: WalletProviderStore = createProviderStore(), +): MnemonicHdWalletProvider { + return new MnemonicHdWalletProvider({ store, cipher, passphrase }); +} + +export function createTestKeymaster( + gatekeeper: Gatekeeper, + cipher: CipherNode, + options: { + store?: WalletJsonMemory; + providerStore?: WalletProviderStore; + passphrase?: string; + defaultRegistry?: string; + search?: SearchEngine; + } = {}, +) { + const store = options.store ?? new WalletJsonMemory(); + const providerStore = options.providerStore ?? createProviderStore(); + const walletProvider = createMnemonicWalletProvider(cipher, options.passphrase, providerStore); + const keymaster = new Keymaster({ + gatekeeper, + store, + walletProvider, + cipher, + defaultRegistry: options.defaultRegistry, + search: options.search, + }); + + return { keymaster, store, providerStore, walletProvider }; +} + export function disableSubtle(): () => void { const originalDesc = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); const originalCrypto: CryptoLike = originalDesc?.value; diff --git a/tests/keymaster/utils.test.ts b/tests/keymaster/utils.test.ts index b3e385e1a..cc999ed2d 100644 --- a/tests/keymaster/utils.test.ts +++ b/tests/keymaster/utils.test.ts @@ -6,15 +6,15 @@ import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; import { ExpectedExceptionError, UnknownIDError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; import { MdipDocument } from "@mdip/gatekeeper/types"; +import { createTestKeymaster } from './testUtils.ts'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; +let store: WalletJsonMemory; +let walletProvider: any; let cipher: CipherNode; let keymaster: Keymaster; -const PASSPHRASE = 'passphrase'; - beforeAll(async () => { ipfs = new HeliaClient(); await ipfs.start(); @@ -29,9 +29,8 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: PASSPHRASE }); + ({ keymaster, store, walletProvider } = createTestKeymaster(gatekeeper, cipher)); }); describe('constructor', () => { @@ -48,7 +47,7 @@ describe('constructor', () => { try { // @ts-expect-error Testing invalid usage, missing gatekeeper arg - new Keymaster({ wallet, cipher, passphrase: PASSPHRASE }); + new Keymaster({ store, walletProvider, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { @@ -56,17 +55,17 @@ describe('constructor', () => { } try { - // @ts-expect-error Testing invalid usage, missing wallet arg - new Keymaster({ gatekeeper, cipher, passphrase: PASSPHRASE }); + // @ts-expect-error Testing invalid usage, missing store arg + new Keymaster({ gatekeeper, walletProvider, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { - expect(error.message).toBe('Invalid parameter: options.wallet'); + expect(error.message).toBe('Invalid parameter: options.store'); } try { // @ts-expect-error Testing invalid usage, missing cipher arg - new Keymaster({ gatekeeper, wallet, passphrase: PASSPHRASE }); + new Keymaster({ gatekeeper, store, walletProvider }); throw new ExpectedExceptionError(); } catch (error: any) { @@ -75,7 +74,7 @@ describe('constructor', () => { try { // @ts-expect-error Testing invalid usage, invalid gatekeeper arg - new Keymaster({ gatekeeper: {}, wallet, cipher, passphrase: PASSPHRASE }); + new Keymaster({ gatekeeper: {}, store, walletProvider, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { @@ -83,44 +82,44 @@ describe('constructor', () => { } try { - // @ts-expect-error Testing invalid usage, invalid wallet arg - new Keymaster({ gatekeeper, wallet: {}, cipher, passphrase: PASSPHRASE }); + // @ts-expect-error Testing invalid usage, invalid store arg + new Keymaster({ gatekeeper, store: {}, walletProvider, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { - expect(error.message).toBe('Invalid parameter: options.wallet'); + expect(error.message).toBe('Invalid parameter: options.store'); } try { - // @ts-expect-error Testing invalid usage, invalid cipher arg - new Keymaster({ gatekeeper, wallet, cipher: {}, passphrase: PASSPHRASE }); + // @ts-expect-error Testing invalid usage, invalid walletProvider arg + new Keymaster({ gatekeeper, store, walletProvider: {}, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { - expect(error.message).toBe('Invalid parameter: options.cipher'); + expect(error.message).toBe('Invalid parameter: options.walletProvider'); } try { - // @ts-expect-error Testing invalid usage, invalid search arg - new Keymaster({ gatekeeper, wallet, cipher, search: {}, passphrase: PASSPHRASE }); + // @ts-expect-error Testing invalid usage, invalid cipher arg + new Keymaster({ gatekeeper, store, walletProvider, cipher: {} }); throw new ExpectedExceptionError(); } catch (error: any) { - expect(error.message).toBe('Invalid parameter: options.search'); + expect(error.message).toBe('Invalid parameter: options.cipher'); } try { - // @ts-expect-error Testing invalid usage, missing passphrase arg - new Keymaster({ gatekeeper, wallet, cipher }); + // @ts-expect-error Testing invalid usage, invalid search arg + new Keymaster({ gatekeeper, store, walletProvider, cipher, search: {} }); throw new ExpectedExceptionError(); } catch (error: any) { - expect(error.message).toBe('Invalid parameter: options.passphrase'); + expect(error.message).toBe('Invalid parameter: options.search'); } // Cover the ExpectedExceptionError class for completeness try { - new Keymaster({ gatekeeper, wallet, cipher, passphrase: PASSPHRASE }); + new Keymaster({ gatekeeper, store, walletProvider, cipher }); throw new ExpectedExceptionError(); } catch (error: any) { diff --git a/tests/keymaster/wallet.test.ts b/tests/keymaster/wallet.test.ts index f7c2d6337..cf4218e87 100644 --- a/tests/keymaster/wallet.test.ts +++ b/tests/keymaster/wallet.test.ts @@ -1,92 +1,106 @@ import Gatekeeper from '@mdip/gatekeeper'; import Keymaster from '@mdip/keymaster'; -import { - WalletEncFile, +import type { + LegacyWalletEncFile, + LegacyWalletFile, WalletFile, + WalletProviderStore, } from '@mdip/keymaster/types'; import CipherNode from '@mdip/cipher/node'; import DbJsonMemory from '@mdip/gatekeeper/db/json-memory'; import WalletJsonMemory from '@mdip/keymaster/wallet/json-memory'; -import { ExpectedExceptionError } from '@mdip/common/errors'; import HeliaClient from '@mdip/ipfs/helia'; -import { MdipDocument } from "@mdip/gatekeeper/types"; +import MnemonicHdWalletProvider from '../../packages/keymaster/src/provider/mnemonic-hd.ts'; import { TestHelper } from './helper.ts'; import { disableSubtle } from './testUtils.ts'; import { encMnemonic, decMnemonic } from '@mdip/keymaster/encryption'; let ipfs: HeliaClient; let gatekeeper: Gatekeeper; -let wallet: WalletJsonMemory; +let walletStore: WalletJsonMemory; +let providerStore: WalletJsonMemory; let cipher: CipherNode; +let walletProvider: MnemonicHdWalletProvider; let keymaster: Keymaster; let helper: TestHelper; const PASSPHRASE = 'passphrase'; -const MOCK_WALLET_V0_UNENCRYPTED: WalletFile = { - "seed": { - "mnemonic": "wp3keoeTNleruzCiTOrCgDmm6viThBq_GWdNIGzXKcS62XqtrBkm0-jDhEUoU1FvB5oWnmCqkSIhnKKeaUwPbK5ysjCHbIVrf9JAr-91FabxtX0B2dctgccg_MEVk88u6anmcFP4DAEhK5zUDXCYGgFR", - "hdkey": { - "xpriv": "xprv9s21ZrQH143K2JL3GWr8NVjn1XR9kpKpKX4G4g5cvYKyrGShVz7ro2zf75AYyArqm8b7VQGpbvcLXGw6Sp5sa5pAPfHMfbjsPkgiezjHSGN", - "xpub": "xpub661MyMwAqRbcEnQWNYP8jdgWZZFeAH3fgjyrs4VEUsrxj4mr3XS7LqK8xNiAKdSdnCb5zbdxPvgu49fdGgzMgDW8AfbyP6CQjWFkYgFbNdB" - } +const MOCK_WALLET_V0_UNENCRYPTED: LegacyWalletFile = { + seed: { + mnemonic: 'wp3keoeTNleruzCiTOrCgDmm6viThBq_GWdNIGzXKcS62XqtrBkm0-jDhEUoU1FvB5oWnmCqkSIhnKKeaUwPbK5ysjCHbIVrf9JAr-91FabxtX0B2dctgccg_MEVk88u6anmcFP4DAEhK5zUDXCYGgFR', + hdkey: { + xpriv: 'xprv9s21ZrQH143K2JL3GWr8NVjn1XR9kpKpKX4G4g5cvYKyrGShVz7ro2zf75AYyArqm8b7VQGpbvcLXGw6Sp5sa5pAPfHMfbjsPkgiezjHSGN', + xpub: 'xpub661MyMwAqRbcEnQWNYP8jdgWZZFeAH3fgjyrs4VEUsrxj4mr3XS7LqK8xNiAKdSdnCb5zbdxPvgu49fdGgzMgDW8AfbyP6CQjWFkYgFbNdB', + }, }, - "counter": 0, - "ids": {} -} + counter: 0, + ids: {}, +}; -const MOCK_WALLET_V0_WITH_IDS: WalletFile = { - "seed": { - "mnemonic": "WLWbs2iHBobOaKVJXViqefiTYayURf-_6gh_ndflhTACKYG8WKn8WWsQHXNiyNYjU9sfM9kOce8fyAyKjUERgdjnZv2_y6MKO9QsnQMd4XUZceKSa22QGdzBSBFOZ13Odzj9fVd4W-bfvgSZuJJqMWwNhw", - "hdkey": { - "xpriv": "xprv9s21ZrQH143K2v1nGQ7a6WnEH9VQv6AT7FrxSPGPfSuvgz1mxGsazcTKNk58oRWVpB2MqgaRBPXevSuRbtUziXeQT2ZYmCXnUe6JRHomHrn", - "xpub": "xpub661MyMwAqRbcFQ6FNReaTeixqBKuKYtJUUnZEmg1DnSuZnLvVpBqYQmoE31V13nDfVQ8kMkfPKkMk1oWw77jUjXZJT22jH5dpRTvE8M84m9" - } +const MOCK_WALLET_V0_WITH_IDS: LegacyWalletFile = { + seed: { + mnemonic: 'WLWbs2iHBobOaKVJXViqefiTYayURf-_6gh_ndflhTACKYG8WKn8WWsQHXNiyNYjU9sfM9kOce8fyAyKjUERgdjnZv2_y6MKO9QsnQMd4XUZceKSa22QGdzBSBFOZ13Odzj9fVd4W-bfvgSZuJJqMWwNhw', + hdkey: { + xpriv: 'xprv9s21ZrQH143K2v1nGQ7a6WnEH9VQv6AT7FrxSPGPfSuvgz1mxGsazcTKNk58oRWVpB2MqgaRBPXevSuRbtUziXeQT2ZYmCXnUe6JRHomHrn', + xpub: 'xpub661MyMwAqRbcFQ6FNReaTeixqBKuKYtJUUnZEmg1DnSuZnLvVpBqYQmoE31V13nDfVQ8kMkfPKkMk1oWw77jUjXZJT22jH5dpRTvE8M84m9', + }, }, - "counter": 2, - "ids": { - "id_1": { - "did": "did:test:z3v8AuakAd5R7WeGZUin2TtsqyxJPxouLfMEbpn5CmaNXChWq7r", - "account": 0, - "index": 0 + counter: 2, + ids: { + id_1: { + did: 'did:test:z3v8AuakAd5R7WeGZUin2TtsqyxJPxouLfMEbpn5CmaNXChWq7r', + account: 0, + index: 0, + }, + id_2: { + did: 'did:test:z3v8AuaiAYJ263LLYdApaUmGjy8Dnhx46LU1YDUvGHAcj9Ykgxg', + account: 1, + index: 0, }, - "id_2": { - "did": "did:test:z3v8AuaiAYJ263LLYdApaUmGjy8Dnhx46LU1YDUvGHAcj9Ykgxg", - "account": 1, - "index": 0 - } }, - "current": "id_2" -} + current: 'id_2', +}; const MOCK_WALLET_V0_ENCRYPTED = { - "salt": "SHUIyrheMkaGv7uyV+6ZHw==", - "iv": "nW4a05eR2rxHY0T7", - "data": "O+UlnXsCA522UwUwpFqtybIKwrJsHrVatrUJgNVBjFUk6TAdMsdGzW49WiJt+lF4iJe6ftETd1wjSretZc97gi+VzZzX0Ggba6rmXnuD189jRFg7eudCqG4y6Rgt72SYxZu3pgaEJ146Ntj+H6cAcSIfYyhNgtPmlpWBZcm68wP8YRaP5i0/mZF89md4DjjyFOv8qTLG4m42fmoCmliIeJdmBChjPdpAm8V/ZOwkULjKQPpLAjDe4uCwvgenZduSJEDyP8m1jAcwGFxcI1mcXVYunR/YruczYXGY4dPnmW03lXinOX+5SR/bs9Z23uhqoVgUgW25Rfz/5zr4YFVXBQcVQXEvLtR38KPWeuOKltvU3FbysSgIrM6WBSkJt5chfYCGg7a554lqHyeGTxrlUa8th+hXSv/LVkvl+juhq+yd85QqyX8gLhxZxw4lx5eeaU3uJ+BJ33onI2y4sr02ZU5fYOIPFKS7IGCE0KK2hv0NwNvSv8oy402m9xU+iCIr19Xs28jm61/difLh/x1g/RXQUV/07b8tZLbB6n6hBC/h+3jLexJeFIpn1C1yBY+JQopTS+NgXEZZK+HuFp3k/JjI0ImxIy/2gPSm3jRAs1f8GfLLEMdJWoseZ/laPhD0QdWPQt7oGqKTfn7G72os8gGsme4AiFtKzg0zEv3whzLvOW6W2uUXAR83cXdlKcLpju7vrjjdfrcqYxkR3VDp" -} + salt: 'SHUIyrheMkaGv7uyV+6ZHw==', + iv: 'nW4a05eR2rxHY0T7', + data: 'O+UlnXsCA522UwUwpFqtybIKwrJsHrVatrUJgNVBjFUk6TAdMsdGzW49WiJt+lF4iJe6ftETd1wjSretZc97gi+VzZzX0Ggba6rmXnuD189jRFg7eudCqG4y6Rgt72SYxZu3pgaEJ146Ntj+H6cAcSIfYyhNgtPmlpWBZcm68wP8YRaP5i0/mZF89md4DjjyFOv8qTLG4m42fmoCmliIeJdmBChjPdpAm8V/ZOwkULjKQPpLAjDe4uCwvgenZduSJEDyP8m1jAcwGFxcI1mcXVYunR/YruczYXGY4dPnmW03lXinOX+5SR/bs9Z23uhqoVgUgW25Rfz/5zr4YFVXBQcVQXEvLtR38KPWeuOKltvU3FbysSgIrM6WBSkJt5chfYCGg7a554lqHyeGTxrlUa8th+hXSv/LVkvl+juhq+yd85QqyX8gLhxZxw4lx5eeaU3uJ+BJ33onI2y4sr02ZU5fYOIPFKS7IGCE0KK2hv0NwNvSv8oy402m9xU+iCIr19Xs28jm61/difLh/x1g/RXQUV/07b8tZLbB6n6hBC/h+3jLexJeFIpn1C1yBY+JQopTS+NgXEZZK+HuFp3k/JjI0ImxIy/2gPSm3jRAs1f8GfLLEMdJWoseZ/laPhD0QdWPQt7oGqKTfn7G72os8gGsme4AiFtKzg0zEv3whzLvOW6W2uUXAR83cXdlKcLpju7vrjjdfrcqYxkR3VDp', +}; -const MOCK_WALLET_V1: WalletFile = { - "version": 1, - "seed": { - "mnemonicEnc": { - "data": "p3gKBzVtJTflKBHSDgrMiuncBH4foJM++DyoQAZD/cVeQDCY4aFTxSC0nkylGcpi88Odq0SXkc2nAHyjA7+D6FZzbiTDdgqu3SJXznZEMCJDzHTkpLOa", - "iv": "2mHu57FRcEERBLMv", - "salt": "m74zOr/8etDRMoU8dnriXA==", +const MOCK_WALLET_V1: LegacyWalletFile = { + version: 1, + seed: { + mnemonicEnc: { + data: 'p3gKBzVtJTflKBHSDgrMiuncBH4foJM++DyoQAZD/cVeQDCY4aFTxSC0nkylGcpi88Odq0SXkc2nAHyjA7+D6FZzbiTDdgqu3SJXznZEMCJDzHTkpLOa', + iv: '2mHu57FRcEERBLMv', + salt: 'm74zOr/8etDRMoU8dnriXA==', }, }, - "counter": 0, - "ids": {} + counter: 0, + ids: {}, }; -const MOCK_WALLET_V1_ENCRYPTED: WalletEncFile = { - "version": 1, - "seed": { - "mnemonicEnc": { - "salt": "8c+TrInC7EJZAnwjD6k8+A==", - "iv": "EkeweG9JHYjXr7cN", - "data": "4MLe/4SX9unO+7DTK1KUKLBLeHuJNS4bT9yjp8L/xnLzexpobGEmRJebUuv3e0aIs4krINlkTlP4krmqkI3p/EVlu9Ap6GRNoogZR4ZC1EtKUTwgNaQ7058o0/d1LQ8wSA==" - } +const MOCK_WALLET_V1_ENCRYPTED: LegacyWalletEncFile = { + version: 1, + seed: { + mnemonicEnc: { + salt: '8c+TrInC7EJZAnwjD6k8+A==', + iv: 'EkeweG9JHYjXr7cN', + data: '4MLe/4SX9unO+7DTK1KUKLBLeHuJNS4bT9yjp8L/xnLzexpobGEmRJebUuv3e0aIs4krINlkTlP4krmqkI3p/EVlu9Ap6GRNoogZR4ZC1EtKUTwgNaQ7058o0/d1LQ8wSA==', + }, }, - "enc": "CAKfW05djVJ2VnkLLbiBgtJpfC3x8xvc4_-M0OJBA6N7YcuXyd1F3GhifoUZ2Zdy2XGP_nGzhjS2u3NXgIM" + enc: 'CAKfW05djVJ2VnkLLbiBgtJpfC3x8xvc4_-M0OJBA6N7YcuXyd1F3GhifoUZ2Zdy2XGP_nGzhjS2u3NXgIM', +}; + +function createProviderStore(): WalletProviderStore { + return new WalletJsonMemory() as unknown as WalletProviderStore; +} + +function createWalletProvider( + store: WalletProviderStore = createProviderStore(), + passphrase: string = PASSPHRASE, +): MnemonicHdWalletProvider { + return new MnemonicHdWalletProvider({ store, cipher, passphrase }); } beforeAll(async () => { @@ -103,30 +117,26 @@ afterAll(async () => { beforeEach(() => { const db = new DbJsonMemory('test'); gatekeeper = new Gatekeeper({ db, ipfs, registries: ['local', 'hyperswarm', 'TFTC'] }); - wallet = new WalletJsonMemory(); + walletStore = new WalletJsonMemory(); + providerStore = new WalletJsonMemory(); cipher = new CipherNode(); - keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: PASSPHRASE }); + walletProvider = createWalletProvider(providerStore as unknown as WalletProviderStore); + keymaster = new Keymaster({ gatekeeper, store: walletStore, walletProvider, cipher }); helper = new TestHelper(keymaster); }); describe('loadWallet', () => { - it('should create a wallet on first load', async () => { + it('should create v2 metadata on first load', async () => { const wallet = await keymaster.loadWallet(); - expect(wallet).toEqual( - expect.objectContaining({ - version: 1, - counter: 0, - seed: expect.objectContaining({ - mnemonicEnc: { - salt: expect.any(String), - iv: expect.any(String), - data: expect.any(String), - }, - }), - ids: {} - }) - ); + expect(wallet).toEqual({ + version: 2, + provider: { + type: 'mnemonic-hd', + walletFingerprint: expect.any(String), + }, + ids: {}, + }); }); it('should return the same wallet on second load', async () => { @@ -137,482 +147,280 @@ describe('loadWallet', () => { }); it('should throw exception on load with incorrect passphrase', async () => { - await wallet.saveWallet(MOCK_WALLET_V1_ENCRYPTED); - const keymasterIncorrect = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'incorrect' }); - await expect(keymasterIncorrect.loadWallet()).rejects.toThrow('Keymaster: Incorrect passphrase.'); - }); + await walletStore.saveWallet(structuredClone(MOCK_WALLET_V1_ENCRYPTED), true); - it('should throw exception saving a deprecated encrypted wallet', async () => { - const mockWallet = { salt: "", iv: "", data: "" }; + const incorrectKeymaster = new Keymaster({ + gatekeeper, + store: walletStore, + walletProvider: createWalletProvider(createProviderStore(), 'incorrect'), + cipher, + }); - try { - // @ts-expect-error Testing unsupported historical wallet shape - await keymaster.saveWallet(mockWallet); - throw new ExpectedExceptionError(); - } catch (error: any) { - // eslint-disable-next-line sonarjs/no-duplicate-string - expect(error.message).toBe('Keymaster: Unsupported wallet version.'); - } + await expect(incorrectKeymaster.loadWallet()).rejects.toThrow('Keymaster: Incorrect passphrase.'); }); - it('should upgrade a v0 unencrypted wallet to v1', async () => { - await wallet.saveWallet(MOCK_WALLET_V0_UNENCRYPTED as any); + it('should upgrade a v0 wallet to v2', async () => { + await walletStore.saveWallet(structuredClone(MOCK_WALLET_V0_UNENCRYPTED), true); - const res = await keymaster.loadWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - counter: 0, - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object), - }), - }) - ); + const wallet = await keymaster.loadWallet(); + + expect(wallet).toEqual({ + version: 2, + provider: { + type: 'mnemonic-hd', + walletFingerprint: expect.any(String), + }, + ids: {}, + }); }); - it('should throw on deprecated encrypted v0 wallet', async () => { - // @ts-expect-error Testing unsupported historical wallet shape - await wallet.saveWallet(MOCK_WALLET_V0_ENCRYPTED, true); + it('should migrate a v1 encrypted wallet to v2', async () => { + await walletStore.saveWallet(structuredClone(MOCK_WALLET_V1_ENCRYPTED), true); - await expect(keymaster.loadWallet()).rejects.toThrow('Keymaster: Unsupported wallet version.'); - }); + const wallet = await keymaster.loadWallet(); - it('should load a v1 encrypted wallet without hdkey', async () => { - await wallet.saveWallet(MOCK_WALLET_V1_ENCRYPTED); - const res = await keymaster.loadWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - counter: 0, - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object) - }) - }) - ); - expect(res.seed?.hdkey).toBeUndefined(); + expect(wallet.version).toBe(2); + expect(wallet.provider).toEqual({ + type: 'mnemonic-hd', + walletFingerprint: expect.any(String), + }); + expect(wallet.ids).toEqual({}); + expect((wallet as any).seed).toBeUndefined(); + expect((wallet as any).counter).toBeUndefined(); }); - it('should load a v1 encrypted wallet from cache without hdkey', async () => { - await wallet.saveWallet(MOCK_WALLET_V1_ENCRYPTED); - // prime cache - await keymaster.loadWallet(); - // load from cache - const res = await keymaster.loadWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - counter: 0, - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object) - }) - }) - ); - expect(res.seed?.hdkey).toBeUndefined(); + it('should throw on deprecated encrypted v0 wallet', async () => { + await walletStore.saveWallet(structuredClone(MOCK_WALLET_V0_ENCRYPTED) as any, true); + + await expect(keymaster.loadWallet()).rejects.toThrow('Keymaster: Unsupported wallet version.'); }); it('should throw on unsupported wallet version', async () => { - let clone = structuredClone(MOCK_WALLET_V1_ENCRYPTED); - delete clone.seed.mnemonicEnc; - await wallet.saveWallet(clone); + const invalidWallet: any = structuredClone(MOCK_WALLET_V1_ENCRYPTED); + delete invalidWallet.seed.mnemonicEnc; + await walletStore.saveWallet(invalidWallet, true); - try { - await keymaster.loadWallet(); - throw new ExpectedExceptionError(); - } catch (error: any) { - expect(error.message).toBe('Keymaster: Unsupported wallet version.'); - } + await expect(keymaster.loadWallet()).rejects.toThrow('Keymaster: Unsupported wallet version.'); }); }); describe('saveWallet', () => { - it('test saving directly on the unencrypted wallet', async () => { - const ok = await wallet.saveWallet(MOCK_WALLET_V1); - expect(ok).toBe(true); - }); - - it('should save a wallet', async () => { - const ok = await keymaster.saveWallet(MOCK_WALLET_V1); - const wallet = await keymaster.loadWallet(); - - expect(ok).toBe(true); - expect(wallet).toStrictEqual(MOCK_WALLET_V1); - }); - - it('should ignore overwrite flag if unnecessary', async () => { - const ok = await keymaster.saveWallet(MOCK_WALLET_V1, false); + it('should save a v2 wallet', async () => { const wallet = await keymaster.loadWallet(); + wallet.metadata = { foo: 'bar' }; - expect(ok).toBe(true); - expect(wallet).toStrictEqual(MOCK_WALLET_V1); - }); - - it('should overwrite an existing wallet', async () => { - const mockWallet = MOCK_WALLET_V1; - mockWallet.counter = 1; - - await keymaster.saveWallet(MOCK_WALLET_V1); - const ok = await keymaster.saveWallet(mockWallet); - const wallet = await keymaster.loadWallet(); + const ok = await keymaster.saveWallet(wallet, true); expect(ok).toBe(true); - expect(wallet).toStrictEqual(mockWallet); + expect(await keymaster.loadWallet()).toStrictEqual(wallet); }); it('should not overwrite an existing wallet if specified', async () => { - const mockWallet = MOCK_WALLET_V1; - mockWallet.counter = 1; - - await keymaster.saveWallet(MOCK_WALLET_V1); - const ok = await keymaster.saveWallet(mockWallet, false); const wallet = await keymaster.loadWallet(); + const updated = structuredClone(wallet); + updated.metadata = { foo: 'bar' }; - expect(ok).toBe(false); - expect(wallet).toStrictEqual(MOCK_WALLET_V1); - }); - - it('should overwrite an existing wallet in a loop', async () => { - for (let i = 0; i < 10; i++) { - const mockWallet = MOCK_WALLET_V1; - mockWallet.counter = i + 1; - - const ok = await keymaster.saveWallet(mockWallet); - const wallet = await keymaster.loadWallet(); - - expect(ok).toBe(true); - expect(wallet).toStrictEqual(mockWallet); - } - }); - - it('should not overwrite an existing wallet if specified', async () => { - const mockWallet = MOCK_WALLET_V1; - mockWallet.counter = 2; - - await keymaster.saveWallet(MOCK_WALLET_V1); - const ok = await keymaster.saveWallet(mockWallet, false); - const walletData = await keymaster.loadWallet(); + const ok = await keymaster.saveWallet(updated, false); expect(ok).toBe(false); - expect(walletData).toStrictEqual(MOCK_WALLET_V1); + expect(await keymaster.loadWallet()).toStrictEqual(wallet); }); - it('should save augmented wallet', async () => { + it('should save augmented wallet metadata', async () => { await keymaster.createId('Bob'); const wallet = await keymaster.loadWallet(); - wallet.ids['Bob'].icon = 'smiley'; + wallet.ids.Bob.icon = 'smiley'; wallet.metadata = { foo: 'bar' }; await keymaster.saveWallet(wallet, true); - const wallet2 = await keymaster.loadWallet(); - - expect(wallet).toStrictEqual(wallet2); - }); - - it('should upgrade a v0 wallet to v1', async () => { - const ok = await keymaster.saveWallet(MOCK_WALLET_V0_UNENCRYPTED); - expect(ok).toBe(true); - - const res = await wallet.loadWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - enc: expect.any(String), - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object), - }), - }) - ); + expect(await keymaster.loadWallet()).toStrictEqual(wallet); }); - it('v0 upgrade must not use stale _hdkeyCache', async () => { - await keymaster.newWallet(undefined, true); - expect( - await keymaster.saveWallet(MOCK_WALLET_V0_UNENCRYPTED, true) - ).toBe(true); - }); + it('should upgrade a v0 wallet with ids to v2', async () => { + const ok = await keymaster.saveWallet(structuredClone(MOCK_WALLET_V0_WITH_IDS), true); + const wallet = await keymaster.loadWallet(); - it('should encrypt an unencrypted v1 wallet contents and remove hdkey', async () => { - const ok = await keymaster.saveWallet(MOCK_WALLET_V1); expect(ok).toBe(true); + expect(wallet.version).toBe(2); + expect(wallet.current).toBe('id_2'); + expect(wallet.ids.id_1).toEqual({ + did: MOCK_WALLET_V0_WITH_IDS.ids.id_1.did, + keyRef: 'hd:0#0', + }); + expect(wallet.ids.id_2).toEqual({ + did: MOCK_WALLET_V0_WITH_IDS.ids.id_2.did, + keyRef: 'hd:1#0', + }); + }); + + it('should save a v1 encrypted wallet as v2 metadata', async () => { + const ok = await keymaster.saveWallet(structuredClone(MOCK_WALLET_V1_ENCRYPTED), true); + const stored = await walletStore.loadWallet() as WalletFile; - const res = await wallet.loadWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - enc: expect.any(String), - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object), - }), - }) - ); - }); - - it('should save a v1 encrypted wallet', async () => { - const ok = await keymaster.saveWallet(MOCK_WALLET_V1_ENCRYPTED, true); expect(ok).toBe(true); + expect(stored.version).toBe(2); + expect(stored.provider).toEqual({ + type: 'mnemonic-hd', + walletFingerprint: expect.any(String), + }); }); it('should throw on incorrect passphrase', async () => { - const wallet = new WalletJsonMemory(); - const keymaster = new Keymaster({ gatekeeper, wallet, cipher, passphrase: 'incorrect' }); - - try { - await keymaster.saveWallet(MOCK_WALLET_V1_ENCRYPTED, true); - throw new ExpectedExceptionError(); - } catch (error: any) { - expect(error.message).toBe('Keymaster: Incorrect passphrase.'); - } - }); -}); - -describe('decryptMnemonic', () => { - it('should return 12 words', async () => { - const wallet = await keymaster.loadWallet(); - const mnemonic = await keymaster.decryptMnemonic(); - - expect(mnemonic !== wallet.seed!.mnemonic).toBe(true); - - // Split the mnemonic into words - const words = mnemonic.split(' '); - expect(words.length).toBe(12); - }); -}); - -describe('exportEncryptedWallet', () => { - it('should export the wallet in encrypted form', async () => { - const res = await keymaster.exportEncryptedWallet(); - expect(res).toEqual( - expect.objectContaining({ - version: 1, - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object) - }), - enc: expect.any(String) - }) - ); - }); -}); + const incorrectKeymaster = new Keymaster({ + gatekeeper, + store: walletStore, + walletProvider: createWalletProvider(createProviderStore(), 'incorrect'), + cipher, + }); -describe('updateSeedBank', () => { - it('should throw error on missing DID', async () => { - const doc: MdipDocument = {}; - - try { - await keymaster.updateSeedBank(doc); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe('Invalid parameter: seed bank missing DID'); - } + await expect( + incorrectKeymaster.saveWallet(structuredClone(MOCK_WALLET_V1_ENCRYPTED), true) + ).rejects.toThrow('Keymaster: Incorrect passphrase.'); }); }); describe('newWallet', () => { it('should overwrite an existing wallet when allowed', async () => { const wallet1 = await keymaster.loadWallet(); + await keymaster.newWallet(undefined, true); const wallet2 = await keymaster.loadWallet(); - expect(wallet1.seed!.mnemonicEnc !== wallet2.seed!.mnemonicEnc).toBe(true); + expect(wallet1.provider.walletFingerprint).not.toBe(wallet2.provider.walletFingerprint); }); it('should not overwrite an existing wallet by default', async () => { await keymaster.loadWallet(); - try { - await keymaster.newWallet(); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe('Keymaster: save wallet failed'); - } + await expect(keymaster.newWallet()).rejects.toThrow('Keymaster: save wallet failed'); }); it('should create a wallet from a mnemonic', async () => { - const mnemonic1 = cipher.generateMnemonic(); - await keymaster.newWallet(mnemonic1); - const mnemonic2 = await keymaster.decryptMnemonic(); + const mnemonic = cipher.generateMnemonic(); + await keymaster.newWallet(mnemonic); + const wallet = await keymaster.loadWallet(); + + const comparisonProvider = createWalletProvider(createProviderStore()); + await comparisonProvider.newWallet(mnemonic, true); - expect(mnemonic1 === mnemonic2).toBe(true); + expect(wallet.provider.walletFingerprint).toBe(await comparisonProvider.getFingerprint()); }); it('should throw exception on invalid mnemonic', async () => { - try { - // @ts-expect-error Testing invalid usage, incorrect argument - await keymaster.newWallet([]); - throw new ExpectedExceptionError(); - } - catch (error: any) { - expect(error.message).toBe('Invalid parameter: mnemonic'); - } + await expect( + keymaster.newWallet([] as any) + ).rejects.toThrow('Invalid parameter: mnemonic'); }); }); -describe('resolveSeedBank', () => { - it('should create a deterministic seed bank ID', async () => { - const bank1 = await keymaster.resolveSeedBank(); - const bank2 = await keymaster.resolveSeedBank(); +describe('MnemonicHdWalletProvider backup', () => { + it('should backup and restore provider state directly', async () => { + await keymaster.createId('Bob'); - // Update the retrieved timestamp to match any value - bank1.didResolutionMetadata!.retrieved = expect.any(String); + const backup = await walletProvider.backupWallet(); + const restoredProvider = createWalletProvider(createProviderStore()); + const ok = await restoredProvider.saveWallet(backup, true); - expect(bank1).toStrictEqual(bank2); + expect(ok).toBe(true); + expect(await restoredProvider.getFingerprint()).toBe(await walletProvider.getFingerprint()); }); -}); -describe('backupWallet', () => { - it('should return a valid DID', async () => { - await keymaster.createId('Bob'); - const did = await keymaster.backupWallet(); - const doc = await keymaster.resolveDID(did); + it('should decrypt the mnemonic and change passphrase with a matching mnemonic', async () => { + const mnemonic = cipher.generateMnemonic(); + await walletProvider.newWallet(mnemonic, true); + + const backup = await walletProvider.backupWallet(); + const restoredProvider = createWalletProvider(createProviderStore(), 'temporary'); + await restoredProvider.saveWallet(backup, true); + await restoredProvider.changePassphrase(mnemonic, 'updated-passphrase'); - expect(did === doc.didDocument!.id).toBe(true); + expect(await restoredProvider.decryptMnemonic()).toBe(mnemonic); + expect(await restoredProvider.getFingerprint()).toBe(await walletProvider.getFingerprint()); }); - it('should store backup in seed bank', async () => { - await keymaster.createId('Bob'); - const did = await keymaster.backupWallet(); - const bank = await keymaster.resolveSeedBank(); + it('should reject passphrase change when the mnemonic does not match', async () => { + const mnemonic = cipher.generateMnemonic(); + await walletProvider.newWallet(mnemonic, true); + + const backup = await walletProvider.backupWallet(); + const restoredProvider = createWalletProvider(createProviderStore(), 'temporary'); + await restoredProvider.saveWallet(backup, true); - expect(did === (bank.didDocumentData! as { wallet: string }).wallet).toBe(true); + await expect( + restoredProvider.changePassphrase(cipher.generateMnemonic(), 'updated-passphrase') + ).rejects.toThrow('Keymaster: Mnemonic does not match wallet.'); }); }); -describe('recoverWallet', () => { - it('should recover wallet from seed bank', async () => { +describe('backupWallet', () => { + it('should return a valid DID and store backupDid in metadata', async () => { await keymaster.createId('Bob'); - const wallet = await keymaster.loadWallet(); - const mnemonic = await keymaster.decryptMnemonic(); - await keymaster.backupWallet(); - // Recover wallet from mnemonic - await keymaster.newWallet(mnemonic, true); - const recovered = await keymaster.recoverWallet(); + const did = await keymaster.backupWallet(); + const doc = await keymaster.resolveDID(did); + const wallet = await keymaster.loadWallet(); - expect(recovered).toEqual( - expect.objectContaining({ - counter: wallet.counter, - version: wallet.version, - seed: { - mnemonicEnc: expect.any(Object), - }, - current: wallet.current, - ids: wallet.ids - }) - ); + expect(did).toBe(doc.didDocument!.id); + expect(wallet.backupDid).toBe(did); }); +}); - it('should recover over existing wallet', async () => { +describe('recoverWallet', () => { + it('should recover wallet from stored backup DID', async () => { await keymaster.createId('Bob'); - await keymaster.loadWallet(); await keymaster.backupWallet(); await keymaster.createId('Alice'); - // Recover over existing wallet const recovered = await keymaster.recoverWallet(); - expect(recovered).toEqual( - expect.objectContaining({ - version: 1, - counter: 1, - current: "Bob", - seed: expect.objectContaining({ - mnemonicEnc: expect.any(Object), - }), - ids: expect.objectContaining({ - Bob: expect.objectContaining({ - account: 0, - did: expect.any(String), - index: 0 - }), - }) - }) - ); + expect(Object.keys(recovered.ids)).toEqual(['Bob']); + expect(recovered.current).toBe('Bob'); }); - it('should recover augmented wallet from seed bank', async () => { + it('should recover augmented wallet from backup DID', async () => { await keymaster.createId('Bob'); const wallet = await keymaster.loadWallet(); - const mnemonic = await keymaster.decryptMnemonic(); - - wallet.ids['Bob'].icon = 'smiley'; + wallet.ids.Bob.icon = 'smiley'; wallet.metadata = { foo: 'bar' }; await keymaster.saveWallet(wallet, true); - await keymaster.backupWallet(); - - // Recover wallet from mnemonic - await keymaster.newWallet(mnemonic, true); - const recovered = await keymaster.recoverWallet(); - - expect(recovered).toEqual( - expect.objectContaining({ - counter: wallet.counter, - version: wallet.version, - seed: { - mnemonicEnc: expect.any(Object), - }, - current: wallet.current, - ids: wallet.ids - }) - ); - }); - - it('should recover v0 wallet from seed bank', async () => { - await keymaster.saveWallet(MOCK_WALLET_V0_WITH_IDS); - const mnemonic = await keymaster.decryptMnemonic(); - await keymaster.backupWallet(undefined, MOCK_WALLET_V0_WITH_IDS); - - // Recover wallet from mnemonic - await keymaster.newWallet(mnemonic, true); - const recovered = await keymaster.recoverWallet(); - - expect(recovered).toBeDefined(); - expect(recovered.ids).toStrictEqual(MOCK_WALLET_V0_WITH_IDS.ids); - }); - - it('should recover wallet from backup DID', async () => { - await keymaster.createId('Bob'); - const wallet = await keymaster.loadWallet(); - const mnemonic = await keymaster.decryptMnemonic(); const did = await keymaster.backupWallet(); - // Recover wallet from mnemonic and recovery DID - await keymaster.newWallet(mnemonic, true); + await keymaster.createId('Alice'); const recovered = await keymaster.recoverWallet(did); expect(recovered).toEqual( expect.objectContaining({ - counter: wallet.counter, - version: wallet.version, - seed: { - mnemonicEnc: expect.any(Object), - }, - current: wallet.current, - ids: wallet.ids + version: 2, + ids: expect.objectContaining({ + Bob: expect.objectContaining({ + did: wallet.ids.Bob.did, + keyRef: wallet.ids.Bob.keyRef, + icon: 'smiley', + }), + }), + metadata: { foo: 'bar' }, }) ); + expect(recovered.ids.Alice).toBeUndefined(); }); it('should do nothing if wallet was not backed up', async () => { await keymaster.createId('Bob'); - const mnemonic = await keymaster.decryptMnemonic(); + const current = await keymaster.loadWallet(); - // Recover wallet from mnemonic - await keymaster.newWallet(mnemonic, true); const recovered = await keymaster.recoverWallet(); - expect(recovered.ids).toStrictEqual({}); + expect(recovered).toStrictEqual(current); }); it('should do nothing if backup DID is invalid', async () => { - const agentDID = await keymaster.createId('Bob'); - const mnemonic = await keymaster.decryptMnemonic(); + await keymaster.createId('Bob'); + const current = await keymaster.loadWallet(); - // Recover wallet from mnemonic - await keymaster.newWallet(mnemonic, true); - const recovered = await keymaster.recoverWallet(agentDID); + const recovered = await keymaster.recoverWallet('did:test:invalid'); - expect(recovered.ids).toStrictEqual({}); + expect(recovered).toStrictEqual(current); }); }); @@ -682,7 +490,7 @@ describe('checkWallet', () => { expect(checked).toBe(16); expect(invalid).toBe(0); - expect(deleted).toBe(4); // 2 credentials mentioned both in held and name lists + expect(deleted).toBe(4); }); }); @@ -764,29 +572,19 @@ describe('fixWallet', () => { describe('no WebCrypto subtle', () => { let restore: () => void; - beforeAll(async () => { + beforeAll(() => { restore = disableSubtle(); }); - afterAll(async () => { + afterAll(() => { restore(); }); it('encMnemonic will throw without crypto subtle', async () => { - try { - await encMnemonic("", PASSPHRASE); - throw new ExpectedExceptionError(); - } catch (error: any) { - expect(error.message).toBe('Web Cryptography API not available'); - } + await expect(encMnemonic('', PASSPHRASE)).rejects.toThrow('Web Cryptography API not available'); }); it('decMnemonic will throw without crypto subtle', async () => { - try { - await decMnemonic(MOCK_WALLET_V0_ENCRYPTED, PASSPHRASE); - throw new ExpectedExceptionError(); - } catch (error: any) { - expect(error.message).toBe('Web Cryptography API not available'); - } + await expect(decMnemonic(MOCK_WALLET_V0_ENCRYPTED, PASSPHRASE)).rejects.toThrow('Web Cryptography API not available'); }); });