@paydlux/agent 0.1.9 → 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
@@ -1,253 +1,682 @@
1
- // The approver app's own logic. Loaded by phone-app/index.html and served by the
2
- // desk at /phone/app.js. Kept in a separate file so the Content-Security-Policy
3
- // (script-src 'self') is real rather than defeated by an inline <script>.
4
- (function () {
5
- "use strict";
6
- const X = window.PaydluxXdr;
7
- const $ = (id) => document.getElementById(id);
8
- const DB = "paydlux-approver", STORE = "keys";
9
-
10
- let signingKey = null;
11
-
12
- function idb(mode, fn) {
13
- return new Promise((res, rej) => {
14
- const open = indexedDB.open(DB, 1);
15
- open.onupgradeneeded = () => open.result.createObjectStore(STORE);
16
- open.onerror = () => rej(open.error);
17
- open.onsuccess = () => {
18
- const tx = open.result.transaction(STORE, mode);
19
- const req = fn(tx.objectStore(STORE));
20
- req.onsuccess = () => res(req.result);
21
- req.onerror = () => rej(req.error);
22
- };
23
- });
24
- }
25
- const saveKey = (k) => idb("readwrite", (s) => s.put(k, "approver"));
26
- const loadKey = () => idb("readonly", (s) => s.get("approver"));
27
- const dropKey = () => idb("readwrite", (s) => s.delete("approver"));
28
-
29
- function base64urlToBytes(s) {
30
- const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
31
- const out = new Uint8Array(bin.length);
32
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
33
- return out;
34
- }
1
+ // Paydlux approver — the client half.
2
+ //
3
+ // Two jobs, and only two: hold the approver key on this device, and sign the
4
+ // transaction hash for a payment a human has read.
5
+ //
6
+ // Rules this file is written to:
7
+ // * The seed is imported into a NON-extractable WebCrypto key and then
8
+ // dropped. What is persisted is the CryptoKey, not the seed. An injected
9
+ // script can call sign(); it cannot read the seed.
10
+ // * Signing is local. The bytes signed are the transaction hash the app
11
+ // computed itself from the XDR it fetched — never a hash the server handed
12
+ // over as an opaque "please sign this".
13
+ // * Every string that came from a buyer's agent (request path, request body,
14
+ // reason) is written with textContent. Never innerHTML. This page authorises
15
+ // payments; one unescaped field there is remote code execution on the
16
+ // approver's phone.
17
+ // * One origin: the desk that served this app. No other fetch is made.
18
+ "use strict";
35
19
 
36
- async function showReady(pub) {
37
- $("who").textContent = X.encodeAccountEd25519(pub);
38
- $("enrol").classList.add("hidden");
39
- $("ready").classList.remove("hidden");
40
- }
20
+ const X = globalThis.PaydluxXdr;
21
+ const $ = (id) => document.getElementById(id);
22
+ const show = (id, on = true) => $(id).classList.toggle("hidden", !on);
23
+ const set = (id, v) => { $(id).textContent = v == null || v === "" ? "\u2014" : String(v); };
24
+ const err = (id, msg) => { $(id).textContent = msg || ""; };
41
25
 
42
- // ------------------------------------------------------------- enrolment
43
- $("enrolBtn").addEventListener("click", async () => {
44
- $("enrolErr").textContent = "";
45
- const seed = $("seed").value.trim();
46
- try {
47
- // Imported twice on purpose: once extractable purely to read the PUBLIC
48
- // half back for display, once non-extractable as the key we keep.
49
- const seedBytes = X.decodeSecretSeed(seed);
50
- const pkcs8 = new Uint8Array(16 + 32);
51
- pkcs8.set([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20], 0);
52
- pkcs8.set(seedBytes, 16);
53
-
54
- // "verify" is not a legal usage for a private Ed25519 key — the browser
55
- // refuses the import outright. Sign only.
56
- const tmp = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
57
- const jwk = await crypto.subtle.exportKey("jwk", tmp);
58
- const pub = base64urlToBytes(jwk.x);
59
-
60
- signingKey = await X.importSecretSeed(seed); // non-extractable
61
- seedBytes.fill(0); pkcs8.fill(0); $("seed").value = "";
62
-
63
- // What is persisted is the non-extractable CryptoKey object, not the seed.
64
- // Whatever backs IndexedDB can use that key to sign; no code path can read
65
- // the seed out of it. Losing the database leaks nothing; stealing it leaks
66
- // nothing either.
67
- await saveKey({ key: signingKey, x: jwk.x });
68
- await showReady(pub);
69
- } catch (e) {
70
- $("enrolErr").textContent = e.message;
71
- }
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
+
30
+ // ------------------------------------------------------------------ storage
31
+ // The approver key goes in IndexedDB, not localStorage. localStorage can only
32
+ // hold strings, which would force us to re-encode the key as something
33
+ // readable; IndexedDB stores the CryptoKey object itself, and a
34
+ // non-extractable key has no readable form to store.
35
+ const DB = "paydlux-approver";
36
+ const idb = () => new Promise((res, rej) => {
37
+ const open = indexedDB.open(DB, 1);
38
+ open.onupgradeneeded = () => open.result.createObjectStore("keys");
39
+ open.onsuccess = () => res(open.result);
40
+ open.onerror = () => rej(open.error);
41
+ });
42
+ async function idbPut(value) {
43
+ const db = await idb();
44
+ return new Promise((res, rej) => {
45
+ const t = db.transaction("keys", "readwrite");
46
+ t.objectStore("keys").put(value, "approver");
47
+ t.oncomplete = () => res();
48
+ t.onerror = () => rej(t.error);
49
+ });
50
+ }
51
+ async function idbGet() {
52
+ const db = await idb();
53
+ return new Promise((res, rej) => {
54
+ const t = db.transaction("keys", "readonly");
55
+ const g = t.objectStore("keys").get("approver");
56
+ g.onsuccess = () => res(g.result ?? null);
57
+ g.onerror = () => rej(g.error);
58
+ });
59
+ }
60
+ async function idbDel() {
61
+ const db = await idb();
62
+ return new Promise((res, rej) => {
63
+ const t = db.transaction("keys", "readwrite");
64
+ t.objectStore("keys").delete("approver");
65
+ t.oncomplete = () => res();
66
+ t.onerror = () => rej(t.error);
72
67
  });
68
+ }
73
69
 
74
- $("forget").addEventListener("click", async () => {
75
- signingKey = null;
76
- await dropKey();
77
- location.reload();
70
+ // The approval link carries the seller's portal key. It is the thing that makes
71
+ // this app useful, so it is kept on the device after the seller opens it once.
72
+ const LINK = "paydlux.link";
73
+ const linkParams = () => {
74
+ const raw = localStorage.getItem(LINK) || "";
75
+ const u = new URL(raw, location.href);
76
+ return u.searchParams;
77
+ };
78
+ const key = () => linkParams().get("k") || "";
79
+
80
+ // ------------------------------------------------------------------- api
81
+ // Same origin always: the desk that served this file.
82
+ async function api(path, opts = {}) {
83
+ const sep = path.includes("?") ? "&" : "?";
84
+ const res = await fetch(path + sep + "k=" + encodeURIComponent(key()), {
85
+ ...opts,
86
+ headers: { ...(opts.headers || {}) },
78
87
  });
88
+ const text = await res.text();
89
+ let body = null;
90
+ try { body = text ? JSON.parse(text) : null; } catch { /* not json */ }
91
+ if (!res.ok) {
92
+ throw new Error((body && (body.reason || body.error)) || `the desk answered ${res.status}`);
93
+ }
94
+ return body;
95
+ }
79
96
 
80
- // --------------------------------------------------------------- review
81
- let pending = null;
82
-
83
- /**
84
- * Deep link from the desk: /phone/?id=pay_0001&k=PORTALKEY
85
- * The human taps a link instead of pasting 400 characters of base64 on a
86
- * phone. The desk returns its own record of the challenge, so the amount the
87
- * phone displays is the amount the desk recorded.
88
- */
89
- async function loadFromDesk() {
90
- const q = new URLSearchParams(location.search);
91
- const id = q.get("id"), k = q.get("k");
92
- if (!id) return;
97
+ // ---------------------------------------------------------------- key state
98
+ let approverKey = null; // the non-extractable CryptoKey (browser path)
99
+ let approverPublic = null; // its G... address, for display
100
+ let approverPubBytes = null; // its 32 raw bytes, for addSignature
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
+
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.
93
130
  try {
94
- const res = await fetch("/challenge/" + encodeURIComponent(id) +
95
- (k ? "?k=" + encodeURIComponent(k) : ""), { cache: "no-store" });
96
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
97
- const rec = await res.json();
98
- $("xdr").value = rec.xdr;
99
- pending = { fromDesk: true, id, k };
100
- await renderChallenge(rec.xdr);
101
- $("readErr").textContent = "";
102
- } catch (e) {
103
- $("readErr").textContent = "could not load " + id + ": " + e.message;
104
- }
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 */ }
105
138
  }
139
+ try { approverKey = await idbGet(); } catch { approverKey = null; }
140
+ if (!approverKey) return false;
141
+ // A non-extractable key cannot be asked for its own public bytes, so they were
142
+ // captured at enrolment and kept next to it. The address is for display; the
143
+ // raw bytes are what addSignature needs to attach the signature.
144
+ approverPublic = localStorage.getItem("paydlux.pub") || null;
145
+ const raw = localStorage.getItem("paydlux.pubraw") || "";
146
+ approverPubBytes = raw ? X.hexToBytes(raw) : null;
147
+ return true;
148
+ }
106
149
 
107
- async function renderChallenge(xdrText) {
108
- const env = X.parseEnvelope(xdrText);
109
- const op = env.tx.ops[0];
110
- const testnet = await X.transactionHash(xdrText, X.NETWORK_ID.testnet);
111
- const publicNet = await X.transactionHash(xdrText, X.NETWORK_ID.public);
112
- pending = Object.assign({}, pending, {
113
- xdrText, env, hashes: { testnet: testnet.hash, public: publicNet.hash },
114
- });
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
+ }
115
166
 
116
- $("amount").textContent = X.formatAmount(op.amount) + " " + op.asset.code;
117
- $("token").textContent = op.asset.issuer
118
- ? op.asset.code + " issued by " + op.asset.issuer
119
- : "XLM (native)";
120
- $("payee").textContent = X.encodeAccountEd25519(op.destination.pub);
121
- $("vault").textContent = X.encodeAccountEd25519(env.tx.source.pub);
122
- $("memo").textContent = env.tx.memo || "(none)";
123
- const max = env.tx.bounds && env.tx.bounds.maxTime;
124
- $("deadline").textContent = max && max !== "0"
125
- ? new Date(Number(max) * 1000).toLocaleString() + " (" + remaining(Number(max)) + ")"
126
- : "none — this transaction never expires";
127
-
128
- // Which network an envelope belongs to is NOT inside the envelope, so the
129
- // human picks it. Signing for the wrong network yields a signature the
130
- // ledger refuses; it cannot silently move funds somewhere else.
131
- $("hash").innerHTML =
132
- '<label class="tag ok"><input type="radio" name="net" value="testnet" checked> testnet</label> '
133
- + testnet.hash +
134
- '<br><label class="tag warn"><input type="radio" name="net" value="public"> mainnet</label> '
135
- + publicNet.hash;
136
- $("review").classList.remove("hidden");
167
+ async function enrol(seed) {
168
+ const trimmed = seed.trim();
169
+ // Import once as non-extractable (what we keep) and once as extractable
170
+ // purely to read back the public half, which the temporary key is then
171
+ // discarded for. The seed itself is never stored anywhere.
172
+ const { key: kp, pub, address } = await X.importSecretSeedPair(trimmed);
173
+ approverKey = kp;
174
+ approverPublic = address;
175
+ approverPubBytes = pub;
176
+ await idbPut(kp);
177
+ localStorage.setItem("paydlux.pub", address);
178
+ localStorage.setItem("paydlux.pubraw", X.hex(pub));
179
+ $("seed").value = "";
180
+ return true;
181
+ }
182
+
183
+ // ------------------------------------------------------------------ render
184
+ function paintKeyState() {
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);
137
193
  }
194
+ }
195
+
196
+ function paintLinkState() {
197
+ const have = !!key();
198
+ show("nolink", !have);
199
+ show("queue", have);
200
+ if (!have) show("paste", false); // no link, no queue: nothing to fall back to yet
201
+ if (!have) $("link").value = "";
202
+ }
203
+
204
+ const ago = (iso) => {
205
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
206
+ if (s < 60) return Math.round(s) + "s ago";
207
+ if (s < 3600) return Math.round(s / 60) + "m ago";
208
+ return Math.round(s / 3600) + "h ago";
209
+ };
210
+
211
+ const STATUS_TAG = {
212
+ pending: ["warn", "waiting"],
213
+ awaiting_phone: ["warn", "on your phone"],
214
+ settled: ["ok", "paid"],
215
+ denied: ["bad", "rejected"],
216
+ failed: ["bad", "failed"],
217
+ expired: ["bad", "expired"],
218
+ };
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
+
227
+ /** A queue row. Built from DOM nodes so agent text can never be markup. */
228
+ function row(rec) {
229
+ const wrap = document.createElement("div");
230
+ const live = rec.status === "pending" || rec.status === "awaiting_phone";
231
+ wrap.className = "item" + (live ? " live" : "") +
232
+ (rec.status === "settled" || rec.status === "denied" ? " hist" : "");
138
233
 
139
- function remaining(unix) {
140
- const s = unix - Math.floor(Date.now() / 1000);
141
- if (s <= 0) return "EXPIRED";
142
- if (s < 120) return s + "s left";
143
- return Math.round(s / 60) + " min left";
234
+ const top = document.createElement("div");
235
+ top.className = "top";
236
+ const amt = document.createElement("div");
237
+ amt.className = "amt";
238
+ amt.textContent = `${rec.price} ${rec.token || ""}`;
239
+ const [, cls] = STATUS_TAG[rec.status] || ["warn", rec.status];
240
+ const tag = document.createElement("span");
241
+ tag.className = "tag " + cls;
242
+ tag.textContent = (STATUS_TAG[rec.status] || [null, rec.status])[1] || rec.status;
243
+ top.append(amt, tag);
244
+
245
+ const uri = document.createElement("div");
246
+ uri.className = "uri";
247
+ uri.textContent = rec.uri || "";
248
+
249
+ const wants = document.createElement("div");
250
+ wants.className = "wants";
251
+ wants.textContent = rec.wants || "";
252
+
253
+ const foot = document.createElement("div");
254
+ foot.className = "foot";
255
+ const when = document.createElement("span");
256
+ when.className = "ago";
257
+ when.textContent = ago(rec.at) + (rec.tx ? " \u00b7 " + rec.tx.slice(0, 12) + "\u2026" : "");
258
+ foot.append(when);
259
+
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);
144
268
  }
145
269
 
146
- $("read").addEventListener("click", async () => {
147
- $("readErr").textContent = "";
148
- $("review").classList.add("hidden");
149
- const xdrText = $("xdr").value.trim().replace(/\s+/g, "");
150
- try {
151
- await renderChallenge(xdrText);
152
- } catch (e) {
153
- $("readErr").textContent = e.message;
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) {
281
+ const btns = document.createElement("div");
282
+ btns.className = "btns";
283
+ const yes = document.createElement("button");
284
+ yes.textContent = "Review";
285
+ yes.addEventListener("click", () => openReview(rec.id));
286
+ const no = document.createElement("button");
287
+ no.className = "danger";
288
+ no.textContent = "Reject";
289
+ no.addEventListener("click", () => reject(rec.id));
290
+ btns.append(yes, no);
291
+ foot.append(btns);
292
+ }
293
+
294
+ wrap.append(top, uri, wants, foot);
295
+ return wrap;
296
+ }
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
+
307
+ let queueTimer = null;
308
+ let lastLive = 0;
309
+ async function refreshQueue() {
310
+ if (!key()) return;
311
+ try {
312
+ const list = await api("/queue");
313
+ const live = list.filter((r) => r.status === "pending" || r.status === "awaiting_phone");
314
+ const past = list.filter((r) => r.status !== "pending" && r.status !== "awaiting_phone").slice(0, 5);
315
+ const box = $("qlist");
316
+ box.replaceChildren();
317
+ if (!live.length && !past.length) {
318
+ const e = document.createElement("div");
319
+ e.className = "empty";
320
+ e.textContent = "nothing waiting";
321
+ box.append(e);
154
322
  }
155
- });
323
+ live.forEach((r) => box.append(row(r)));
324
+ past.forEach((r) => box.append(row(r)));
325
+ // The hand-paste box is the fallback for a seller who sends a transaction by
326
+ // message instead of a link. It appears when there is nothing in the queue,
327
+ // so a fresh device never meets "paste the transaction" before its payments.
328
+ show("paste", !live.length);
329
+ set("qwhen", new Date().toLocaleTimeString());
330
+ set("qtitle", live.length ? `${live.length} waiting for you` : "Waiting for you");
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 */ }
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.
345
+ err("qerr", e.message + " — if this is new, open the seller's approval link again");
346
+ if (!navigator.onLine) show("offline", true);
347
+ }
348
+ }
349
+
350
+ // ----------------------------------------------------------------- review
351
+ let current = null; // { id, xdr, hash, summary }
156
352
 
157
- $("reject").addEventListener("click", () => reset());
158
- $("again").addEventListener("click", () => reset());
159
- function reset() {
160
- pending = null;
161
- $("result").classList.add("hidden");
162
- $("review").classList.add("hidden");
163
- $("xdr").value = "";
353
+ async function openReview(id) {
354
+ err("qerr", "");
355
+ try {
356
+ const rec = await api("/challenge/" + encodeURIComponent(id));
357
+ const env = X.parseEnvelope(rec.xdr);
358
+ const op = env.tx.ops[0];
359
+ if (env.tx.ops.length !== 1 || op.type !== X.OP_PAYMENT) {
360
+ throw new Error("this transaction is not a single payment \u2014 refusing to sign it");
361
+ }
362
+ const { hash } = await X.transactionHash(rec.xdr, X.NETWORK_ID[rec.network] || rec.network);
363
+ if (rec.hash && hash !== rec.hash) {
364
+ throw new Error("the transaction does not hash to what the desk advertised \u2014 refusing");
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
+ }
377
+ current = {
378
+ id, xdr: rec.xdr, hash,
379
+ amount: X.formatAmount(op.amount),
380
+ token: op.asset.code || "XLM",
381
+ issuer: op.asset.issuer || "",
382
+ payee: X.encodeAccountEd25519(op.destination.pub),
383
+ vault: X.encodeAccountEd25519(env.tx.source.pub),
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)",
389
+ deadline: rec.expiresAt || env.tx.cond?.timeBounds?.max || "",
390
+ network: rec.network,
391
+ wants: rec.wants || "",
392
+ };
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);
404
+ set("token", current.issuer ? `${current.token} (${current.issuer.slice(0, 12)}\u2026)` : current.token);
405
+ set("payee", current.payee);
406
+ set("vault", current.vault);
407
+ set("wants", current.wants);
408
+ set("memo", current.memo);
409
+ set("deadline", current.deadline ? new Date(Number(current.deadline) * 1000).toLocaleString() : "none");
410
+ set("network", current.network === "public" ? "Stellar mainnet" : current.network);
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)";
418
+ err("signErr", "");
419
+ resetHold();
420
+ show("review", true);
421
+ show("queue", false);
422
+ $("review").scrollIntoView({ behavior: "smooth", block: "start" });
423
+ } catch (e) {
424
+ err("qerr", e.message);
164
425
  }
426
+ }
427
+
428
+ function backToQueue() {
429
+ current = null;
430
+ show("review", false);
431
+ show("result", false);
432
+ show("queue", !!key());
433
+ refreshQueue();
434
+ }
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;
165
442
 
166
- // ---------------------------------------------------------------- sign
167
- $("approve").addEventListener("click", async () => {
168
- $("signErr").textContent = "";
169
- if (!signingKey) {
170
- $("signErr").textContent = "Import the approver key first.";
171
- return;
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();
172
461
  }
173
- if (!pending) return;
174
-
175
- const picked = document.querySelector("input[name=net]:checked");
176
- const which = picked ? picked.value : "testnet";
177
- const network = which === "testnet" ? X.NETWORK_ID.testnet : X.NETWORK_ID.public;
178
-
179
- // A pasted transaction has no trusted source, so the human proves they were
180
- // shown this exact one by typing its hash back. A transaction fetched from
181
- // the desk over the portal key is already bound to the desk's own record,
182
- // which the desk re-checks before submitting.
183
- if (!pending.fromDesk) {
184
- const quoted = prompt("Type the first 16 characters of the hash the seller quoted you:");
185
- if (!quoted) return;
186
- const q = quoted.trim().toLowerCase();
187
- if (q !== pending.hashes.testnet.slice(0, 16) && q !== pending.hashes.public.slice(0, 16)) {
188
- $("signErr").textContent = "That hash matches neither. Nothing was signed.";
189
- return;
190
- }
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
+
489
+ // ------------------------------------------------------------------ sign
490
+ async function signAndSubmit() {
491
+ if (!current) return;
492
+ if (!approverKey && !native.available()) { err("signErr", "no approver key on this device"); return; }
493
+ const button = $("approve");
494
+ button.disabled = true;
495
+ try {
496
+ // Re-hash at the moment of signing. What is signed is what was on screen.
497
+ const { hash } = await X.transactionHash(current.xdr, X.NETWORK_ID[current.network] || current.network);
498
+ if (hash !== current.hash) throw new Error("the transaction changed under you \u2014 nothing was signed");
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)));
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");
191
510
  }
511
+ const signed = X.addSignature(current.xdr, sig, approverPubBytes);
512
+ const out = await api("/signed/" + encodeURIComponent(current.id), {
513
+ method: "POST",
514
+ headers: { "content-type": "application/json" },
515
+ body: JSON.stringify({ signedXdr: signed }),
516
+ });
517
+ set("signed", signed);
518
+ const tag = $("resultTag");
519
+ tag.className = "tag " + (out.status === "settled" ? "ok" : "bad");
520
+ tag.textContent = out.status === "settled" ? "Paid" : out.status;
521
+ if (out.tx) set("resultLabel", `Signed \u00b7 ledger tx ${out.tx}`);
522
+ if (out.reason) set("resultLabel", out.reason);
523
+ show("result", true);
524
+ show("review", false);
525
+ buzz(out.status === "settled" ? [20, 40, 20] : 200);
526
+ badge(0);
527
+ } catch (e) {
528
+ err("signErr", e.message);
529
+ resetHold();
530
+ buzz(200);
531
+ } finally {
532
+ button.disabled = false;
533
+ }
534
+ }
192
535
 
193
- try {
194
- const { hash } = await X.transactionHash(pending.xdrText, network);
195
- // The signature covers the 32 raw hash bytes. sha256-ing the hex text
196
- // instead yields a well-formed envelope the ledger refuses with BAD_AUTH.
197
- const sig = new Uint8Array(await crypto.subtle.sign(
198
- "Ed25519", signingKey, X.signaturePreimage(hash)));
199
- const rec = await loadKey();
200
- const signed = X.addSignature(pending.xdrText, sig, base64urlToBytes(rec.x));
201
-
202
- if (pending.fromDesk) {
203
- const res = await fetch("/signed/" + encodeURIComponent(pending.id) +
204
- (pending.k ? "?k=" + encodeURIComponent(pending.k) : ""), {
205
- method: "POST",
206
- headers: { "content-type": "application/json" },
207
- body: JSON.stringify({ signedXdr: signed }),
208
- });
209
- const out = await res.json().catch(() => ({}));
210
- if (out.status === "settled") {
211
- // Show the ledger hash and keep the panel up: this is the only proof
212
- // the human gets that the payment actually landed. reset() would hide
213
- // it again, so clear the form by hand instead.
214
- pending = null;
215
- $("xdr").value = "";
216
- $("review").classList.add("hidden");
217
- $("signed").textContent = "settled on ledger\n" + (out.tx || "");
218
- $("result").classList.remove("hidden");
219
- return;
220
- }
221
- $("signErr").textContent = "the desk refused it: " +
222
- (out.reason || out.error || out.status || res.status);
223
- return;
224
- }
225
- $("signed").textContent = signed;
226
- $("result").classList.remove("hidden");
227
- } catch (e) {
228
- $("signErr").textContent = e.message;
536
+ async function reject(id) {
537
+ try {
538
+ await api("/deny/" + encodeURIComponent(id), { method: "POST" });
539
+ buzz(60);
540
+ refreshQueue();
541
+ } catch (e) {
542
+ err("qerr", e.message);
543
+ }
544
+ }
545
+
546
+ // ------------------------------------------------------- hand-pasted xdr
547
+ // For a seller who sends the pending transaction by hand. There is no trusted
548
+ // source for it, so the app makes the human type back the hash the seller
549
+ // quoted: signing then proves the human saw the same bytes, not a swapped one.
550
+ async function readPasted() {
551
+ err("readErr", "");
552
+ try {
553
+ if (!approverKey || !approverPubBytes) throw new Error("import the approver key first");
554
+ const xdr = $("xdr").value.trim();
555
+ if (!xdr) throw new Error("paste the transaction first");
556
+ const env = X.parseEnvelope(xdr);
557
+ if (env.tx.ops.length !== 1 || env.tx.ops[0].type !== X.OP_PAYMENT) {
558
+ throw new Error("not a single payment \u2014 refusing");
559
+ }
560
+ const op = env.tx.ops[0];
561
+ const local = location.hostname === "localhost" || /^127\./.test(location.hostname);
562
+ const net = local ? "testnet" : "public";
563
+ const { hash } = await X.transactionHash(xdr, X.NETWORK_ID[net]);
564
+ // No trusted source, so the human proves they saw these exact bytes by
565
+ // typing back the hash the seller quoted. A swapped transaction hashes
566
+ // differently and this check fails.
567
+ const want = prompt(
568
+ `Network: ${net}\n` +
569
+ `This pays ${X.formatAmount(op.amount)} ${op.asset.code || "XLM"} to ` +
570
+ `${X.encodeAccountEd25519(op.destination.pub)}.\n\n` +
571
+ `Type the first 12 characters of the hash the seller quoted to confirm you are ` +
572
+ `signing the same transaction.`);
573
+ if (!want) return;
574
+ if (want.trim().toLowerCase() !== hash.slice(0, 12)) {
575
+ throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
229
576
  }
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
+ }
581
+ set("signed", X.addSignature(xdr, sig, approverPubBytes));
582
+ set("resultLabel", "Signed \u2014 send this back to the seller");
583
+ $("resultTag").className = "tag ok";
584
+ $("resultTag").textContent = "Signed";
585
+ show("result", true);
586
+ } catch (e) {
587
+ err("readErr", e.message);
588
+ }
589
+ }
590
+
591
+ // ------------------------------------------------------------------ boot
592
+ async function boot() {
593
+ // A deep link from the seller's desk saves the approval link automatically.
594
+ const here = new URL(location.href);
595
+ if (here.searchParams.get("k")) {
596
+ const u = new URL(location.pathname, location.origin);
597
+ const p = new URLSearchParams();
598
+ for (const [k2, v] of here.searchParams) p.set(k2, v);
599
+ localStorage.setItem(LINK, u.pathname + "?" + p.toString());
600
+ }
601
+
602
+ await loadKey();
603
+ paintKeyState();
604
+ paintLinkState();
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);
230
614
  });
231
615
 
232
- $("copy").addEventListener("click", async () => {
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
+
623
+ $("enrolBtn").addEventListener("click", async () => {
624
+ err("enrolErr", "");
233
625
  try {
234
- await navigator.clipboard.writeText($("signed").textContent);
235
- $("copy").textContent = "Copied";
626
+ await enrol($("seed").value);
627
+ paintKeyState();
628
+ refreshQueue();
236
629
  } catch (e) {
237
- $("copy").textContent = "Select the text above and copy it manually";
630
+ err("enrolErr", e.message);
238
631
  }
239
632
  });
633
+ $("seed").addEventListener("keydown", (e) => { if (e.key === "Enter") $("enrolBtn").click(); });
634
+ $("forget").addEventListener("click", async () => {
635
+ await idbDel();
636
+ localStorage.removeItem("paydlux.pub");
637
+ approverKey = null; approverPublic = null;
638
+ paintKeyState();
639
+ });
640
+ $("linkBtn").addEventListener("click", () => {
641
+ const raw = $("link").value.trim();
642
+ if (!raw) return;
643
+ const u = new URL(raw, location.origin);
644
+ if (!u.searchParams.get("k")) { err("linkErr", "that link has no approval key in it"); return; }
645
+ localStorage.setItem(LINK, u.pathname + "?" + u.searchParams.toString());
646
+ err("linkErr", "");
647
+ paintLinkState();
648
+ refreshQueue();
649
+ });
650
+ wireHold();
651
+ $("reject").addEventListener("click", () => current && reject(current.id) && backToQueue());
652
+ $("again").addEventListener("click", backToQueue);
653
+ $("read").addEventListener("click", readPasted);
654
+ $("copy").addEventListener("click", async () => {
655
+ try { await navigator.clipboard.writeText($("signed").textContent); $("copy").textContent = "Copied"; }
656
+ catch { $("copy").textContent = "Copy failed \u2014 select the text"; }
657
+ setTimeout(() => { $("copy").textContent = "Copy"; }, 1500);
658
+ });
240
659
 
241
- // On reload, pull the non-extractable CryptoKey back out of IndexedDB. It can
242
- // sign; it cannot be exported. The seed itself is never stored anywhere.
243
- (async () => {
244
- try {
245
- const rec = await loadKey();
246
- if (rec && rec.key instanceof CryptoKey) {
247
- signingKey = rec.key;
248
- await showReady(base64urlToBytes(rec.x));
249
- }
250
- } catch (e) { /* enrolment UI stays up */ }
251
- await loadFromDesk();
252
- })();
253
- })();
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
+
674
+ if (key()) {
675
+ await refreshQueue();
676
+ if (!document.hidden) startPolling();
677
+ const id = new URLSearchParams(localStorage.getItem(LINK)?.split("?")[1] || "").get("id");
678
+ if (id) openReview(id);
679
+ }
680
+ }
681
+
682
+ boot().catch((e) => { err("qerr", e.message); });