21 | Local Data Lock: Tests
22 |
23 |
24 |
25 |
26 |
27 | Note: these tests require a passkey-enabled device (TouchID, FaceID, etc); also, the data entered is saved (encrypted!) only in the session-storage on this device, which can be inspected via this browser's developer-tools.
28 |
29 | Steps To Run Tests:
30 |
31 | Register a local account (providing a username and display-name for the first passkey on the account).
32 | Register another local account (providing a different username and display name for the passkey on that second account).
33 | Select one of the accounts from the drop-down list, and click the 'unlock account' button.
34 | Type some text into the box, and click the 'save' button.
35 | Click the 'sign & verify' button, then click the 'Sign' button (after modifying the text, if you prefer), and look for "Verified!" under the signature. Close the dialog.
36 | Click the 'lock account' button.
37 | Click the 'detect (and unlock) account' button; you will be prompted to choose one of the passkeys for one of the registered local accounts.
38 | Click 'add passkey' and provide yet another username and display-name for the additional passkey on the currently selected account.
39 | Change the 'Passkey Keep-Alive' value to 1 minute, and click the 'set' button.
40 | Wait at least 1 minute, then enter (or change) some text, and click 'save'; you will be prompted to re-authenticate a registered passkey.
41 | While logged into both accounts, you will be able to switch between them (using the dropdown and the 'login to account' button), and update the text for each account and click 'save' button... all WITHOUT being re-prompted for any passkeys; once the 1 minute has expired, you'll be prompted for the passkey at the first interaction with each account.
42 | Click the 'reset account' button; you will be prompted to create a new passkey for the current account (previous passkeys will be discarded).
43 | Change the passkey timeout from 0 to 5 (seconds).
44 | Click 'lock account', then 'unlock account'. Wait for at least 5 seconds, and see the authentication dialog be canceled/closed and an error message displayed.
45 |
46 |
47 | When complete with testing:
48 |
49 |
50 | Click the 'reset (remove all accounts)' button.
51 | Use the device's system management settings to remove all the passkeys registered during testing.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | (?)
60 | Passkey Keep-Alive: min
61 |
62 |
set
63 |
64 |
65 |
66 | (?)
67 | Passkey Timeout: sec
68 |
69 |
set (0 to disable)
70 |
71 |
72 |
73 | register local account
74 | detect (and unlock) account
75 | reset (remove all accounts)
76 |
77 |
78 |
79 | - select local account -
80 |
81 | unlock account
82 | lock account
83 |
84 |
85 | add passkey
86 | reset account
87 | sign & verify
88 |
89 |
90 |
91 |
92 |
93 | save
94 |
95 |
96 |
97 |
98 |
99 |
100 |
114 |
115 |
116 |
117 |
118 |
119 |
--------------------------------------------------------------------------------
/test/spinner.js:
--------------------------------------------------------------------------------
1 | var spinnerStart = Scheduler(300,400);
2 | var spinnerCancel;
3 |
4 |
5 | // ***********************
6 |
7 | export { startSpinner, stopSpinner, };
8 |
9 |
10 | // ***********************
11 |
12 | function startSpinner() {
13 | if (!spinnerCancel) {
14 | spinnerCancel = spinnerStart(showSpinner);
15 | }
16 | }
17 |
18 | function showSpinner() {
19 | Swal.fire({
20 | position: "top",
21 | showConfirmButton: false,
22 | allowOutsideClick: false,
23 | allowEscapeKey: false,
24 | customClass: {
25 | popup: "spinner-popup",
26 | },
27 | });
28 | Swal.showLoading();
29 | }
30 |
31 | function stopSpinner() {
32 | if (spinnerCancel) {
33 | spinnerCancel();
34 | spinnerCancel = null;
35 | if (Swal.isVisible() && Swal.getPopup().matches(".spinner-popup")) {
36 | return Swal.close();
37 | }
38 | }
39 | }
40 |
41 | function Scheduler(debounceMin,throttleMax) {
42 | var entries = new WeakMap();
43 |
44 | return schedule;
45 |
46 |
47 | // ***********************
48 |
49 | function schedule(fn) {
50 | var entry;
51 |
52 | if (entries.has(fn)) {
53 | entry = entries.get(fn);
54 | }
55 | else {
56 | entry = {
57 | last: 0,
58 | timer: null,
59 | };
60 | entries.set(fn,entry);
61 | }
62 |
63 | var now = Date.now();
64 |
65 | if (!entry.timer) {
66 | entry.last = now;
67 | }
68 |
69 | if (
70 | // no timer running yet?
71 | entry.timer == null ||
72 | // room left to debounce while still under the throttle-max?
73 | (now - entry.last) < throttleMax
74 | ) {
75 | if (entry.timer) {
76 | clearTimeout(entry.timer);
77 | }
78 |
79 | let time = Math.min(debounceMin,Math.max(0,(entry.last + throttleMax) - now));
80 | entry.timer = setTimeout(run,time,fn,entry);
81 | }
82 |
83 | if (!entry.cancelFn) {
84 | entry.cancelFn = function cancel(){
85 | if (entry.timer) {
86 | clearTimeout(entry.timer);
87 | entry.timer = entry.cancelFn = null;
88 | }
89 | };
90 | }
91 | return entry.cancelFn;
92 | }
93 |
94 | function run(fn,entry) {
95 | entry.timer = entry.cancelFn = null;
96 | entry.last = Date.now();
97 | fn();
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | import {
2 | supportsWebAuthn,
3 | supportsWAUserVerification,
4 | listLocalIdentities,
5 | clearLockKeyCache,
6 | removeLocalAccount,
7 | getLockKey,
8 | lockData,
9 | unlockData,
10 | signData,
11 | verifySignature,
12 | configure,
13 | resetAbortReason,
14 | }
15 | // note: this module specifier comes from the import-map
16 | // in index.html; swap "src" for "dist" here to test
17 | // against the dist/* files
18 | from "local-data-lock/src";
19 | import SSStore from "@byojs/storage/session-storage";
20 |
21 | // simple helper util for showing a spinner
22 | // (during slower passkey operations)
23 | import { startSpinner, stopSpinner, } from "./spinner.js";
24 |
25 |
26 | configure({
27 | accountStorage: "session-storage",
28 | });
29 |
30 |
31 | // ***********************
32 |
33 | var passkeyKeepAliveEl;
34 | var setPasskeyKeepAliveBtn;
35 | var passkeyTimeoutEl;
36 | var setPasskeyTimeoutBtn;
37 | var registerAccountBtn;
38 | var detectAccountBtn;
39 | var resetAllAccountsBtn;
40 | var selectAccountEl;
41 | var unlockAccountBtn;
42 | var addPasskeyBtn;
43 | var resetAccountBtn;
44 | var lockAccountBtn;
45 | var accountDataEl;
46 | var saveDataBtn;
47 | var signVerifyBtn;
48 |
49 | var currentAccountID;
50 | var localAccountIDs = await listLocalIdentities();
51 | var passkeyTimeout = 0;
52 |
53 | if (document.readyState == "loading") {
54 | document.addEventListener("DOMContentLoaded",ready,false);
55 | }
56 | else {
57 | ready();
58 | }
59 |
60 |
61 | // ***********************
62 |
63 | async function ready() {
64 | passkeyKeepAliveEl = document.getElementById("passkey-keep-alive");
65 | setPasskeyKeepAliveBtn = document.getElementById("set-passkey-keep-alive-btn");
66 | passkeyTimeoutEl = document.getElementById("passkey-timeout");
67 | setPasskeyTimeoutBtn = document.getElementById("set-passkey-timeout-btn");
68 | registerAccountBtn = document.getElementById("register-account-btn");
69 | detectAccountBtn = document.getElementById("detect-account-btn");
70 | resetAllAccountsBtn = document.getElementById("reset-all-accounts-btn");
71 | selectAccountEl = document.getElementById("select-account");
72 | unlockAccountBtn = document.getElementById("unlock-account-btn");
73 | addPasskeyBtn = document.getElementById("add-passkey-btn");
74 | resetAccountBtn = document.getElementById("reset-account-btn");
75 | lockAccountBtn = document.getElementById("lock-account-btn");
76 | accountDataEl = document.getElementById("account-data");
77 | saveDataBtn = document.getElementById("save-data-btn");
78 | signVerifyBtn = document.getElementById("sign-verify-btn");
79 |
80 | selectAccountEl.addEventListener("change",changeSelectedAccount,false);
81 | accountDataEl.addEventListener("input",onChangeAccountData,false);
82 |
83 | setPasskeyKeepAliveBtn.addEventListener("click",setKeepAlive,false);
84 | setPasskeyTimeoutBtn.addEventListener("click",setPasskeyTimeout,false);
85 | registerAccountBtn.addEventListener("click",registerAccount,false);
86 | detectAccountBtn.addEventListener("click",detectAccount,false);
87 | resetAllAccountsBtn.addEventListener("click",resetAllAccounts,false);
88 | unlockAccountBtn.addEventListener("click",unlockAccount,false);
89 | addPasskeyBtn.addEventListener("click",addPasskey,false);
90 | resetAccountBtn.addEventListener("click",resetAccount,false);
91 | lockAccountBtn.addEventListener("click",lockAccount,false);
92 | saveDataBtn.addEventListener("click",saveData,false);
93 | signVerifyBtn.addEventListener("click",signAndVerify,false);
94 |
95 | updateElements();
96 | }
97 |
98 | function updateElements() {
99 | selectAccountEl.disabled = (localAccountIDs.length == 0);
100 | selectAccountEl.options.length = 1;
101 | for (let localID of localAccountIDs) {
102 | let optionEl = document.createElement("option");
103 | optionEl.value = localID;
104 | optionEl.innerHTML = localID;
105 | selectAccountEl.appendChild(optionEl);
106 | }
107 |
108 | if (localAccountIDs.length > 0) {
109 | detectAccountBtn.disabled = false;
110 | resetAllAccountsBtn.disabled = false;
111 | }
112 | else {
113 | detectAccountBtn.disabled = true;
114 | resetAllAccountsBtn.disabled = true;
115 | unlockAccountBtn.disabled = true;
116 | }
117 |
118 | if (localAccountIDs.includes(currentAccountID)) {
119 | selectAccountEl.value = currentAccountID;
120 | addPasskeyBtn.disabled = false;
121 | resetAccountBtn.disabled = false;
122 | lockAccountBtn.disabled = false;
123 | signVerifyBtn.disabled = false;
124 | accountDataEl.disabled = false;
125 | }
126 | else {
127 | addPasskeyBtn.disabled = true;
128 | resetAccountBtn.disabled = true;
129 | lockAccountBtn.disabled = true;
130 | signVerifyBtn.disabled = true;
131 | accountDataEl.disabled = true;
132 | accountDataEl.value = "";
133 | selectAccountEl.selectedIndex = 0;
134 | }
135 | }
136 |
137 | function changeSelectedAccount() {
138 | if (selectAccountEl.selectedIndex > 0) {
139 | unlockAccountBtn.disabled = false;
140 | }
141 | else {
142 | unlockAccountBtn.disabled = true;
143 | }
144 | }
145 |
146 | function onChangeAccountData() {
147 | saveDataBtn.disabled = false;
148 | }
149 |
150 | async function setKeepAlive() {
151 | var keepAlive = Math.max(1,Number(passkeyKeepAliveEl.value != null ? passkeyKeepAliveEl.value : 30));
152 | passkeyKeepAliveEl.value = keepAlive;
153 |
154 | configure({ cacheLifetime: keepAlive * 60 * 1000, });
155 | showToast(`Passkey Keep-Alive set to ${keepAlive} minute(s)`);
156 | }
157 |
158 | async function setPasskeyTimeout() {
159 | passkeyTimeout = Math.max(0,Number(passkeyTimeoutEl.value != null ? passkeyTimeoutEl.value : 0));
160 | passkeyTimeoutEl.value = passkeyTimeout;
161 |
162 | if (passkeyTimeout > 0) {
163 | showToast(`Passkey Timeout set to ${passkeyTimeout} second(s)`);
164 | }
165 | else {
166 | showToast(`Passkey Timeout disabled (0 seconds)`);
167 | }
168 | }
169 |
170 | async function promptAddPasskey() {
171 | if (!checkWebAuthnSupport()) return;
172 |
173 | var passkeyUsernameEl;
174 | var passkeyDisplayNameEl;
175 |
176 | var result = await Swal.fire({
177 | title: "Add Passkey",
178 | html: `
179 |
546 |
547 | ${
548 | JSON.stringify(msg,null," ")
549 | }
550 |
551 |
552 | Signature: ${signature}
553 |
554 |
${verified ? "Verified!" : "Not verified"}
555 |
556 | `,
557 | showConfirmButton: true,
558 | confirmButtonText: "OK",
559 | confirmButtonColor: "darkslateblue",
560 | showCancelButton: false,
561 | allowOutsideClick: true,
562 | allowEscapeKey: true,
563 | });
564 | }
565 | catch (err) {
566 | if (intv != null) { clearTimeout(intv); }
567 | logError(err);
568 | stopSpinner();
569 | showError("Signing/verifying failed.");
570 | }
571 | }
572 | }
573 |
574 | async function unlockAccountData(accountID,key) {
575 | var data = await loadAccountData(accountID);
576 | if (typeof data == "string") {
577 | if (data != "") {
578 | let text = unlockData(data,key,{ parseJSON: false, });
579 | accountDataEl.value = text;
580 | }
581 | else {
582 | accountDataEl.value = "";
583 | }
584 | }
585 | else {
586 | accountDataEl.value = "";
587 | }
588 | }
589 |
590 | async function lockAccountData(accountID,key,data) {
591 | await storeAccountData(accountID,lockData(data,key));
592 | }
593 |
594 | async function loadAccountData(accountID) {
595 | var data = await SSStore.get(`account-data-${accountID}`);
596 | if (typeof data == "string") {
597 | return data;
598 | }
599 | }
600 |
601 | async function storeAccountData(accountID,data) {
602 | await SSStore.set(`account-data-${accountID}`,data);
603 | }
604 |
605 | function logError(err,returnLog = false) {
606 | var err = `${
607 | err.stack ? err.stack : err.toString()
608 | }${
609 | err.cause ? `\n${logError(err.cause,/*returnLog=*/true)}` : ""
610 | }`;
611 | if (returnLog) return err;
612 | else console.error(err);
613 | }
614 |
615 | function showError(errMsg) {
616 | return Swal.fire({
617 | title: "Error!",
618 | text: errMsg,
619 | icon: "error",
620 | confirmButtonText: "OK",
621 | });
622 | }
623 |
624 | function showToast(toastMsg) {
625 | return Swal.fire({
626 | text: toastMsg,
627 | showConfirmButton: false,
628 | showCloseButton: true,
629 | timer: 5000,
630 | toast: true,
631 | position: "top-end",
632 | customClass: {
633 | popup: "toast-popup",
634 | },
635 | });
636 | }
637 |
638 | function createTimeoutToken(seconds) {
639 | if (seconds > 0) {
640 | let ac = new AbortController();
641 | let intv = setTimeout(() => ac.abort("Timeout!"),seconds * 1000);
642 | return { signal: ac.signal, intv, };
643 | }
644 | }
645 |
646 | async function checkWebAuthnSupport() {
647 | if (!(
648 | supportsWebAuthn &&
649 | supportsWAUserVerification
650 | )) {
651 | showError("Sorry, but this device doesn't seem to support the proper passkey functionality (including user-verification).");
652 | return false;
653 | }
654 | }
655 |
--------------------------------------------------------------------------------