aboutsummaryrefslogtreecommitdiffstats
path: root/src/passkey.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/passkey.js')
-rw-r--r--src/passkey.js70
1 files changed, 65 insertions, 5 deletions
diff --git a/src/passkey.js b/src/passkey.js
index 4bbd01d..248e1e6 100644
--- a/src/passkey.js
+++ b/src/passkey.js
@@ -27,23 +27,28 @@ function skFromPrf(prf) {
const NO_PRF = 'this device’s passkeys can’t derive a wallet key (no PRF support)';
-async function assertionPrf(allowCredentials) {
+async function assertion(salt, allowCredentials) {
const cred = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
userVerification: 'required',
...(allowCredentials ? { allowCredentials } : {}),
- extensions: { prf: { eval: { first: PRF_SALT } } },
+ extensions: { prf: { eval: { first: salt } } },
},
});
const out = cred && cred.getClientExtensionResults().prf?.results?.first;
if (!out) throw new Error(NO_PRF);
- return new Uint8Array(out);
+ return { prf: new Uint8Array(out), cred };
}
+const assertionPrf = async (allowCredentials) => (await assertion(PRF_SALT, allowCredentials)).prf;
-// Sign in with an existing (discoverable) passkey.
+// Sign in with an existing (discoverable) passkey. A device's lock passkey
+// (below) is refused: its PRF would derive a stranger's key, silently.
+export const LOCK_PASSKEY = 'that passkey locks a wallet on this device — it isn’t a sign-in passkey';
export async function passkeySignIn() {
- return skFromPrf(await assertionPrf(null));
+ const { prf, cred } = await assertion(PRF_SALT, null);
+ if (isLockHandle(cred)) throw new Error(LOCK_PASSKEY);
+ return skFromPrf(prf);
}
// Create a passkey, then derive the key from it. Some platforms evaluate PRF
@@ -69,3 +74,58 @@ export async function passkeyCreate() {
if (ext.prf && ext.prf.enabled === false) throw new Error(NO_PRF);
return skFromPrf(await assertionPrf([{ type: 'public-key', id: cred.rawId }]));
}
+
+// ---------------------------------------------------------------------------
+// Lock with a passkey: the saved-wallets vault encrypted under a secret only
+// the passkey can produce (its PRF, behind a fingerprint or face), instead of
+// a password. The passkey lives in the platform's secure hardware — Google
+// Password Manager, iCloud Keychain, a security key — so a copy of this
+// browser's storage holds only ciphertext. A different salt from sign-in's,
+// and a user handle that marks it, so the two can never be mistaken.
+const LOCK_SALT = sha256(new TextEncoder().encode('coinos-vault-lock-v1'));
+const LOCK_TAG = new TextEncoder().encode('coinos-vault-lock-key:v1');
+const LOCK_HANDLE = new TextEncoder().encode('coinos-lock:');
+function isLockHandle(cred) {
+ const u = cred && cred.response && cred.response.userHandle;
+ if (!u) return false;
+ const b = new Uint8Array(u);
+ return b.length > LOCK_HANDLE.length && LOCK_HANDLE.every((x, i) => b[i] === x);
+}
+const b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+const unb64u = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+// The vault password a lock passkey stands for: never typed, never shown.
+function lockSecret(prf) {
+ const buf = new Uint8Array(LOCK_TAG.length + prf.length);
+ buf.set(LOCK_TAG, 0); buf.set(prf, LOCK_TAG.length);
+ return 'passkey:' + Array.from(sha256(buf), (x) => x.toString(16).padStart(2, '0')).join('');
+}
+
+// A new lock passkey for this device: { id, secret }. Not discoverable where
+// the platform lets us choose, so it stays out of the sign-in picker.
+export async function lockPasskeyCreate() {
+ const handle = new Uint8Array(LOCK_HANDLE.length + 16);
+ handle.set(LOCK_HANDLE, 0); handle.set(crypto.getRandomValues(new Uint8Array(16)), LOCK_HANDLE.length);
+ const cred = await navigator.credentials.create({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ rp: { name: 'Coinos', id: location.hostname },
+ user: { id: handle, name: 'coinos wallet lock', displayName: 'Coinos wallet lock' },
+ pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
+ authenticatorSelection: { residentKey: 'discouraged', userVerification: 'required' },
+ extensions: { prf: { eval: { first: LOCK_SALT } } },
+ },
+ });
+ const id = b64u(cred.rawId);
+ const ext = cred.getClientExtensionResults();
+ const direct = ext.prf?.results?.first;
+ if (direct) return { id, secret: lockSecret(new Uint8Array(direct)) };
+ if (ext.prf && ext.prf.enabled === false) throw new Error(NO_PRF);
+ return { id, secret: await lockPasskeyUnlock(id) };
+}
+// The fingerprint or face check that opens the vault: its secret.
+export async function lockPasskeyUnlock(id) {
+ const { prf } = await assertion(LOCK_SALT, [{ type: 'public-key', id: unb64u(id) }]);
+ return lockSecret(prf);
+}
+// A dismissed dialog is a plain cancel, not an error worth showing.
+export const passkeyCancelled = (e) => /NotAllowed|AbortError|abort/i.test(String((e && e.name) || '') + ' ' + String((e && e.message) || ''));