Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
118cf4d
Backup Codes: export flow
danimoh Dec 28, 2025
928fdfc
Backup Codes: refactor view transitions for reuse in import flow
danimoh Jan 19, 2026
c862ef2
Backup Codes: refactor BackupCodesIllustration for reuse as BackupCod…
danimoh Jan 19, 2026
14fa3d3
Import refactor: add general Import handler (in prep for ImportBackup…
danimoh Jan 19, 2026
27e00a9
Import refactor: more consistent naming of translation keys
danimoh Jan 19, 2026
440e2d5
Import fixes: fix use of legacy NimiqPoW for legacy Login Files
danimoh Jan 19, 2026
adc7358
Import fixes: fix clearing/setting of recovery words
danimoh Jan 19, 2026
3423635
Import fixes: avoid unnecessary duplicate validation of recovery words
danimoh Jan 19, 2026
0b426c2
Import fixes: update Login File download headline once initiated
danimoh Jan 20, 2026
3ad4857
Import fixes: fix and update Import demo
danimoh Jan 19, 2026
1db1048
Import performance: take encrypted secret from KeyStore instead of re…
danimoh Jan 19, 2026
69fd36e
Import performance: parallelize encryption of secret and derivation o…
danimoh Jan 19, 2026
6d9c8aa
Backup Codes: BackupCodesInput
danimoh Jan 20, 2026
0ecc999
Backup Codes: import flow
danimoh Jan 20, 2026
2dbbd9a
Backup Codes: fixes for Firefox and Safari
danimoh Jan 26, 2026
7f5b6f1
Backup Codes: adapt import input autofocus and blur behavior for mobile
danimoh Jan 26, 2026
6872106
Backup Codes: more consistent line breaks between zoomed/non-zoomed c…
danimoh Jan 26, 2026
26164f4
Backup Codes: support browser history navigation between codes to import
danimoh Jan 26, 2026
bacd188
Key: improve the security of derived keys and make use of expensive k…
danimoh Feb 10, 2026
768a910
Key: make salt independent of key material
danimoh Feb 10, 2026
19cf940
BackupCodes: switch kdf to simple HKDF
danimoh Feb 10, 2026
81c3d6d
Backup Codes: add a comment regarding unambiguous matching of codes
danimoh Feb 10, 2026
32c21fa
Backup Codes: constant time checksum check
danimoh Feb 10, 2026
721d116
karma.conf.js: fix import order of test dependencies
danimoh Feb 10, 2026
25be049
Update translations
danimoh Feb 10, 2026
52d66d6
Backup Codes: add final link to blog post
danimoh Feb 10, 2026
1b7f063
Keyguard Client 1.10.0
danimoh Feb 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nimiq/keyguard-client",
"version": "1.9.0",
"version": "1.10.0",
"description": "Nimiq Keyguard client library",
"main": "dist/KeyguardClient.common.js",
"module": "dist/KeyguardClient.es.js",
Expand Down
3 changes: 3 additions & 0 deletions client/src/PublicRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type SingleKeyResult = {
}>;
fileExported: boolean;
wordsExported: boolean;
backupCodesExported: boolean;
bitcoinXPub?: string;
polygonAddresses?: Array<{
address: string,
Expand Down Expand Up @@ -155,11 +156,13 @@ export type SimpleRequest = BasicRequest & {
export type ExportRequest = SimpleRequest & {
fileOnly?: boolean,
wordsOnly?: boolean,
backupCodesOnly?: boolean,
};

export type ExportResult = {
fileExported: boolean,
wordsExported: boolean,
backupCodesExported: boolean,
};

type SignTransactionRequestCommon = SimpleRequest & TransactionInfo;
Expand Down
168 changes: 117 additions & 51 deletions demos/Export.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@
<script src="../node_modules/@nimiq/rpc/dist/rpc.umd.js"></script>

<div>
<div>
<label>
<input type="radio" name="rpc-type-selector" value="popup">
Popup
</label>
<label>
<input type="radio" name="rpc-type-selector" value="redirect" checked>
Redirect
</label>
</div>
<div>
<label>
<input type="radio" name="export-type-selector" value="file+words" checked>
file+words
</label>
<label>
<input type="radio" name="export-type-selector" value="fileOnly">
fileOnly
</label>
<label>
<input type="radio" name="export-type-selector" value="wordsOnly">
wordsOnly
</label>
<label>
<input type="radio" name="export-type-selector" value="backupCodesOnly">
backupCodesOnly
</label>
</div>
<button class="bip39">Export (BIP39)</button>
<button class="legacy">Export (Legacy)</button>
<button class="unencrypted">Export (unencrypted)</button>
Expand All @@ -21,71 +49,109 @@
<textarea id="result" rows=20 cols=60></textarea>
</div>

<script>
(async () => {
await Nimiq.WasmHelper.doImportBrowser();
Nimiq.GenesisConfig.test();
})();
<script type="module">
const SESSION_STORAGE_TEMPORARY_KEY_ID = 'temporary-key-id';

document.querySelector('.bip39').addEventListener('click', async () => {
const entropy = Nimiq.Entropy.generate();
const key = new Key(entropy);
const password = Nimiq.BufferUtils.fromUtf8('1234567890');
const id = await KeyStore.instance.put(key, password);
const $result = document.getElementById('result');

const exportRequest = {
appName: 'Export Demo',
keyId: id,
};
await Nimiq.default();
const redirectClient = new Rpc.RedirectRpcClient(new URL('../src/request/export/', location.href).href, '*');
redirectClient.onResponse('request', onRequestResult, onRequestError);
await redirectClient.init();

exportPopup(exportRequest);
});
for (const keyType of ['bip39', 'legacy', 'unencrypted']) {
document.querySelector(`.${keyType}`).addEventListener('click', async () => {
const keyId = await createTemporaryKey(keyType);
await requestExport(keyId);
});
}

document.querySelector('.legacy').addEventListener('click', async () => {
const secret = Nimiq.PrivateKey.generate();
/**
* @param {'bip39' | 'legacy' | 'unencrypted'} type
* @returns {Promise<string>}
*/
async function createTemporaryKey(type) {
await clearTemporaryKey();
$result.textContent = 'Generating test key...';
const secret = type !== 'legacy'
? Nimiq.Entropy.generate()
: Nimiq.PrivateKey.generate();
const password = type !== 'unencrypted'
? Nimiq.BufferUtils.fromUtf8('1234567890')
: undefined;
const key = new Key(secret);
const password = Nimiq.BufferUtils.fromUtf8('1234567890');
const id = await KeyStore.instance.put(key, password);

const exportRequest = {
appName: 'Export Demo',
keyId: id,
};
const keyId = await KeyStore.instance.put(key, password);
sessionStorage.setItem(SESSION_STORAGE_TEMPORARY_KEY_ID, keyId);
$result.textContent = 'Test key generated';
return keyId;
}

exportPopup(exportRequest);
});
async function clearTemporaryKey() {
const keyId = sessionStorage.getItem(SESSION_STORAGE_TEMPORARY_KEY_ID);
if (!keyId) return;
await KeyStore.instance.remove(keyId);
sessionStorage.removeItem(SESSION_STORAGE_TEMPORARY_KEY_ID);
}

document.querySelector('.unencrypted').addEventListener('click', async () => {
const secret = Nimiq.Entropy.generate();
const key = new Key(secret);
const id = await KeyStore.instance.put(key);
/**
* @param {string} keyId
* @returns {Promise<void>}
*/
async function requestExport(keyId) {
const rpcType = document.querySelector('input[name="rpc-type-selector"]:checked').value;
let popup, rpc;
if (rpcType === 'popup') {
popup = window.open('../src/request/export/', 'Export Demo',
`left=${window.innerWidth / 2 - 350},top=75,width=700,height=850,location=yes,dependent=yes`);
rpc = new Rpc.PostMessageRpcClient(popup, '*');
await rpc.init();
} else {
rpc = redirectClient;
}

const exportRequest = {
const request = {
appName: 'Export Demo',
keyId: id,
keyId,
};

exportPopup(exportRequest);
});


async function exportPopup(request) {
const keyguard = window.open('../src/request/export/', 'Export Demo',
`left=${window.innerWidth / 2 - 350},top=75,width=700,height=850,location=yes,dependent=yes`);
const rpc = new Rpc.PostMessageRpcClient(keyguard, '*');
await rpc.init();

const exportType = document.querySelector('input[name="export-type-selector"]:checked').value;
if (exportType !== 'file+words') {
request[exportType] = true;
}
try {
const result = await rpc.call('request', request);
console.log('Keyguard result:', result);
document.querySelector('#result').textContent = JSON.stringify(result);
if (rpcType === 'popup') {
const result = await rpc.call('request', request);
await onRequestResult(result);
} else {
await rpc.call(location.href, 'request', /* handleHistoryBack */ true, request);
// result will be handled by redirectClient on return to this page
}
} catch (e) {
console.error('Keyguard error', e);
document.querySelector('#result').textContent = `Error: ${e.message || e}`;
await onRequestError(e);
} finally {
if (popup) {
popup.close();
}
}
}

/**
* @param {unknown} result
* @returns {Promise<void>}
*/
async function onRequestResult(result) {
console.log('Keyguard result:', result);
$result.textContent = JSON.stringify(result);
await clearTemporaryKey();
}

keyguard.close();
await KeyStore.instance.remove(request.keyId);
/**
* @param {unknown} error
* @returns {Promise<void>}
*/
async function onRequestError(error) {
console.error('Keyguard error:', error);
$result.textContent = `Error: ${error.message || error}`;
await clearTemporaryKey();
}
</script>

Expand Down
91 changes: 77 additions & 14 deletions demos/Import.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,97 @@
<body>
<link href="../src/nimiq-style.css" rel="stylesheet">

<div>
<label>
<input type="radio" name="rpc-type-selector" value="popup">
Popup
</label>
<label>
<input type="radio" name="rpc-type-selector" value="redirect" checked>
Redirect
</label>
</div>
<div>
<label>
<input type="checkbox" id="checkbox-isKeyLost">
isKeyLost
</label>
<label>
<input type="checkbox" id="checkbox-enableBackArrow">
enableBackArrow
</label>
<label>
<input type="checkbox" id="checkbox-wordsOnly">
wordsOnly
</label>
</div>
<div>
<button id="import">Import</button>
<br><br>
<textarea id="result" rows=20 cols=60></textarea>
</div>

<script>
<script type="module">
const $result = document.getElementById('result');

const redirectClient = new Rpc.RedirectRpcClient(new URL('../src/request/import/', location.href).href, '*');
redirectClient.onResponse('request', onRequestResult, onRequestError);
await redirectClient.init();

document.querySelector('#import').addEventListener('click', () => requestImport());

async function requestImport() {
const keyguard = window.open(`../src/request/import/`, 'Export Demo',
`left=${window.innerWidth / 2 - 350},top=75,width=700,height=850,location=yes,dependent=yes`);
const rpc = new Rpc.PostMessageRpcClient(keyguard, '*');
await rpc.init();
const rpcType = document.querySelector('input[name="rpc-type-selector"]:checked').value;
let popup, rpc;
if (rpcType === 'popup') {
popup = window.open('../src/request/import/', 'Import Demo',
`left=${window.innerWidth / 2 - 350},top=75,width=700,height=850,location=yes,dependent=yes`);
rpc = new Rpc.PostMessageRpcClient(popup, '*');
await rpc.init();
} else {
rpc = redirectClient;
}

const request = {
appName: 'Import Demo',
requestedKeyPaths: ["m/44'/242'/0'/0'"],
bitcoinXPubPath: `m/84'/1'/0'`, // Bitcoin BIP84 Testnet
polygonAccountPath: `m/44'/699'/0'`, // Polygon Testnet
isKeyLost: document.getElementById('checkbox-isKeyLost').checked,
enableBackArrow: document.getElementById('checkbox-enableBackArrow').checked,
wordsOnly: document.getElementById('checkbox-wordsOnly').checked,
};
try {
const result = await rpc.call('request', {
appName: 'Import Demo',
defaultKeyPath: "m/44'/242'/0'/0'",
requestedKeyPaths: ["m/44'/242'/0'/0'"],
});
document.getElementById('result').value = 'Keyguard result: ' + JSON.stringify(result);
console.log('Keyguard result:', result);
if (rpcType === 'popup') {
const result = await rpc.call('request', request);
await onRequestResult(result);
} else {
await rpc.call(location.href, 'request', /* handleHistoryBack */ true, request);
// result will be handled by redirectClient on return to this page
}
} catch (e) {
console.error(e);
await onRequestError(e);
} finally {
if (popup) {
popup.close();
}
}
}

/**
* @param {unknown} result
*/
function onRequestResult(result) {
console.log('Keyguard result:', result);
$result.textContent = JSON.stringify(result);
}

keyguard.close();
/**
* @param {unknown} error
*/
function onRequestError(error) {
console.error('Keyguard error:', error);
$result.textContent = `Error: ${error.message || error}`;
}
</script>

Expand Down
3 changes: 2 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ module.exports = function (/** @type {any} */ config) {
files: [
{pattern: 'src/lib/Nimiq.mjs', type: 'module'},
{pattern: 'node_modules/@nimiq/core/**/*', included: false},
'src/lib/Observable.js',
'src/lib/Observable.js', // Force load of Observable before global use in I18n.
'src/lib/*.js', // Force load of lib files before components and common.js
'src/request/TopLevelApi.js', // Force load of TopLevelApi before BitcoinEnabledTopLevelApi
'src/lib/bitcoin/*.js',
'node_modules/ethers/dist/ethers.umd.js',
'src/components/BackupCodesIllustrationBase.js', // Force load of parent class before others that extend it
'src/**/*.js',
'tests/**/*.spec.js',
],
Expand Down
25 changes: 25 additions & 0 deletions src/components/BackupCodesIllustration.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* message-bubble positioning for individual steps */

.backup-codes-illustration.backup-codes-intro .message-bubble.code-1,
.backup-codes-illustration.backup-codes-success .message-bubble.code-1 {
transform: translate(-4.5rem, -3.5rem);
transform: translate(round(-4.5rem, 1px), round(-3.5rem, 1px));
}
.backup-codes-illustration.backup-codes-intro .message-bubble.code-2,
.backup-codes-illustration.backup-codes-success .message-bubble.code-2 {
transform: translate(4.5rem, 3.5rem);
transform: translate(round(4.5rem, 1px), round(3.5rem, 1px));
}

.backup-codes-illustration.backup-codes-send-code-1 .message-bubble.code-2,
.backup-codes-illustration.backup-codes-send-code-1-confirm .message-bubble.code-2 {
transform: translate(12.5rem, 5.25rem);
transform: translate(round(12.5rem, 1px), round(5.25rem, 1px));
z-index: -1;
}

.backup-codes-illustration.backup-codes-send-code-2 .message-bubble.code-1,
.backup-codes-illustration.backup-codes-send-code-2-confirm .message-bubble.code-1 {
transform: translate(-12.5rem, -5.25rem);
transform: translate(round(-12.5rem, 1px), round(-5.25rem, 1px));
}
Loading
Loading