aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/Pin.svelte
blob: a429e9760f6cf7b51f7834031bcc8cf35c63a2eb (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
<script>
  import { t } from "$lib/translations";
  import { post, success, fail, focus, setCookie } from "$lib/utils";
  import Pinpad from "$comp/Pinpad.svelte";
  import { pin } from "$lib/store";
  import { onMount } from "svelte";

  export let value = "";
  export let title = $t("user.settings.verifyPIN");
  export let cancel = () => (p = "");
  export let notify = true;

  let p = "";

  $: update(value);
  let update = (value) => (p = value);

  $: p.toString().length > 5 && (value = p.toString());

  let loaded;
  onMount(() => setTimeout(() => (loaded = true), 500));

  let locktime = 30 * 60;

  $: loaded && checkPin(p);
  let checkPin = async (p) => {
    if (p?.length !== 6) return;
    let result;
    try {
      result = await post("/pin", { pin: p });
    } catch (e) {
      console.log("Pin check failed", e);
    }

    if (result) {
      if (notify) success("Pin confirmed");
      if (locktime) setCookie("pin", p, locktime);
      $pin = p;
    } else {
      fail("Invalid pin");
      value = "";
    }
  };
</script>

{#if loaded}
  <div
    role="dialog"
    class="fixed bg-base-100 bg-opacity-90 inset-0 h-full w-full z-50 cursor-default"
    on:click|preventDefault
    aria-labelledby="title"
  >
    <div
      class="mx-auto p-5 border shadow-lg rounded-md bg-base-100 space-y-5 max-w-lg"
    >
      <h1 id="title" class="text-center text-2xl font-semibold">{title}</h1>
      <Pinpad bind:v={p} {cancel} />

      <div>
        <label for="locktime" class="font-bold"
          >{$t("user.settings.rememberFor")}</label
        >
        <select name="locktime" bind:value={locktime}>
          <option value={30}>30 {$t("user.settings.seconds")}</option>
          <option value={5 * 60}>5 {$t("user.settings.minutes")}</option>
          <option value={10 * 60}>10 {$t("user.settings.minutes")}</option>
          <option value={30 * 60}>30 {$t("user.settings.minutes")}</option>
          <option value={60 * 60}>1 {$t("user.settings.hour")}</option>
          <option value={8 * 60 * 60}>8 {$t("user.settings.hours")}</option>
        </select>
      </div>

      <div class="w-full flex">
        <button class="btn" on:click|preventDefault={cancel}>
          <div class="my-auto">Cancel</div>
        </button>
      </div>
    </div>
  </div>
{/if}
lor: #FDD } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ .highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #A00 } /* Generic.Error */ .highlight .gh { color: #333 } /* Generic.Heading */ .highlight .gi { color: #000; background-color: #DFD } /* Generic.Inserted */ .highlight .go { color: #888 } /* Generic.Output */ .highlight .gp { color: #555 } /* Generic.Prompt */ .highlight .gs { font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #666 } /* Generic.Subheading */ .highlight .gt { color: #A00 } /* Generic.Traceback */ .highlight .kc { color: #080; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #080; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #080; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #080 } /* Keyword.Pseudo */ .highlight .kr { color: #080; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #888; font-weight: bold } /* Keyword.Type */ .highlight .m { color: #00D; font-weight: bold } /* Literal.Number */ .highlight .s { color: #D20; background-color: #FFF0F0 } /* Literal.String */ .highlight .na { color: #369 } /* Name.Attribute */ .highlight .nb { color: #038 } /* Name.Builtin */ .highlight .nc { color: #B06; font-weight: bold } /* Name.Class */ .highlight .no { color: #036; font-weight: bold } /* Name.Constant */ .highlight .nd { color: #555 } /* Name.Decorator */ .highlight .ne { color: #B06; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #06B; font-weight: bold } /* Name.Function */ .highlight .nl { color: #369; font-style: italic } /* Name.Label */ .highlight .nn { color: #B06; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #369; font-weight: bold } /* Name.Property */ .highlight .nt { color: #B06; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #369 } /* Name.Variable */ .highlight .ow { color: #080 } /* Operator.Word */ .highlight .w { color: #BBB } /* Text.Whitespace */ .highlight .mb { color: #00D; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #00D; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #00D; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #00D; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #00D; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #D20; background-color: #FFF0F0 } /* Literal.String.Affix */ .highlight .sb { color: #D20; background-color: #FFF0F0 } /* Literal.String.Backtick */ .highlight .sc { color: #D20; background-color: #FFF0F0 } /* Literal.String.Char */ .highlight .dl { color: #D20; background-color: #FFF0F0 } /* Literal.String.Delimiter */ .highlight .sd { color: #D20; background-color: #FFF0F0 } /* Literal.String.Doc */ .highlight .s2 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Double */ .highlight .se { color: #04D; background-color: #FFF0F0 } /* Literal.String.Escape */ .highlight .sh { color: #D20; background-color: #FFF0F0 } /* Literal.String.Heredoc */ .highlight .si { color: #33B; background-color: #FFF0F0 } /* Literal.String.Interpol */ .highlight .sx { color: #2B2; background-color: #F0FFF0 } /* Literal.String.Other */ .highlight .sr { color: #080; background-color: #FFF0FF } /* Literal.String.Regex */ .highlight .s1 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Single */ .highlight .ss { color: #A60; background-color: #FFF0F0 } /* Literal.String.Symbol */ .highlight .bp { color: #038 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #06B; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #369 } /* Name.Variable.Class */ .highlight .vg { color: #D70 } /* Name.Variable.Global */ .highlight .vi { color: #33B } /* Name.Variable.Instance */ .highlight .vm { color: #369 } /* Name.Variable.Magic */ .highlight .il { color: #00D; font-weight: bold } /* Literal.Number.Integer.Long */
// Cordn in the app: this device's standing with a coordinator (its MLS
// signature key and a published KeyPackage), its groups, and the traffic
// with the coordinator. Protocol rules live in cordn.js, the transport in
// contextvm.js; the messages feature hears about everything through
// `on(type, group, data)`, the same way it hears from marmot-client.js, and
// drives both through one interface.
//
// Like Marmot, MLS state is per DEVICE and lives in IndexedDB: a second
// device of the same account is a second leaf with its own KeyPackage.

import * as C from './cordn.js';
import { cvmClient } from './contextvm.js';
import { marmotStore } from './marmot-store.js';
import { finalizeEvent, nip44 } from './nostr.js';
import { dlog } from './debug.js';

const DAY = 86400;
const KP_REFRESH = 7 * DAY;    // republish well inside the 84-day lifetime; the coordinator keeps one last-resort package per identity
const POLL_FOCUSED = 4000;      // a Cordn group on screen
const POLL_IDLE = 45_000;       // groups held, none on screen
const POLL_EMPTY = 180_000;     // no Cordn groups at all: only Welcomes to look for
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// `store` is injectable for tests outside a browser (no IndexedDB there).
export function cordnClient({ scope, pubkey, identity, on, coordinator = C.DEFAULT_COORDINATOR, subClosed, store = marmotStore(scope + '|cordn') }) {
  const groups = new Map();   // gid -> record
  let dev = null, loaded = null, stopped = false, cvm = null, cvmSigner = null;
  let kpBusy = null, pollTimer = 0, polling = null, focused = null, rotateTimer = 0;
  const dirty = new Set();
  let flushTimer = 0;

  const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('cordn: listener', e); } };

  // ---- storage ----

  function load() {
    if (!loaded)
      loaded = store.all().then((all) => {
        dev = all.get('dev') || null;
        for (const [k, v] of all) if (k.startsWith('g:') && v && v.tip) groups.set(v.id, v);
      }).catch((e) => { dlog('cordn: store', e); });
    return loaded;
  }
  const touch = (g) => { dirty.add(g.id); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 400); };
  async function flush() {
    clearTimeout(flushTimer);
    const ids = [...dirty];
    dirty.clear();
    await Promise.all(ids.map((id) => groups.has(id) ? store.put('g:' + id, groups.get(id)) : null)).catch((e) => dlog('cordn: save', e));
  }
  // a ratchet that moved is on disk before anything of ours leaves
  const persist = (g) => { dirty.delete(g.id); return store.put('g:' + g.id, g); };
  const saveDev = () => store.put('dev', dev);

  // ---- this device and its coordinator ----

  async function signer({ interactive = false } = {}) {
    const id = await identity();
    if (!id || !id.signer || id.pubkey !== pubkey) return null;
    if (id.signer instanceof Uint8Array) {
      const sk = id.signer;
      return { raw: sk, sign: async (e) => finalizeEvent(e, sk), decrypt: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, peer)) };
    }
    // Every coordinator call is signed, and every reply decrypted, by the
    // signer: an interactive one (a trip through another app per call) is
    // never used unasked.
    if (id.signer.interactive && !interactive) return null;
    if (!id.signer.decryptFrom) return null;
    return { raw: null, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) };
  }

  // The transport, once a signer can back it. Rebuilt if the signer changes.
  async function transport(opts) {
    const s = await signer(opts);
    if (!s) return null;
    const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : 'adapter';
    if (cvm && cvmSigner === key) return cvm;
    if (cvm) cvm.stop();
    cvmSigner = key;
    cvm = cvmClient({ server: coordinator.pubkey, relays: coordinator.relays, pubkey, sign: s.sign, decrypt: s.decrypt,
      subClosed: () => { if (!stopped && subClosed) subClosed(); } });
    return cvm;
  }
  // The public coordinator is slow and sometimes silent (2 of 12 probes went
  // unanswered in 15 s on 2026-10-06), so a call is tried twice. A repeated
  // msg_post carries the same bytes, which every reader drops as a duplicate;
  // kp_take is never repeated (a second take would spend a second package).
  const CALL_MS = 12_000;
  const call = async (name, args, opts) => {
    const t = await transport(opts);
    if (!t) throw new Error('no signer');
    const tries = name === 'kp_take' ? 1 : 2;
    let err;
    for (let i = 0; i < tries; i++) {
      try { return await t.call(name, args, { timeoutMs: CALL_MS }); }
      catch (e) { err = e; if (!/did not answer|no relay took/.test(e.message)) throw e; }
    }
    throw err;
  };

  // A device record and a current last-resort KeyPackage on the coordinator.
  function ready(opts = {}) {
    if (!kpBusy) kpBusy = ensure(opts).catch((e) => { dlog('cordn: keypackage', e); return false; }).finally(() => { kpBusy = null; });
    return kpBusy;
  }
  async function ensure(opts) {
    await load();
    const cur = dev && dev.kps && dev.kps.find((k) => k.published && !k.spent);
    if (cur && now() - cur.published < KP_REFRESH) return true;
    const t = await transport(opts);
    if (!t) return false;
    if (!dev) { dev = { sig: C.newSigner(), kps: [] }; await saveDev(); }
    return publishKeyPackage(t);
  }
  async function publishKeyPackage(t) {
    const kp = C.newKeyPackage(dev.sig, pubkey);
    // the private half is on disk before the public half is findable
    dev.kps.push(kp);
    await saveDev();
    try {
      await call('kp_publish', { kp_ref: kp.ref, kp_64: C.keyPackageBase64(kp) });
    } catch (e) {
      dev.kps = dev.kps.filter((k) => k !== kp);
      await saveDev();
      throw e;
    }
    kp.published = now();
    // older packages give way; the ones a Welcome may still name stay a while
    const old = dev.kps.filter((k) => k !== kp && k.published);
    for (const k of old) if (now() - k.at > 2 * DAY) { dev.kps = dev.kps.filter((x) => x !== k); call('kp_remove', { kp_refs: [k.ref] }).catch(() => {}); }
    await saveDev();
    return true;
  }

  // Can this person be invited? Their KeyPackage on the coordinator, or null.
  const reach = new Map(); // pk -> { at, p }
  function lookup(pk) {
    const hit = reach.get(pk);
    if (hit && Date.now() - hit.at < 5 * 60_000) return hit.p;
    const p = (async () => {
      const r = await call('kp_list', {});
      const mine = (r.keyPackages || []).filter((k) => k.pk === pk);
      return mine.length ? { pubkey: pk, refs: mine.map((k) => k.kp_ref) } : null;
    })().catch(() => null);
    reach.set(pk, { at: Date.now(), p });
    return p;
  }
  // Take their package off the coordinator for an invitation (a regular one
  // is consumed by this; a last-resort one stays).
  async function takeKeyPackage(pk) {
    const r = await call('kp_take', { id: pk }, { interactive: true });
    if (!r.keyPackage) { const e = new Error('not reachable'); e.missing = [pk]; throw e; }
    return C.readKeyPackage(r.keyPackage.event ? keyPackageOfEvent(r.keyPackage.event) : r.keyPackage.kp_64, pk);
  }
  // The coordinator serves the publication event verbatim; the package is its kp_64 argument.
  function keyPackageOfEvent(ev) {
    try { return JSON.parse(ev.content).params.arguments.kp_64; } catch { throw new Error('bad key package record'); }
  }

  // ---- the stream ----

  function heard(g, out) {
    for (const o of out) {
      if (o.id) emit('message', g, o);
      else if (o.epoch !== undefined) emit('state', g);
      else if (o.removed) emit('removed', g);
      else if (o.invalid) dlog('cordn: skipped record', g.id, o.invalid);
    }
  }

  // Welcomes waiting for us, then every live group's records past its cursor.
  async function poll() {
    if (polling) return polling;
    polling = (async () => {
      const t = await transport();
      if (!t || stopped) return;
      await load();
      try {
        const w = await call('welcome_take', {});
        for (const welcome of w.welcomes || []) await acceptWelcome(welcome).catch((e) => dlog('cordn: welcome', e));
      } catch (e) { dlog('cordn: welcomes', e); }
      const live = [...groups.values()].filter((g) => !g.removed);
      if (!live.length || stopped) return;
      let r;
      try { r = await call('msg_fetch_many', { groups: live.map((g) => ({ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) })) }); } catch (e) { dlog('cordn: fetch', e.message); return; }
      const byGid = new Map();
      for (const m of r.messages || []) (byGid.get(m.gid) || byGid.set(m.gid, []).get(m.gid)).push(m);
      for (const [gid, recs] of byGid) {
        const g = groups.get(gid);
        if (!g) continue;
        recs.sort((a, b) => a.cursor - b.cursor);
        for (const rec of recs) {
          let out;
          try { out = C.receive(g, rec); } catch (e) { dlog('cordn: receive', e); continue; }
          heard(g, out);
        }
        touch(g);
      }
    })().catch((e) => dlog('cordn: poll', e)).finally(() => { polling = null; schedule(); });
    return polling;
  }
  function schedule() {
    clearTimeout(pollTimer);
    if (stopped) return;
    const live = [...groups.values()].some((g) => !g.removed);
    pollTimer = setTimeout(poll, focused ? POLL_FOCUSED : live ? POLL_IDLE : POLL_EMPTY);
  }
  // Which group is on screen: that one is read every few seconds.
  function focus(gid) {
    gid = gid || null;
    if (gid === focused) return;
    focused = gid;
    if (focused) { clearTimeout(pollTimer); poll(); } else schedule();
  }

  async function acceptWelcome(welcome) {
    if (!dev) return null;
    let j;
    try { j = C.join(dev.sig, pubkey, welcome, dev.kps, { cursor: welcome.after || 0 }); } catch (e) { dlog('cordn: welcome', e.message); return null; }
    const old = groups.get(j.g.id);
    // a retained copy is never silently replaced; a group we were removed from can be rejoined
    if (old && !old.removed) return null;
    if (old) j.g.log = old.log;
    groups.set(j.g.id, j.g);
    await persist(j.g);
    emit('joined', j.g);
    // A regular package that got us in is spent; a last-resort one is not,
    // but its keys have been seen: rotate either, a little later (more
    // Welcomes may name it while we were away).
    if (j.used) { j.used.spent = now(); await saveDev(); }
    clearTimeout(rotateTimer);
    rotateTimer = setTimeout(() => { if (!stopped) transport().then((t) => t && publishKeyPackage(t)).catch(() => {}); }, 45_000);
    return j.g;
  }

  // ---- changes of ours ----

  // Post-then-confirm: the commit is group state once the coordinator has
  // it (its own cursor says so) and no peer's commit got in first. The
  // stream is read up to our record so a race is settled in order.
  async function commit(g, proposals, { interactive = true } = {}) {
    const t = await transport({ interactive });
    if (!t) throw new Error('no signer');
    await poll().catch(() => {});
    if (g.removed) throw new Error('not in this group');
    const p = C.stage(g, proposals);
    await persist(g);
    let posted;
    try { posted = await call('msg_post', { gid: g.id, msg_64: p.msg_64 }, { interactive }); }
    catch (e) { C.drop(g); await persist(g); throw e; }
    await settle(g, t, posted.cursor);
    if (g.pending === p) { C.drop(g); await persist(g); throw new Error('the coordinator did not echo the change'); }
    if (g.tip !== p.state) throw new Error('another change got there first');
    return p;
  }
  // Read the stream up to a cursor of ours, so what happened before it is applied first.
  async function settle(g, t, cursor) {
    for (let i = 0; i < 4 && g.cursor < cursor && !stopped; i++) {
      if (i) await sleep(500 * i);
      let r;
      try { r = await call('msg_fetch_many', { groups: [{ gid: g.id, ...(g.cursor ? { after: g.cursor } : {}) }] }); } catch { continue; }
      const recs = (r.messages || []).filter((m) => m.gid === g.id).sort((a, b) => a.cursor - b.cursor);
      for (const rec of recs) { let out; try { out = C.receive(g, rec); } catch { continue; } heard(g, out); }
    }
    await persist(g);
  }

  async function deliverWelcomes(g, t, welcome, cands, after) {
    if (!welcome) return;
    const welcome_64 = C.welcomeBase64(welcome);
    for (const c of cands) {
      try { await call('welcome_store', { target_pk: c.pubkey, kp_ref: c.ref, welcome_64, ...(after ? { after } : {}) }, { interactive: true }); }
      catch (e) { dlog('cordn: welcome_store', e); }
    }
  }

  async function candidates(pks) {
    const out = [];
    const missing = [];
    for (const pk of pks) { try { out.push(await takeKeyPackage(pk)); } catch (e) { if (e.missing) missing.push(pk); else throw e; } }
    if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; }
    return out;
  }

  async function create({ name = '', icon = '', members = [] }) {
    if (!(await ready({ interactive: true }))) throw new Error('no signer');
    const t = await transport({ interactive: true });
    const cands = await candidates(members);
    const { g, pending } = C.found(dev.sig, pubkey, { name, icon, invitees: cands });
    groups.set(g.id, g);
    await persist(g);
    if (pending) {
      let posted;
      try { posted = await call('msg_post', { gid: g.id, msg_64: pending.msg_64 }, { interactive: true }); }
      catch (e) { groups.delete(g.id); store.del('g:' + g.id).catch(() => {}); throw e; }
      // nobody else is in the group yet: the first commit cannot be raced
      g.cursor = posted.cursor;
      const out = C.confirm(g);
      await persist(g);
      heard(g, out);
      await deliverWelcomes(g, t, pending.welcome, cands, posted.cursor);
    }
    emit('state', g);
    focus(g.id);
    return g;
  }

  // `inner` comes from event(): the app shows it before it has left.
  async function send(g, inner) {
    const t = await transport({ interactive: true });
    if (!t) throw new Error('no signer');
    const { msg_64 } = C.send(g, inner);
    await persist(g);
    try { await call('msg_post', { gid: g.id, msg_64 }, { interactive: true }); return true; }
    catch (e) { dlog('cordn: post', e); g.log = g.log.filter((m) => m.id !== inner.id); touch(g); return false; }
  }

  async function add(g, pks) {
    const cands = await candidates(pks);
    const p = await commit(g, C.invite(cands));
    await deliverWelcomes(g, await transport({ interactive: true }), p.welcome, cands, g.cursor);
  }
  const kick = (g, pk) => commit(g, C.remove(g, pk));
  const rename = (g, name) => commit(g, [C.setMetadata(g, { name })]);

  // Cordn has no self-removal yet (their client's "leave" only forgets the
  // group locally); ours does the same, and a member can still remove us.
  async function leave(g) { forget(g); }
  function forget(g) {
    groups.delete(g.id);
    dirty.delete(g.id);
    store.del('g:' + g.id).catch(() => {});
    if (focused === g.id) focus(null);
    emit('gone', g);
  }
  function accept(g) { g.fresh = false; touch(g); emit('state', g); }

  async function start() {
    await load();
    if (stopped) return;
    emit('loaded', null);
    poll();
  }
  function resubscribe() { if (cvm) cvm.listen(); poll(); }
  function stop() {
    stopped = true;
    clearTimeout(pollTimer); clearTimeout(rotateTimer);
    if (cvm) cvm.stop();
    flush();
  }

  return {
    pubkey, groups, load, start, stop, resubscribe, ready, lookup, accept, focus, poll,
    create, send, add, kick, rename, leave, forget,
    event: (kind, content, tags) => C.appEvent(pubkey, kind, content, tags),
    view: (g) => C.groupView(g.tip),
    peer: () => null,
    accounts: (g) => C.accounts(g.tip),
    isAdmin: (g) => C.isAdmin(g) && !g.removed,
    groupRef: (g) => C.encodeGroupRef({ gid: g.id, coordinator: coordinator.pubkey, relays: coordinator.relays }),
    isSetUp: () => !!dev,
    coordinator,
  };
}