// Merchant keys. One 32-byte root secret is the whole account: it derives the NEAR // signing key, the key that opens invoice documents, and each jar's key. The root // comes from a passkey (WebAuthn PRF) or a recovery key, and never leaves the device. import { getPublicKeyAsync, signAsync } from '../vendor/ed25519.js'; import { b58decode, b58encode, hex, implicitAccount, publicKeyString } from './near.js'; const te = new TextEncoder(); export async function hkdf(root, info, length = 32) { const key = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt: te.encode('duly-v1'), info: te.encode(info) }, key, length * 8, ); return new Uint8Array(bits); } /** A signer for one derived account. `path` separates the main account from jars. */ export async function deriveSigner(root, path = 'main') { const seed = await hkdf(root, `near-ed25519/${path}`); const publicKey = await getPublicKeyAsync(seed); return { account: implicitAccount(publicKey), publicKey, publicKeyString: publicKeyString(publicKey), sign: (hash) => signAsync(hash, seed), }; } export async function openSession(root) { if (!(root instanceof Uint8Array) || root.length !== 32) throw new Error('Bad key'); const main = await deriveSigner(root); return { root, main, account: main.account, docKey: await hkdf(root, 'documents') }; } export function newRoot() { return crypto.getRandomValues(new Uint8Array(32)); } /** A recovery key is the root in base58 with a short checksum, so typos are caught. */ export async function encodeRecovery(root) { const check = (await hkdf(root, 'recovery-check', 2)); return `duly-${b58encode(root)}-${hex(check)}`; } export async function decodeRecovery(text) { const m = String(text).trim().match(/^duly-([1-9A-HJ-NP-Za-km-z]+)-([0-9a-f]{4})$/); if (!m) throw new Error('That does not look like a recovery key'); const root = b58decode(m[1]); if (root.length !== 32) throw new Error('That does not look like a recovery key'); if (hex(await hkdf(root, 'recovery-check', 2)) !== m[2]) throw new Error('That recovery key has a typo'); return root; } // ------------------------------------------------------------- passkeys async function prfSalt() { return new Uint8Array(await crypto.subtle.digest('SHA-256', te.encode('duly-merchant-root-v1'))); } export function passkeysAvailable() { return typeof PublicKeyCredential !== 'undefined' && !!navigator.credentials; } function prfResult(cred) { const first = cred.getClientExtensionResults?.().prf?.results?.first; return first ? new Uint8Array(first) : null; } async function assertPrf(allow) { const cred = await navigator.credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: location.hostname, userVerification: 'required', allowCredentials: allow ? [{ type: 'public-key', id: allow }] : [], extensions: { prf: { eval: { first: await prfSalt() } } }, }, }); return prfResult(cred); } /** Returns a root derived from a new passkey, or null if this device has no PRF. */ export async function createPasskeyRoot() { const cred = await navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rp: { name: 'Duly', id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'Duly account', displayName: 'Duly account' }, pubKeyCredParams: [ { type: 'public-key', alg: -8 }, { type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }, ], authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, extensions: { prf: { eval: { first: await prfSalt() } } }, }, }); return prfResult(cred) || (await assertPrf(cred.rawId)); } export async function signInWithPasskey() { const root = await assertPrf(null); if (!root) throw new Error('This passkey cannot sign in here. Use your recovery key.'); return root; } // -------------------------------------------------------------- storage function store(net) { // Test mode remembers the device; live mode lasts for the tab unless asked. return net === 'testnet' ? localStorage : sessionStorage; } const slot = (net) => `duly.session.${net}`; export function saveRoot(net, root, remember = false) { const value = b58encode(root); try { (remember ? localStorage : store(net)).setItem(slot(net), value); } catch { // private windows may refuse storage; the session still works until reload } } export function loadRoot(net) { try { const v = sessionStorage.getItem(slot(net)) || localStorage.getItem(slot(net)); return v ? b58decode(v) : null; } catch { return null; } } export function clearRoot(net) { try { sessionStorage.removeItem(slot(net)); localStorage.removeItem(slot(net)); } catch { // nothing to clear } }