aboutsummaryrefslogtreecommitdiffstats
path: root/src/passkey.js
blob: 248e1e61997d491e7c4a243805d4dfb818fdf905 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// Passkey sign-in: WebAuthn's PRF extension turns a passkey into a
// deterministic 32-byte secret, which hashes into a nostr key — and the
// login flow then treats it exactly like a pasted nsec, so the wallet seed
// derives from it the usual way. No server anywhere: the wallet follows the
// passkey wherever the platform syncs it (iCloud Keychain, Google Password
// Manager), and the same passkey always opens the same wallet because the
// PRF salt is a fixed app-scoped constant.
import { sha256 } from '@noble/hashes/sha256';
import { secp256k1 } from '@noble/curves/secp256k1';

const PRF_SALT = sha256(new TextEncoder().encode('coinos-passkey-v1'));
const DERIVE_TAG = new TextEncoder().encode('coinos-passkey-key:v1');

export const passkeySupported = () =>
  typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;

// Hash the PRF output into a valid secp256k1 scalar. An out-of-range hash is
// a ~2^-128 event, but the loop makes the derivation total rather than lucky.
function skFromPrf(prf) {
  let buf = new Uint8Array(DERIVE_TAG.length + prf.length);
  buf.set(DERIVE_TAG, 0);
  buf.set(prf, DERIVE_TAG.length);
  let sk = sha256(buf);
  while (!secp256k1.utils.isValidPrivateKey(sk)) sk = sha256(sk);
  return sk;
}

const NO_PRF = 'this device’s passkeys can’t derive a wallet key (no PRF support)';

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: salt } } },
    },
  });
  const out = cred && cred.getClientExtensionResults().prf?.results?.first;
  if (!out) throw new Error(NO_PRF);
  return { prf: new Uint8Array(out), cred };
}
const assertionPrf = async (allowCredentials) => (await assertion(PRF_SALT, allowCredentials)).prf;

// 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() {
  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
// during creation; the rest need a follow-up assertion against the new
// credential — both paths end at the same bytes.
export async function passkeyCreate() {
  const cred = await navigator.credentials.create({
    publicKey: {
      challenge: crypto.getRandomValues(new Uint8Array(32)),
      rp: { name: 'Coinos', id: location.hostname },
      user: {
        id: crypto.getRandomValues(new Uint8Array(16)),
        name: 'coinos', displayName: 'Coinos wallet',
      },
      pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
      authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
      extensions: { prf: { eval: { first: PRF_SALT } } },
    },
  });
  const ext = cred.getClientExtensionResults();
  const direct = ext.prf?.results?.first;
  if (direct) return skFromPrf(new Uint8Array(direct));
  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) || ''));