duly / web/lib/session.js · raw
// 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;}// ------------------------------------------------------------- passkeysasync 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;}// -------------------------------------------------------------- storagefunction 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  }}