@paydlux/agent 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/phone-app/app.js CHANGED
@@ -23,6 +23,10 @@ const show = (id, on = true) => $(id).classList.toggle("hidden", !on);
23
23
  const set = (id, v) => { $(id).textContent = v == null || v === "" ? "\u2014" : String(v); };
24
24
  const err = (id, msg) => { $(id).textContent = msg || ""; };
25
25
 
26
+ // A short buzz on the two moments that matter. iOS ignores vibrate(); Android
27
+ // and desktop honour it. Guarded so it is never an uncaught error.
28
+ const buzz = (ms) => { try { navigator.vibrate?.(ms); } catch { /* no haptics */ } };
29
+
26
30
  // ------------------------------------------------------------------ storage
27
31
  // The approver key goes in IndexedDB, not localStorage. localStorage can only
28
32
  // hold strings, which would force us to re-encode the key as something
@@ -91,11 +95,47 @@ async function api(path, opts = {}) {
91
95
  }
92
96
 
93
97
  // ---------------------------------------------------------------- key state
94
- let approverKey = null; // the non-extractable CryptoKey
98
+ let approverKey = null; // the non-extractable CryptoKey (browser path)
95
99
  let approverPublic = null; // its G... address, for display
96
100
  let approverPubBytes = null; // its 32 raw bytes, for addSignature
97
101
 
102
+ // The native app (ios/) exposes a Keychain key behind Face ID through a
103
+ // WKWebView script bridge. When it is present it wins: the key is in the
104
+ // iPhone's hardware-protected Keychain, marked ThisDeviceOnly, and every sign
105
+ // is gated by a biometric. The WebCrypto path below stays for the browser, and
106
+ // the two are never both used — a payment is signed by exactly one key.
107
+ //
108
+ // WebKit gives a page registered against WKScriptMessageHandlerWithReply a
109
+ // promise-returning postMessage, so the round trip is a plain await. No
110
+ // callback registry, no global to collide with.
111
+ const native = {
112
+ available: () => !!(globalThis.webkit?.messageHandlers?.sign
113
+ && globalThis.webkit?.messageHandlers?.publicKey),
114
+ publicKey: async () => {
115
+ const out = await globalThis.webkit.messageHandlers.publicKey.postMessage({});
116
+ return typeof out === "string" && out ? out : null;
117
+ },
118
+ sign: async (preimageHex) => {
119
+ const out = await globalThis.webkit.messageHandlers.sign.postMessage({ preimage: preimageHex });
120
+ if (out && out.signature) return out.signature;
121
+ throw new Error((out && out.error) || "the native signer refused");
122
+ },
123
+ };
124
+
98
125
  async function loadKey() {
126
+ if (native.available()) {
127
+ // The native key needs no import: it is already on the device, behind Face
128
+ // ID. The public half is read from the bridge so the review screen can show
129
+ // which key will sign, and addSignature can attach the result.
130
+ try {
131
+ const hex = await native.publicKey();
132
+ if (hex) {
133
+ approverPubBytes = X.hexToBytes(hex);
134
+ approverPublic = X.encodeAccountEd25519(approverPubBytes);
135
+ return true;
136
+ }
137
+ } catch { /* fall through to the browser key */ }
138
+ }
99
139
  try { approverKey = await idbGet(); } catch { approverKey = null; }
100
140
  if (!approverKey) return false;
101
141
  // A non-extractable key cannot be asked for its own public bytes, so they were
@@ -107,6 +147,23 @@ async function loadKey() {
107
147
  return true;
108
148
  }
109
149
 
150
+ // The one place a signature is produced. Both paths end here, so the audit
151
+ // question "what can sign?" has one answer: this function, over a hash this file
152
+ // computed, after the human held the button.
153
+ async function signPreimage(preimageHex) {
154
+ // One return type for both paths: 64 raw bytes. The bridge answers in hex
155
+ // because a script message cannot carry a byte array, and a half-converted
156
+ // signature is the kind of bug that shows up as an invalid transaction rather
157
+ // than an error.
158
+ if (native.available()) {
159
+ const hex = await native.sign(preimageHex);
160
+ return X.hexToBytes(hex);
161
+ }
162
+ if (!approverKey) throw new Error("no approver key on this device");
163
+ return new Uint8Array(
164
+ await crypto.subtle.sign("Ed25519", approverKey, X.hexToBytes(preimageHex)));
165
+ }
166
+
110
167
  async function enrol(seed) {
111
168
  const trimmed = seed.trim();
112
169
  // Import once as non-extractable (what we keep) and once as extractable
@@ -125,9 +182,15 @@ async function enrol(seed) {
125
182
 
126
183
  // ------------------------------------------------------------------ render
127
184
  function paintKeyState() {
128
- show("enrol", !approverKey);
129
- show("ready", !!approverKey);
130
- if (approverKey) set("who", approverPublic || "imported on this device");
185
+ const have = !!approverKey || native.available();
186
+ show("enrol", !have);
187
+ show("ready", have);
188
+ if (have) {
189
+ set("who", approverPublic || "held by this app");
190
+ // In the native app the seed lives in the Keychain and the page has no way
191
+ // to write it, so the paste box would be a lie.
192
+ if (native.available()) show("seedRow", false);
193
+ }
131
194
  }
132
195
 
133
196
  function paintLinkState() {
@@ -154,10 +217,19 @@ const STATUS_TAG = {
154
217
  expired: ["bad", "expired"],
155
218
  };
156
219
 
220
+ /** Minutes left on a live request, or null. */
221
+ function minutesLeft(rec) {
222
+ if (!rec.expiresAt) return null;
223
+ if (rec.status !== "pending" && rec.status !== "awaiting_phone") return null;
224
+ return Math.round((Number(rec.expiresAt) * 1000 - Date.now()) / 60000);
225
+ }
226
+
157
227
  /** A queue row. Built from DOM nodes so agent text can never be markup. */
158
228
  function row(rec) {
159
229
  const wrap = document.createElement("div");
160
- wrap.className = "item" + (rec.status === "settled" || rec.status === "denied" ? " hist" : "");
230
+ const live = rec.status === "pending" || rec.status === "awaiting_phone";
231
+ wrap.className = "item" + (live ? " live" : "") +
232
+ (rec.status === "settled" || rec.status === "denied" ? " hist" : "");
161
233
 
162
234
  const top = document.createElement("div");
163
235
  top.className = "top";
@@ -185,8 +257,27 @@ function row(rec) {
185
257
  when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
186
258
  foot.append(when);
187
259
 
188
- const actionable = rec.status === "pending" || rec.status === "awaiting_phone";
189
- if (actionable) {
260
+ // The other way a payment dies is the clock. Show the time left on the row,
261
+ // so "I approved it and nothing happened" is not the first warning.
262
+ const left = minutesLeft(rec);
263
+ if (left !== null) {
264
+ const clock = document.createElement("span");
265
+ clock.className = "clock" + (left <= 5 ? " low" : "");
266
+ clock.textContent = left > 0 ? left + " min left" : "expired";
267
+ foot.append(clock);
268
+ }
269
+
270
+ // A failed row with no reason is a dead end: the human cannot tell a passed
271
+ // deadline from a wrong key, and both look like "it says failed".
272
+ if (rec.status === "failed" && rec.reason) {
273
+ const why = document.createElement("div");
274
+ why.className = "wants";
275
+ why.textContent = rec.reason;
276
+ wrap.append(top, uri, wants, why, foot);
277
+ return wrap;
278
+ }
279
+
280
+ if (live) {
190
281
  const btns = document.createElement("div");
191
282
  btns.className = "btns";
192
283
  const yes = document.createElement("button");
@@ -204,7 +295,17 @@ function row(rec) {
204
295
  return wrap;
205
296
  }
206
297
 
298
+ // The home-screen icon badge: the count of what is waiting. On an installed PWA
299
+ // this is the only way a person learns money is waiting without opening the app.
300
+ async function badge(n) {
301
+ try {
302
+ if (n > 0 && navigator.setAppBadge) await navigator.setAppBadge(n);
303
+ else if (navigator.clearAppBadge) await navigator.clearAppBadge();
304
+ } catch { /* badging is not granted, or not supported */ }
305
+ }
306
+
207
307
  let queueTimer = null;
308
+ let lastLive = 0;
208
309
  async function refreshQueue() {
209
310
  if (!key()) return;
210
311
  try {
@@ -226,9 +327,23 @@ async function refreshQueue() {
226
327
  // so a fresh device never meets "paste the transaction" before its payments.
227
328
  show("paste", !live.length);
228
329
  set("qwhen", new Date().toLocaleTimeString());
330
+ set("qtitle", live.length ? `${live.length} waiting for you` : "Waiting for you");
229
331
  err("qerr", "");
332
+ show("offline", false);
333
+ // buzz once when a new request arrives while the app is open
334
+ if (live.length > lastLive) buzz(30);
335
+ lastLive = live.length;
336
+ badge(live.length);
337
+ // Hand the count to the native shell: it owns the icon badge and the local
338
+ // notification, which a web page cannot raise on iOS. It gets a number and
339
+ // nothing else — no amount, no payee — because a lock-screen payment prompt
340
+ // is a payment decision made on a screen nobody unlocked deliberately.
341
+ try { globalThis.webkit?.messageHandlers?.queue?.postMessage({ waiting: live.length }); } catch { /* not native */ }
230
342
  } catch (e) {
343
+ // Distinguish "the desk is down" from "this link was never issued": the
344
+ // first is a network problem the seller fixes, the second is a key problem.
231
345
  err("qerr", e.message + " — if this is new, open the seller's approval link again");
346
+ if (!navigator.onLine) show("offline", true);
232
347
  }
233
348
  }
234
349
 
@@ -248,6 +363,17 @@ async function openReview(id) {
248
363
  if (rec.hash && hash !== rec.hash) {
249
364
  throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
250
365
  }
366
+ // The vault only accepts the key it was built with. A seed from an earlier
367
+ // run produces a perfectly valid signature over a perfectly valid
368
+ // transaction, and the ledger's only reply is BAD_AUTH \u2014 which reads like a
369
+ // bug and is really the wrong key. Say it before signing instead.
370
+ if (rec.approver && approverPublic && rec.approver !== approverPublic) {
371
+ throw new Error(
372
+ "this vault expects a different approver key.\n" +
373
+ "vault wants: " + rec.approver.slice(0, 12) + "\u2026\n" +
374
+ "this device: " + approverPublic.slice(0, 12) + "\u2026\n" +
375
+ "The seller re-ran the desk, which made a new key. Get the new seed.");
376
+ }
251
377
  current = {
252
378
  id, xdr: rec.xdr, hash,
253
379
  amount: X.formatAmount(op.amount),
@@ -255,12 +381,26 @@ async function openReview(id) {
255
381
  issuer: op.asset.issuer || "",
256
382
  payee: X.encodeAccountEd25519(op.destination.pub),
257
383
  vault: X.encodeAccountEd25519(env.tx.source.pub),
258
- memo: op.source ? "(payment from a secondary key)" : (rec.memo || ""),
384
+ // Read the memo out of the envelope, not out of the server's summary:
385
+ // the envelope is what is being signed, so it is the only source that
386
+ // cannot disagree with the bytes. xdr.js already renders every memo type
387
+ // to a plain string, so this is display-safe by construction.
388
+ memo: env.tx.memo || "(none)",
259
389
  deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
260
390
  network: rec.network,
261
391
  wants: rec.wants || "",
262
392
  };
263
- set("amount", `${current.amount} ${current.token}`);
393
+ // The amount is the one number a person must read. It gets the biggest type
394
+ // on the screen and the token sits next to it, so "4.50" is never read
395
+ // without its unit.
396
+ $("amount").replaceChildren();
397
+ const num = document.createElement("span");
398
+ num.textContent = current.amount;
399
+ const tk = document.createElement("span");
400
+ tk.className = "tk";
401
+ tk.textContent = " " + current.token;
402
+ $("amount").append(num, tk);
403
+ set("wantsBig", current.wants);
264
404
  set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
265
405
  set("payee", current.payee);
266
406
  set("vault", current.vault);
@@ -269,7 +409,14 @@ async function openReview(id) {
269
409
  set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
270
410
  set("network", current.network === "public" ? "Stellar mainnet" : current.network);
271
411
  set("hash", current.hash);
412
+ // The network belongs in the header, not buried in the list: signing a
413
+ // testnet transaction and signing real money look identical otherwise, and
414
+ // the chip is the one place a person glances before holding the button.
415
+ set("netChip", current.network === "public" ? "mainnet" : current.network);
416
+ show("netChip", true);
417
+ $("netChip").style.color = current.network === "public" ? "var(--ok)" : "var(--warn)";
272
418
  err("signErr", "");
419
+ resetHold();
273
420
  show("review", true);
274
421
  show("queue", false);
275
422
  $("review").scrollIntoView({ behavior: "smooth", block: "start" });
@@ -286,18 +433,81 @@ function backToQueue() {
286
433
  refreshQueue();
287
434
  }
288
435
 
436
+ // ------------------------------------------------------ hold-to-approve
437
+ // Approving money is the expensive action; rejecting is the safe one. So Reject
438
+ // is one tap and Approve needs a deliberate hold. This is what stops a pocket
439
+ // tap, a mis-tap, and the person who taps Approve and only then starts reading.
440
+ const HOLD_MS = 1200;
441
+ let holdStart = 0, holdTimer = null;
442
+
443
+ function resetHold() {
444
+ $("holdFill").style.width = "0";
445
+ $("holdLbl").textContent = "Hold to approve & sign";
446
+ $("approve").classList.remove("armed");
447
+ }
448
+
449
+ function armHold() {
450
+ holdStart = Date.now();
451
+ $("holdLbl").textContent = "Keep holding\u2026";
452
+ holdTimer = setInterval(() => {
453
+ const p = Math.min(1, (Date.now() - holdStart) / HOLD_MS);
454
+ $("holdFill").style.width = (p * 100) + "%";
455
+ if (p >= 1) {
456
+ clearInterval(holdTimer); holdTimer = null;
457
+ $("approve").classList.add("armed");
458
+ $("holdLbl").textContent = "Signed";
459
+ buzz([20, 40, 20]);
460
+ signAndSubmit();
461
+ }
462
+ }, 40);
463
+ }
464
+
465
+ function cancelHold() {
466
+ if (holdTimer) { clearInterval(holdTimer); holdTimer = null; }
467
+ if (!holdStart) return;
468
+ holdStart = 0;
469
+ if (!$("approve").classList.contains("armed")) resetHold();
470
+ }
471
+
472
+ function wireHold() {
473
+ const b = $("approve");
474
+ b.addEventListener("pointerdown", (e) => { e.preventDefault(); if (!holdTimer) armHold(); });
475
+ b.addEventListener("pointerup", cancelHold);
476
+ b.addEventListener("pointercancel", cancelHold);
477
+ b.addEventListener("pointerleave", cancelHold);
478
+ // keyboard / VoiceOver: hold is a pointer gesture, so Enter needs a confirm
479
+ b.addEventListener("keydown", (e) => {
480
+ if (e.key === "Enter" || e.key === " ") {
481
+ e.preventDefault();
482
+ if (!confirm("Approve and sign this payment?")) return;
483
+ $("holdFill").style.width = "100%";
484
+ signAndSubmit();
485
+ }
486
+ });
487
+ }
488
+
289
489
  // ------------------------------------------------------------------ sign
290
490
  async function signAndSubmit() {
291
491
  if (!current) return;
292
- if (!approverKey) { err("signErr", "no approver key on this device"); return; }
492
+ if (!approverKey && !native.available()) { err("signErr", "no approver key on this device"); return; }
293
493
  const button = $("approve");
294
494
  button.disabled = true;
295
495
  try {
296
496
  // Re-hash at the moment of signing. What is signed is what was on screen.
297
497
  const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
298
498
  if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
299
- const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
499
+ // One signer for both paths. The preimage is built here, from the hash this
500
+ // file computed, so the native side is asked to sign exactly what was on
501
+ // screen and is never handed a transaction to interpret.
502
+ const pre = X.signaturePreimage(hash);
503
+ const sig = new Uint8Array(await signPreimage(X.hex(pre)));
300
504
  if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
505
+ // Prove the signature came from the key this screen named, before a byte
506
+ // of it leaves the device. A bridge that signs with some other key is then
507
+ // useless: its output never reaches the ledger.
508
+ if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
509
+ throw new Error("the signature does not match this key — nothing was sent");
510
+ }
301
511
  const signed = X.addSignature(current.xdr, sig, approverPubBytes);
302
512
  const out = await api("/signed/" + encodeURIComponent(current.id), {
303
513
  method: "POST",
@@ -312,8 +522,12 @@ async function signAndSubmit() {
312
522
  if (out.reason) set("resultLabel", out.reason);
313
523
  show("result", true);
314
524
  show("review", false);
525
+ buzz(out.status === "settled" ? [20, 40, 20] : 200);
526
+ badge(0);
315
527
  } catch (e) {
316
528
  err("signErr", e.message);
529
+ resetHold();
530
+ buzz(200);
317
531
  } finally {
318
532
  button.disabled = false;
319
533
  }
@@ -322,6 +536,7 @@ async function signAndSubmit() {
322
536
  async function reject(id) {
323
537
  try {
324
538
  await api("/deny/" + encodeURIComponent(id), { method: "POST" });
539
+ buzz(60);
325
540
  refreshQueue();
326
541
  } catch (e) {
327
542
  err("qerr", e.message);
@@ -359,7 +574,10 @@ async function readPasted() {
359
574
  if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
360
575
  throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
361
576
  }
362
- const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", approverKey, X.signaturePreimage(hash)));
577
+ const sig = new Uint8Array(await signPreimage(X.hex(X.signaturePreimage(hash))));
578
+ if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
579
+ throw new Error("the signature does not match this key — nothing was produced");
580
+ }
363
581
  set("signed", X.addSignature(xdr, sig, approverPubBytes));
364
582
  set("resultLabel", "Signed \u2014 send this back to the seller");
365
583
  $("resultTag").className = "tag ok";
@@ -385,6 +603,23 @@ async function boot() {
385
603
  paintKeyState();
386
604
  paintLinkState();
387
605
 
606
+ // iOS gives an installed PWA no browser chrome, and Safari offers no install
607
+ // button, so the instruction has to live inside the app. Shown once per device.
608
+ const standalone = matchMedia("(display-mode: standalone)").matches
609
+ || navigator.standalone === true;
610
+ show("install", !standalone && !localStorage.getItem("paydlux.installDismissed"));
611
+ $("install").addEventListener("click", () => {
612
+ localStorage.setItem("paydlux.installDismissed", "1");
613
+ show("install", false);
614
+ });
615
+
616
+ // The offline shell: the app launches from cache with its icon and code even
617
+ // with no network, then fails honestly when it tries to read the queue. It
618
+ // never caches payment data — see sw.js.
619
+ if ("serviceWorker" in navigator) {
620
+ navigator.serviceWorker.register("sw.js", { scope: "./" }).catch(() => { /* not fatal */ });
621
+ }
622
+
388
623
  $("enrolBtn").addEventListener("click", async () => {
389
624
  err("enrolErr", "");
390
625
  try {
@@ -395,6 +630,7 @@ async function boot() {
395
630
  err("enrolErr", e.message);
396
631
  }
397
632
  });
633
+ $("seed").addEventListener("keydown", (e) => { if (e.key === "Enter") $("enrolBtn").click(); });
398
634
  $("forget").addEventListener("click", async () => {
399
635
  await idbDel();
400
636
  localStorage.removeItem("paydlux.pub");
@@ -411,7 +647,7 @@ async function boot() {
411
647
  paintLinkState();
412
648
  refreshQueue();
413
649
  });
414
- $("approve").addEventListener("click", signAndSubmit);
650
+ wireHold();
415
651
  $("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
416
652
  $("again").addEventListener("click", backToQueue);
417
653
  $("read").addEventListener("click", readPasted);
@@ -421,9 +657,23 @@ async function boot() {
421
657
  setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
422
658
  });
423
659
 
660
+ // Poll only while visible. An approver app left open on a phone should not
661
+ // wake the radio every few seconds in a pocket.
662
+ const startPolling = () => {
663
+ if (queueTimer) return;
664
+ queueTimer = setInterval(refreshQueue, 4000);
665
+ refreshQueue();
666
+ };
667
+ const stopPolling = () => { if (queueTimer) { clearInterval(queueTimer); queueTimer = null; } };
668
+ document.addEventListener("visibilitychange", () => {
669
+ if (document.hidden) stopPolling(); else if (key()) startPolling();
670
+ });
671
+ window.addEventListener("online", () => { show("offline", false); refreshQueue(); });
672
+ window.addEventListener("offline", () => show("offline", true));
673
+
424
674
  if (key()) {
425
675
  await refreshQueue();
426
- queueTimer = setInterval(refreshQueue, 4000);
676
+ if (!document.hidden) startPolling();
427
677
  const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
428
678
  if (id) openReview(id);
429
679
  }