@paydlux/agent 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,8 @@
16
16
  "phone-app/index.html",
17
17
  "phone-app/app.js",
18
18
  "phone-app/xdr.js",
19
+ "phone-app/verify.js",
20
+ "phone-app/sw.js",
19
21
  "phone-app/app.css",
20
22
  "phone-app/manifest.webmanifest",
21
23
  "phone-app/icon-192.png",
@@ -41,7 +43,7 @@
41
43
  "test:stellar": "tsx stellar/demo.ts",
42
44
  "test:portal": "tsx stellar/xss-check.ts",
43
45
  "test:mandate": "tsx stellar/mandate-check.ts",
44
- "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts && tsx ios/bridge-check.ts",
46
+ "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts && tsx ios/bridge-check.ts && tsx phone-app/ui-check.ts",
45
47
  "test:ios": "tsx ios/bridge-check.ts",
46
48
  "test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
47
49
  "test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
package/phone-app/app.js CHANGED
@@ -19,9 +19,14 @@
19
19
 
20
20
  const X = globalThis.PaydluxXdr;
21
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 || ""; };
22
+ // Null-safe on purpose. `$(id).classList` on a missing id throws, and a throw
23
+ // here aborts the rest of the boot sequence — which is how one stale id in
24
+ // paintKeyState left the queue panel permanently hidden and the Approve buttons
25
+ // unreachable in the native app. A missing element is a cosmetic miss, not a
26
+ // reason to stop rendering the queue.
27
+ const show = (id, on = true) => { const el = $(id); if (el) el.classList.toggle("hidden", !on); };
28
+ const set = (id, v) => { const el = $(id); if (el) el.textContent = v == null || v === "" ? "\u2014" : String(v); };
29
+ const err = (id, msg) => { const el = $(id); if (el) el.textContent = msg || ""; };
25
30
 
26
31
  // A short buzz on the two moments that matter. iOS ignores vibrate(); Android
27
32
  // and desktop honour it. Guarded so it is never an uncaught error.
@@ -188,8 +193,11 @@ function paintKeyState() {
188
193
  if (have) {
189
194
  set("who", approverPublic || "held by this app");
190
195
  // 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);
196
+ // to write it, so the paste box would be a lie — and the remove button
197
+ // worse: idbDel() below clears the browser key, which does not exist here,
198
+ // so it would look like it worked while the real key stayed on the device.
199
+ // Removal belongs to the app (its menu, and the enrol screen), not the page.
200
+ if (native.available()) show("forget", false);
193
201
  }
194
202
  }
195
203
 
@@ -500,8 +508,9 @@ async function signAndSubmit() {
500
508
  // file computed, so the native side is asked to sign exactly what was on
501
509
  // screen and is never handed a transaction to interpret.
502
510
  const pre = X.signaturePreimage(hash);
503
- const sig = new Uint8Array(await signPreimage(X.hex(pre)));
504
511
  if (!approverPubBytes) throw new Error("this device lost the approver's public key — re-import it");
512
+ if (!globalThis.PaydluxVerify) throw new Error("this approver page is missing verify.js — nothing was signed");
513
+ const sig = new Uint8Array(await signPreimage(X.hex(pre)));
505
514
  // Prove the signature came from the key this screen named, before a byte
506
515
  // of it leaves the device. A bridge that signs with some other key is then
507
516
  // useless: its output never reaches the ledger.
@@ -575,6 +584,7 @@ async function readPasted() {
575
584
  throw new Error("that is not the hash the seller quoted \u2014 nothing was signed");
576
585
  }
577
586
  const sig = new Uint8Array(await signPreimage(X.hex(X.signaturePreimage(hash))));
587
+ if (!globalThis.PaydluxVerify) throw new Error("this approver page is missing verify.js \u2014 nothing was signed");
578
588
  if (!await globalThis.PaydluxVerify.verifySignature(sig, hash, approverPubBytes)) {
579
589
  throw new Error("the signature does not match this key — nothing was produced");
580
590
  }
@@ -0,0 +1,80 @@
1
+ // Paydlux approver — offline shell cache.
2
+ //
3
+ // What this caches, and what it must never cache:
4
+ //
5
+ // CACHED index.html, app.js, xdr.js, the icons, the manifest. These are the
6
+ // app itself: client code with no secret and no payment data in it,
7
+ // byte-identical for every visitor. Caching them is what lets the
8
+ // installed app launch from the home screen with no signal and still
9
+ // show its own key, instead of a Safari error page.
10
+ //
11
+ // NEVER /queue, /challenge/*, /signed/*, /approve, /deny, /list, /healthz,
12
+ // or anything carrying ?k=. Those hold payment amounts, payee
13
+ // addresses, signed transactions and the portal key. A payment
14
+ // answer left in a cache is a payment answer a later request could
15
+ // be shown instead of a live one — and approving a stale challenge is
16
+ // exactly the failure this app exists to prevent.
17
+ //
18
+ // Strategy: network-first for the shell, so an online phone always runs the
19
+ // current code (a security fix must reach it on the next open, not the next
20
+ // reinstall). Cache is the fallback when the network is not there at all.
21
+ const SHELL = [
22
+ "./",
23
+ "./app.js",
24
+ "./xdr.js",
25
+ "./verify.js",
26
+ "./manifest.webmanifest",
27
+ "./icon-192.png",
28
+ "./icon-512.png",
29
+ ];
30
+ const CACHE = "paydlux-approver-v3";
31
+
32
+ // A path is cacheable only if it is one of the shell files, by exact name.
33
+ // Everything else — including any future API route — falls through untouched.
34
+ const isShell = (url) => {
35
+ if (url.search !== "") return false; // any query = never cached
36
+ const file = url.pathname.replace(/^.*\/phone\//, "").replace(/\/$/, "") || "index.html";
37
+ return ["", "index.html", "app.js", "xdr.js", "verify.js", "manifest.webmanifest",
38
+ "icon-192.png", "icon-512.png"].includes(file);
39
+ };
40
+
41
+ self.addEventListener("install", (e) => {
42
+ e.waitUntil((async () => {
43
+ const c = await caches.open(CACHE);
44
+ // Individual puts, not addAll: one missing icon must not abort the shell.
45
+ for (const path of SHELL) {
46
+ try { await c.add(new Request(path, { cache: "reload" })); } catch { /* keep going */ }
47
+ }
48
+ self.skipWaiting();
49
+ })());
50
+ });
51
+
52
+ self.addEventListener("activate", (e) => {
53
+ e.waitUntil((async () => {
54
+ // Drop every older shell cache. An old app.js sitting around is old signing
55
+ // code, and signing code is the one thing that must not go stale.
56
+ for (const k of await caches.keys()) if (k !== CACHE) await caches.delete(k);
57
+ self.clients.claim();
58
+ })());
59
+ });
60
+
61
+ self.addEventListener("fetch", (e) => {
62
+ const url = new URL(e.request.url);
63
+ if (e.request.method !== "GET" || url.origin !== self.location.origin || !isShell(url)) return;
64
+ e.respondWith((async () => {
65
+ try {
66
+ const fresh = await fetch(e.request, { cache: "reload" });
67
+ if (fresh.ok) {
68
+ const copy = fresh.clone();
69
+ const c = await caches.open(CACHE);
70
+ await c.put(url.pathname, copy);
71
+ }
72
+ return fresh;
73
+ } catch {
74
+ return (await caches.match(url.pathname))
75
+ // No cached shell either: answer plainly rather than let the browser
76
+ // show its own error page over the app.
77
+ || new Response("offline", { status: 503, headers: { "content-type": "text/plain" } });
78
+ }
79
+ })());
80
+ });
@@ -0,0 +1,48 @@
1
+ // Did the key this screen showed actually sign these bytes?
2
+ //
3
+ // Why this exists: the app asks something else to produce a signature — the
4
+ // browser's WebCrypto, or the iOS app's Keychain-backed signer over a script
5
+ // bridge. Anything that returns bytes without proving them is a black box. A
6
+ // bridge that returned a signature from a different key than the one the review
7
+ // screen named would have it attached to a real transaction and sent to the
8
+ // ledger as if the human had approved it with the key they were shown.
9
+ //
10
+ // So the app checks the signature against the public key it displayed, over the
11
+ // bytes it asked to be signed, before anything is sent. If the check fails,
12
+ // nothing goes out and the human is told.
13
+ //
14
+ // Implementation note: this deliberately uses the platform's Ed25519 verify,
15
+ // not a hand-written one. The app already depends on WebCrypto Ed25519 to sign
16
+ // (see importSecretSeedPair in xdr.js), so verifying adds no new dependency —
17
+ // and a second hand-rolled curve implementation inside a payment approver would
18
+ // be a second place to be wrong, in the one component whose whole job is to be
19
+ // right about who signed.
20
+ "use strict";
21
+
22
+ /**
23
+ * verifySignature(sigBytes, hashHex, pubBytes) -> Promise<boolean>
24
+ *
25
+ * A Stellar approver signature covers the 32-byte transaction hash (that is
26
+ * what signaturePreimage returns, and what the ledger verifies), so the app
27
+ * verifies over the identical bytes it asked to be signed.
28
+ */
29
+ async function verifySignature(sig, hashHex, pub) {
30
+ const X = globalThis.PaydluxXdr;
31
+ if (!(sig instanceof Uint8Array) || sig.length !== 64) return false;
32
+ if (!(pub instanceof Uint8Array) || pub.length !== 32) return false;
33
+ if (!/^[0-9a-f]{64}$/.test(hashHex)) return false;
34
+ try {
35
+ // The public key is imported as a verify-only key: it cannot sign, and it
36
+ // cannot be exported into something that could.
37
+ const key = await crypto.subtle.importKey(
38
+ "raw", pub, { name: "Ed25519" }, false, ["verify"]);
39
+ return await crypto.subtle.verify(
40
+ "Ed25519", key, sig, X.signaturePreimage(hashHex));
41
+ } catch {
42
+ // A platform without WebCrypto Ed25519 cannot verify, and must not pretend
43
+ // it did. Callers treat false as "nothing was sent".
44
+ return false;
45
+ }
46
+ }
47
+
48
+ globalThis.PaydluxVerify = { verifySignature };