@paydlux/agent 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -33,7 +33,8 @@ stellar/agent.ts the buyer side — one class, one method call
33
33
  stellar/desk.ts approval queue: a human decides, in the clear
34
34
  stellar/portal.ts the page that human looks at
35
35
  stellar/policy.ts daily budget / seller allowlist (the part a ledger can't count)
36
- stellar/demo.ts 12 checks against the live testnet, one command
36
+ stellar/demo.ts 17 checks against the live testnet, one command
37
+ phone-app/ the approver app a human opens on their own device
37
38
  stellar/probe-*.ts the experiments that settled how the ledger behaves
38
39
  stellar/onboard.ts keys → funded vault → printed agent key
39
40
  ```
@@ -42,10 +43,36 @@ stellar/onboard.ts keys → funded vault → printed agent key
42
43
 
43
44
  ```bash
44
45
  npm i
45
- npx tsx stellar/demo.ts # 12 checks, live testnet
46
- npx tsx stellar/story.ts --public # one unguessable link; approve from your phone
46
+ npx tsx stellar/demo.ts # 17 checks, live testnet
47
+ npx tsx stellar/story.ts --tls --split # https://desk.paydlux.com; approve on your phone
47
48
  ```
48
49
 
50
+ `--tls` is the one to use for anything real. It runs a named Cloudflare tunnel
51
+ so the desk is served over `https://desk.paydlux.com` with a real certificate,
52
+ instead of a random `trycloudflare.com` hostname (`--public`) that nothing tells
53
+ the human is *your* desk. Setup is one-time and free:
54
+
55
+ ```bash
56
+ cloudflared tunnel login
57
+ cloudflared tunnel create paydlux-desk
58
+ cloudflared tunnel route dns paydlux-desk desk.paydlux.com
59
+ ```
60
+
61
+ Then `~/.cloudflared/config.yml` points the hostname at `http://127.0.0.1:4022`.
62
+ The tunnel's credentials file stays in `~/.cloudflared/` and must never be
63
+ committed: whoever holds it can serve your hostname.
64
+
65
+ What the desk enforces once it is public (`npx tsx stellar/tls-check.ts`, 19 checks):
66
+
67
+ - plain `http://` is 301'd to `https://` and never served, with HSTS set
68
+ - the desk listens on `127.0.0.1` only, and refuses any `Host` that is not
69
+ `desk.paydlux.com` — so the local port cannot be driven directly to skip TLS
70
+ - a desk configured with a public hostname **refuses to start** without a portal
71
+ key, rather than coming up as an open approval button
72
+ - the portal key also rides in an `HttpOnly; SameSite=Strict; Secure` cookie, so
73
+ the approver app can load its own scripts (a `<script src>` cannot inherit
74
+ `?k=` from the page URL) without the key ever appearing in the DOM
75
+
49
76
  No wallet, no faucet, no config.
50
77
 
51
78
  ### The human key does not live on the seller's machine
@@ -54,17 +81,28 @@ The vault needs two signatures. If both keys sit in one process, that is really
54
81
  one key. So the approval half is split off:
55
82
 
56
83
  ```bash
57
- npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
58
- npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
84
+ npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
85
+ npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
86
+ npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
87
+ npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
59
88
  ```
60
89
 
61
- The seller builds the payment and signs it with their own half. The human's
62
- device (`tools/phone-sign.ts`) decodes it, shows a person the amount and payee,
63
- and signs offline. The approver seed never travels to the seller's machine, and
64
- the signature is over the transaction hash — change the amount or the payee and
65
- it stops counting.
66
-
67
- No wallet, no faucet, no config.
90
+ The seller builds the payment and signs it with their own half. The human opens
91
+ one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
92
+ amount, the token, the payee and the deadline, then signs on the device.
93
+
94
+ The app is three static files with no dependencies and no build step. It carries
95
+ its own Stellar transaction decoder (`phone-app/xdr.js`) rather than the SDK, and
96
+ refuses any transaction shape it does not fully understand: path payments,
97
+ account merges, signer changes and multi-operation transactions are rejected
98
+ outright rather than guessed at. The key is imported into WebCrypto as
99
+ **non-extractable** and only the `CryptoKey` object itself is persisted, so the
100
+ seed is never in localStorage, IndexedDB, or a file. The page is served with
101
+ `connect-src 'self'`, so an injected script cannot phone the key home.
102
+
103
+ The approver seed never travels to the seller's machine, and the signature is
104
+ over the transaction hash — change the amount or the payee and it stops counting.
105
+ `tools/phone-sign.ts` is the same flow for a terminal instead of a browser.
68
106
 
69
107
  Use it as a package instead of cloning:
70
108
 
package/dist/desk.d.ts CHANGED
@@ -38,6 +38,15 @@ export type DeskConfig = {
38
38
  /** If set, the portal and its actions require this key in the URL.
39
39
  * Required before putting the desk on any public URL. */
40
40
  portalKey?: string;
41
+ /**
42
+ * The hostname this desk is served on, e.g. "desk.paydlux.com". When set, the
43
+ * desk answers ONLY requests for that Host. A named Cloudflare tunnel gives
44
+ * real TLS on your own domain, but the local port it forwards to is still
45
+ * plain HTTP: if anything on this machine (or network) reached 127.0.0.1:4022
46
+ * directly it would bypass TLS and the HSTS/cookie protections the domain
47
+ * gives you. Pinning the Host means the only way in is through the tunnel.
48
+ */
49
+ publicHost?: string;
41
50
  };
42
51
  export declare class ApprovalDesk {
43
52
  private records;
package/dist/desk.js CHANGED
@@ -10,12 +10,38 @@
10
10
  // - a stolen phone cannot approve alone
11
11
  // - the approval is a real ledger signature, not a database flag
12
12
  import { createServer } from "node:http";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
13
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
17
  import { Keypair } from "@stellar/stellar-sdk";
15
18
  import { TransactionBuilder } from "@stellar/stellar-sdk";
16
19
  import { passphrase } from "./vault.js";
17
20
  import { portalPage } from "./portal.js";
18
21
  import { createApprovalChallenge, submitApproved, challengeQrPayload, } from "./phone.js";
22
+ /**
23
+ * The approver app is plain static files under phone-app/. Read on each request
24
+ * rather than bundled, so the desk and the app can never drift apart.
25
+ */
26
+ const APP_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "phone-app");
27
+ const appCache = new Map();
28
+ function readAppFile(name) {
29
+ const safe = name.replace(/[^a-z.]/g, "");
30
+ const hit = appCache.get(safe);
31
+ if (hit)
32
+ return hit;
33
+ let text;
34
+ try {
35
+ text = readFileSync(join(APP_DIR, safe), "utf8");
36
+ }
37
+ catch {
38
+ // A package install that shipped without phone-app/ should say so instead of
39
+ // throwing out of the request handler.
40
+ throw new Error("approver app is missing from this install (expected " + join(APP_DIR, safe) + ")");
41
+ }
42
+ appCache.set(safe, text);
43
+ return text;
44
+ }
19
45
  /** A portal link is a capability: whoever holds it can approve. So it is long,
20
46
  * random, and compared without leaking timing. */
21
47
  export function newPortalKey() { return randomBytes(16).toString("base64url"); }
@@ -71,6 +97,20 @@ function readBody(req) {
71
97
  req.on("error", reject);
72
98
  });
73
99
  }
100
+ /** Read one cookie out of the request header. No dependency for this. */
101
+ function cookieOf(req, name) {
102
+ const raw = req.headers.cookie;
103
+ if (!raw)
104
+ return null;
105
+ for (const part of raw.split(";")) {
106
+ const eq = part.indexOf("=");
107
+ if (eq < 0)
108
+ continue;
109
+ if (part.slice(0, eq).trim() === name)
110
+ return decodeURIComponent(part.slice(eq + 1).trim());
111
+ }
112
+ return null;
113
+ }
74
114
  function same(a, b) {
75
115
  const x = Buffer.from(a), y = Buffer.from(b);
76
116
  return x.length === y.length && timingSafeEqual(x, y);
@@ -204,16 +244,68 @@ export class ApprovalDesk {
204
244
  /** Serve the pending list as JSON (the portal polls this). */
205
245
  api(handler) { return handler; }
206
246
  listen(port = this.cfg.port) {
247
+ // A desk reachable from outside this machine must be gated. This is a
248
+ // startup refusal rather than a runtime one because the dangerous case is
249
+ // forgetting a flag: a public desk with no portal key is an open approval
250
+ // button, and it would otherwise start up looking completely healthy.
251
+ if (this.cfg.publicHost && !this.cfg.portalKey) {
252
+ throw new Error("a desk served on " + this.cfg.publicHost + " needs a portalKey; " +
253
+ "refusing to start an ungated approval desk");
254
+ }
207
255
  const srv = createServer(async (req, res) => {
208
256
  // (server handle kept below so close() can stop it)
209
257
  const url = new URL(req.url ?? "/", "http://localhost");
258
+ // Order matters. The Host pin runs first so a request that did not come
259
+ // through the tunnel is refused before the desk reveals anything about
260
+ // itself, including whether a portal key looks correct.
261
+ if (this.cfg.publicHost) {
262
+ const want = this.cfg.publicHost.toLowerCase();
263
+ const got = String(req.headers.host ?? "").toLowerCase().replace(/:\d+$/, "");
264
+ if (got !== want) {
265
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
266
+ res.end("403 — this desk only answers on " + want + ".");
267
+ return;
268
+ }
269
+ // Cloudflare terminates TLS and forwards both http and https to us, so
270
+ // http://desk.paydlux.com answers too unless we send it away. A human
271
+ // approving a payment over plain HTTP is handing the portal key and the
272
+ // transaction to anyone on the path, so plain HTTP gets a redirect and
273
+ // nothing else. x-forwarded-proto is set by Cloudflare, not the client.
274
+ if (String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim() === "http") {
275
+ res.writeHead(301, { location: "https://" + want + (req.url ?? "/") });
276
+ res.end();
277
+ return;
278
+ }
279
+ // Once HTTPS is established, tell the browser to never try plain HTTP
280
+ // again for this host.
281
+ res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
282
+ }
283
+ // Liveness probe, before the portal-key gate: it proves the desk and its
284
+ // TLS path are up without revealing anything. No data, safe to poll.
285
+ if (url.pathname === "/healthz") {
286
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
287
+ res.end("ok");
288
+ return;
289
+ }
210
290
  // A public demo link must not be an open approval button. If a portalKey
211
291
  // is configured, every route needs it, or the desk answers 403.
212
- if (this.cfg.portalKey && !same(url.searchParams.get("k") ?? "", this.cfg.portalKey)) {
292
+ //
293
+ // The key may arrive in the URL (the link the human opened) or in a
294
+ // cookie the desk set when that link was first validated. The cookie is
295
+ // what makes the approver app work: its <script src="xdr.js"> cannot
296
+ // inherit ?k= from the page URL, so without a cookie the app 403s on its
297
+ // own code and shows a blank form. HttpOnly + SameSite=Strict + Secure
298
+ // keeps it out of the DOM and off any cross-site request.
299
+ const presented = url.searchParams.get("k") ?? cookieOf(req, "pd_portal");
300
+ if (this.cfg.portalKey && !same(presented ?? "", this.cfg.portalKey)) {
213
301
  res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
214
302
  res.end("403 — this approval link was never issued or has been rotated.");
215
303
  return;
216
304
  }
305
+ if (this.cfg.portalKey && url.searchParams.get("k") !== null) {
306
+ res.setHeader("set-cookie", `pd_portal=${this.cfg.portalKey}; Path=/; HttpOnly; SameSite=Strict; ` +
307
+ `Secure; Max-Age=${12 * 3600}`);
308
+ }
217
309
  const qs = url.search;
218
310
  const send = (code, obj) => {
219
311
  res.writeHead(code, { "content-type": "application/json" });
@@ -227,6 +319,66 @@ export class ApprovalDesk {
227
319
  }
228
320
  if (url.pathname === "/list")
229
321
  return send(200, this.list());
322
+ // The approver app. Served from the desk so the human opens one link on
323
+ // their phone instead of pasting XDR around. The app itself makes no
324
+ // requests after loading, and the portalKey gate above already covers
325
+ // these paths: without the key, nothing here is reachable.
326
+ // The app uses relative script srcs, so it must be served from a
327
+ // directory URL. /phone without the slash would resolve app.js to
328
+ // /app.js, so send it to the canonical path with the key intact.
329
+ if (url.pathname === "/phone") {
330
+ res.writeHead(302, { location: "/phone/" + url.search });
331
+ res.end();
332
+ return;
333
+ }
334
+ if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
335
+ res.writeHead(200, {
336
+ "content-type": "text/html; charset=utf-8",
337
+ // The key lives in IndexedDB behind a non-extractable CryptoKey;
338
+ // an injected script is the realistic threat, so lock the origin.
339
+ // No inline script on the page: app.js and xdr.js are separate
340
+ // files, so script-src 'self' actually holds. connect-src 'self' is
341
+ // what stops the app talking anywhere but the desk that served it.
342
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
343
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
344
+ "form-action 'none'; frame-ancestors 'none'",
345
+ "referrer-policy": "no-referrer",
346
+ "x-content-type-options": "nosniff",
347
+ });
348
+ res.end(readAppFile("index.html"));
349
+ return;
350
+ }
351
+ if (url.pathname.startsWith("/phone/")) {
352
+ const file = url.pathname.slice("/phone/".length);
353
+ if (!/^[a-z]+\.(js|css)$/.test(file))
354
+ return send(404, { error: "not found" });
355
+ res.writeHead(200, {
356
+ "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
357
+ "cache-control": "no-store",
358
+ "x-content-type-options": "nosniff",
359
+ });
360
+ res.end(readAppFile(file));
361
+ return;
362
+ }
363
+ // The app fetches the pending transaction by handle rather than asking
364
+ // the human to paste 400 characters of base64 on a phone.
365
+ const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
366
+ if (ch) {
367
+ const rec = this.records.get(ch[1]);
368
+ if (!rec)
369
+ return send(404, { error: "unknown handle" });
370
+ // Opening the phone link IS the approval gesture, so build the
371
+ // challenge here if nobody pressed the button yet. The human should
372
+ // need one link, not a button on a desktop plus a link on a phone.
373
+ if (rec.status === "pending")
374
+ await this.approve(rec.id);
375
+ if (!rec.challenge)
376
+ return send(409, { error: `not awaiting a phone signature (it is ${rec.status})` });
377
+ return send(200, {
378
+ id: rec.id, network: rec.challenge.network,
379
+ hash: rec.challenge.hash, xdr: rec.challenge.xdr,
380
+ });
381
+ }
230
382
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
231
383
  if (m && req.method === "POST") {
232
384
  if (m[1] === "signed") {
@@ -246,7 +398,10 @@ export class ApprovalDesk {
246
398
  }
247
399
  });
248
400
  this.server = srv;
249
- return new Promise((r) => srv.listen(port, () => r()));
401
+ // Loopback only. The public path is the Cloudflare tunnel, which connects
402
+ // back to 127.0.0.1; binding to 0.0.0.0 would expose a plain-HTTP desk to
403
+ // the LAN and defeat both the TLS and the Host pin above.
404
+ return new Promise((r) => srv.listen(port, "127.0.0.1", () => r()));
250
405
  }
251
406
  /** Stop serving. Without this the process never exits. */
252
407
  close() {
package/dist/gateway.d.ts CHANGED
@@ -19,9 +19,19 @@ export type GatewayConfig = {
19
19
  /** human keys. In production these live on the phone, never here. */
20
20
  principal?: string;
21
21
  approver?: string;
22
+ /**
23
+ * Split mode: the desk gets the principal key (the seller's own half) but NOT
24
+ * the approver key, so this process cannot approve anything. Approval happens
25
+ * on a second device through the approver app. Pass `approver: undefined`
26
+ * together with this; the gateway still settles, using the signature the
27
+ * phone sends back.
28
+ */
29
+ split?: boolean;
22
30
  deskPort?: number;
23
31
  /** gate the approval portal behind a single unguessable link */
24
32
  portalKey?: string;
33
+ /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
34
+ publicHost?: string;
25
35
  };
26
36
  export type PaymentProof = {
27
37
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
package/dist/gateway.js CHANGED
@@ -35,6 +35,8 @@ export class Gateway {
35
35
  approver: cfg.approver,
36
36
  port: cfg.deskPort ?? 4022,
37
37
  portalKey: cfg.portalKey,
38
+ split: cfg.split,
39
+ publicHost: cfg.publicHost,
38
40
  });
39
41
  }
40
42
  /** Mandates the vault currently holds, so the gateway can hand them out. */
package/dist/portal.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export function portalPage(records, vault, network, qs = "") {
2
2
  const rows = records.length
3
- ? records.map(render).join("\n")
3
+ ? records.map((r) => render(r, qs)).join("\n")
4
4
  : `<tr><td colspan="5" class="empty">nothing waiting</td></tr>`;
5
5
  return `<!doctype html><html><head>
6
6
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -25,6 +25,7 @@ export function portalPage(records, vault, network, qs = "") {
25
25
  .tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
26
26
  .qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
27
27
  details summary{cursor:pointer}
28
+ .phlink{color:var(--gold);text-decoration:underline;font-size:12px}
28
29
  .foot{margin-top:28px;color:var(--muted);font-size:12px}
29
30
  @media(max-width:640px){.body{max-width:180px}}
30
31
  </style></head><body>
@@ -46,15 +47,22 @@ export function portalPage(records, vault, network, qs = "") {
46
47
  setInterval(load,3000);
47
48
  </script></body></html>`;
48
49
  }
49
- function render(r) {
50
+ function render(r, qs = "") {
51
+ // qs is the portal key query string ("?k=..."). The approver link has to carry
52
+ // it, because the desk answers 403 for anything without it.
53
+ const link = `phone/?id=${encodeURIComponent(r.id)}${qs.replace("?", "&")}`;
50
54
  const btns = r.status === "pending"
51
55
  ? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
52
56
  <button class="no" onclick="act('deny','${r.id}')">Deny</button>`
53
57
  : "";
54
58
  // Split mode: the desk has no approver key, so approving hands the payment to
55
59
  // a phone. Show the human exactly what the phone will be asked to sign.
60
+ // Split mode: the desk has no approver key, so approving hands the payment to
61
+ // a phone. Show the human exactly what the phone will be asked to sign, and
62
+ // give the approver app a link so nobody pastes base64 on a small screen.
56
63
  const phone = r.status === "awaiting_phone" && r.challenge
57
64
  ? `<div class="tx">awaiting your phone · ${r.challenge.summary.amount} ${r.challenge.summary.token} to ${r.challenge.summary.payee.slice(0, 12)}…</div>
65
+ <div class="tx"><a class="phlink" href="${link}">open on your phone to sign</a></div>
58
66
  <details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
59
67
  : "";
60
68
  const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
+ "phone-app/index.html",
17
+ "phone-app/app.js",
18
+ "phone-app/xdr.js",
16
19
  "README.md",
17
20
  "LICENSE"
18
21
  ],
@@ -26,8 +29,11 @@
26
29
  "demo:stellar": "tsx stellar/demo.ts",
27
30
  "setup:stellar": "tsx stellar/onboard.ts",
28
31
  "story:stellar": "tsx stellar/story.ts",
32
+ "story:tls": "tsx stellar/story.ts --tls --split",
33
+ "test:tls": "tsx stellar/tls-check.ts",
29
34
  "test": "cd contracts && forge test",
30
35
  "test:stellar": "tsx stellar/demo.ts",
36
+ "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-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",
31
37
  "typecheck": "tsc --noEmit"
32
38
  },
33
39
  "keywords": [
@@ -0,0 +1,253 @@
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
+ }
35
+
36
+ async function showReady(pub) {
37
+ $("who").textContent = X.encodeAccountEd25519(pub);
38
+ $("enrol").classList.add("hidden");
39
+ $("ready").classList.remove("hidden");
40
+ }
41
+
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
+ }
72
+ });
73
+
74
+ $("forget").addEventListener("click", async () => {
75
+ signingKey = null;
76
+ await dropKey();
77
+ location.reload();
78
+ });
79
+
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;
93
+ 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
+ }
105
+ }
106
+
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
+ });
115
+
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");
137
+ }
138
+
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";
144
+ }
145
+
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;
154
+ }
155
+ });
156
+
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 = "";
164
+ }
165
+
166
+ // ---------------------------------------------------------------- sign
167
+ $("approve").addEventListener("click", async () => {
168
+ $("signErr").textContent = "";
169
+ if (!signingKey) {
170
+ $("signErr").textContent = "Import the approver key first.";
171
+ return;
172
+ }
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
+ }
191
+ }
192
+
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;
229
+ }
230
+ });
231
+
232
+ $("copy").addEventListener("click", async () => {
233
+ try {
234
+ await navigator.clipboard.writeText($("signed").textContent);
235
+ $("copy").textContent = "Copied";
236
+ } catch (e) {
237
+ $("copy").textContent = "Select the text above and copy it manually";
238
+ }
239
+ });
240
+
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
+ })();
@@ -0,0 +1,148 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0b0f14">
7
+ <title>Paydlux Approver</title>
8
+ <!--
9
+ The approver half of a Paydlux vault.
10
+
11
+ This page holds ONE key: the approver's. It cannot spend anything by itself.
12
+ It reads a pending payment, shows a human what they are approving, and signs
13
+ the transaction hash. Signing is a purely local operation: no network call can
14
+ change what was signed.
15
+
16
+ The page talks to one origin only — the desk that served it — to fetch the
17
+ pending transaction and hand back the signature. connect-src 'self' in the
18
+ desk's Content-Security-Policy enforces that.
19
+
20
+ What the key model actually guarantees:
21
+ - the seed becomes a NON-extractable WebCrypto key; after enrolment nothing
22
+ on this page, including an injected script, can read it back;
23
+ - the signature covers the transaction hash, so a changed amount or payee
24
+ produces a different hash and a signature the ledger refuses;
25
+ - the approver key alone cannot move funds — the vault needs 2 signatures.
26
+
27
+ What it does NOT guarantee: a person who roots this device, or who approves a
28
+ transaction without reading it. The human reading the amount and payee is part
29
+ of the security model, not a UX step.
30
+
31
+ Serve it over http(s) on a device you control: npx tsx tools/phone-serve.ts
32
+ -->
33
+ <style>
34
+ :root {
35
+ --bg:#0b0f14; --panel:#121821; --line:#22303f; --ink:#e8eef6; --dim:#8fa3b8;
36
+ --ok:#3ddc97; --warn:#ffb454; --bad:#ff6b6b; --accent:#4da3ff;
37
+ }
38
+ * { box-sizing:border-box }
39
+ body {
40
+ margin:0; background:var(--bg); color:var(--ink); font:16px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;
41
+ padding:max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom));
42
+ max-width:560px; margin-inline:auto;
43
+ }
44
+ h1 { font-size:17px; margin:0 0 4px; letter-spacing:.02em }
45
+ .sub { color:var(--dim); font-size:13px; margin-bottom:18px }
46
+ .panel { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px }
47
+ label { display:block; font-size:12px; color:var(--dim); margin:10px 0 4px; text-transform:uppercase; letter-spacing:.06em }
48
+ textarea, input {
49
+ width:100%; background:#0d131a; color:var(--ink); border:1px solid var(--line);
50
+ border-radius:10px; padding:10px; font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
51
+ word-break:break-all;
52
+ }
53
+ textarea { min-height:84px; resize:vertical }
54
+ button {
55
+ width:100%; margin-top:12px; padding:14px; border:0; border-radius:12px;
56
+ background:var(--accent); color:#04121f; font-size:16px; font-weight:700; cursor:pointer;
57
+ }
58
+ button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); font-weight:600 }
59
+ button.danger { background:var(--bad); color:#2a0707 }
60
+ button:disabled { opacity:.4 }
61
+ .row { display:flex; gap:10px }
62
+ .row > * { flex:1 }
63
+ .kv { display:grid; grid-template-columns:96px 1fr; gap:6px 10px; font-size:14px }
64
+ .kv dt { color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:.06em; padding-top:2px }
65
+ .kv dd { margin:0; font-family:ui-monospace,Menlo,monospace; word-break:break-all; font-size:13px }
66
+ .amount { font-size:34px; font-weight:800; margin:2px 0 10px; font-variant-numeric:tabular-nums }
67
+ .tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
68
+ .tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
69
+ .tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
70
+ .tag.bad { background:rgba(255,107,107,.14); color:var(--bad) }
71
+ .err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
72
+ .hint { color:var(--dim); font-size:12px; margin-top:8px }
73
+ .hidden { display:none }
74
+ .out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
75
+ font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
76
+ .who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <h1>Paydlux Approver</h1>
81
+ <div class="sub">Signs payments on this device. The key never leaves it.</div>
82
+
83
+ <!-- ---------------------------------------------------------- enrolment -->
84
+ <div class="panel" id="enrol">
85
+ <div class="tag warn">No approver key on this device</div>
86
+ <label for="seed">Approver secret seed (S&hellip;)</label>
87
+ <input id="seed" type="password" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
88
+ placeholder="S">
89
+ <button id="enrolBtn">Import key</button>
90
+ <div class="hint">
91
+ The seed becomes a non-extractable signing key and is then dropped. Only that key is
92
+ kept: it can sign, nothing can read the seed back out of it. If this device is wiped,
93
+ re-enter the seed.
94
+ </div>
95
+ <div class="err" id="enrolErr"></div>
96
+ </div>
97
+
98
+ <!-- ------------------------------------------------------------ ready -->
99
+ <div class="panel hidden" id="ready">
100
+ <div class="tag ok">Key loaded</div>
101
+ <dl class="kv" style="margin-top:10px">
102
+ <dt>Approver</dt><dd class="who" id="who"></dd>
103
+ </dl>
104
+ <button class="ghost" id="forget">Remove key from this device</button>
105
+ </div>
106
+
107
+ <!-- --------------------------------------------------------- approval -->
108
+ <div class="panel">
109
+ <label for="xdr">Pending transaction (paste the XDR from the seller)</label>
110
+ <textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
111
+ placeholder="Paste the transaction the seller sent you"></textarea>
112
+ <button id="read">Read it</button>
113
+ <div class="err" id="readErr"></div>
114
+ </div>
115
+
116
+ <div class="panel hidden" id="review">
117
+ <div class="amount" id="amount"></div>
118
+ <dl class="kv">
119
+ <dt>Token</dt><dd id="token"></dd>
120
+ <dt>Payee</dt><dd id="payee"></dd>
121
+ <dt>From</dt><dd id="vault"></dd>
122
+ <dt>Reference</dt><dd id="memo"></dd>
123
+ <dt>Deadline</dt><dd id="deadline"></dd>
124
+ <dt>Network</dt><dd id="network"></dd>
125
+ <dt>Hash</dt><dd id="hash"></dd>
126
+ </dl>
127
+ <div class="hint">
128
+ Only the hash is signed. If anything above differs from what the seller told you,
129
+ do not approve &mdash; a changed amount or payee produces a different hash.
130
+ </div>
131
+ <button id="approve">Approve &amp; sign</button>
132
+ <button class="ghost" id="reject">Reject</button>
133
+ <div class="err" id="signErr"></div>
134
+ </div>
135
+
136
+ <!-- ----------------------------------------------------------- result -->
137
+ <div class="panel hidden" id="result">
138
+ <div class="tag ok">Signed</div>
139
+ <label>Signed transaction &mdash; send this back to the seller</label>
140
+ <div class="out" id="signed"></div>
141
+ <button id="copy">Copy</button>
142
+ <button class="ghost" id="again">Done</button>
143
+ </div>
144
+
145
+ <script src="xdr.js"></script>
146
+ <script src="app.js"></script>
147
+ </body>
148
+ </html>
@@ -0,0 +1,436 @@
1
+ // Minimal Stellar transaction codec for the phone. No dependencies, no network.
2
+ //
3
+ // The phone must be able to (a) read a payment so a human can see what they are
4
+ // approving, (b) recompute the transaction hash so it can prove the bytes it was
5
+ // given are the bytes it was shown, and (c) append its signature.
6
+ //
7
+ // It deliberately understands only the payment envelope shape. Anything it does
8
+ // not recognise is an error, not a guess — refusing to sign is always safe.
9
+ //
10
+ // Field order below is taken from the Stellar XDR schema (Transaction,
11
+ // Operation, PaymentOp, Preconditions, Memo, Asset, TransactionExt,
12
+ // DecoratedSignature). phone-app/xdr-check.ts asserts this decoder agrees with
13
+ // the official SDK on a real vault payment.
14
+ //
15
+ // This file runs unchanged in the browser and in Node (tests).
16
+ (function (root) {
17
+ "use strict";
18
+
19
+ // ------------------------------------------------------------------ reader
20
+ function Reader(bytes) {
21
+ this.b = bytes; this.i = 0;
22
+ }
23
+ Reader.prototype.u32 = function () {
24
+ if (this.i + 4 > this.b.length) throw new Error("xdr: truncated reading uint32");
25
+ const v = (this.b[this.i] << 24 | this.b[this.i + 1] << 16 | this.b[this.i + 2] << 8 | this.b[this.i + 3]) >>> 0;
26
+ this.i += 4; return v;
27
+ };
28
+ // Sequence numbers and amounts are int64 and routinely exceed
29
+ // Number.MAX_SAFE_INTEGER, so 64-bit values stay BigInt until displayed.
30
+ Reader.prototype.u64 = function () {
31
+ const hi = BigInt(this.u32()), lo = BigInt(this.u32());
32
+ return (hi << 32n) | lo;
33
+ };
34
+ Reader.prototype.i64 = function () {
35
+ const v = this.u64();
36
+ return v >= (1n << 63n) ? v - (1n << 64n) : v;
37
+ };
38
+ Reader.prototype.fixed = function (n) {
39
+ if (this.i + n > this.b.length) throw new Error("xdr: truncated fixed bytes");
40
+ const out = this.b.slice(this.i, this.i + n);
41
+ this.i += n;
42
+ const pad = (4 - (n % 4)) % 4;
43
+ for (let p = 0; p < pad; p++) {
44
+ if (this.b[this.i + p] !== 0) throw new Error("xdr: bad padding");
45
+ }
46
+ this.i += pad;
47
+ return out;
48
+ };
49
+ Reader.prototype.varOpaque = function (max) {
50
+ const n = this.u32();
51
+ if (n > max) throw new Error(`xdr: opaque length ${n} exceeds the ${max}-byte limit`);
52
+ return this.fixed(n);
53
+ };
54
+
55
+ // ------------------------------------------------------------------ writer
56
+ function Writer() { this.b = []; }
57
+ Writer.prototype.u32 = function (v) {
58
+ this.b.push((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
59
+ };
60
+ Writer.prototype.u64 = function (v) {
61
+ const b = BigInt(v);
62
+ this.u32(Number((b >> 32n) & 0xffffffffn)); this.u32(Number(b & 0xffffffffn));
63
+ };
64
+ Writer.prototype.fixed = function (bytes) {
65
+ for (const x of bytes) this.b.push(x);
66
+ const pad = (4 - (bytes.length % 4)) % 4;
67
+ for (let p = 0; p < pad; p++) this.b.push(0);
68
+ };
69
+ Writer.prototype.varOpaque = function (bytes) { this.u32(bytes.length); this.fixed(bytes); };
70
+ Writer.prototype.bytes = function () { return Uint8Array.from(this.b); };
71
+
72
+ // ------------------------------------------------------------------ constants
73
+ const ENVELOPE_TYPE_TX = 2; // TransactionEnvelope holding a v1 tx
74
+ const KEY_TYPE_ED25519 = 0;
75
+ const KEY_TYPE_MUXED_ED25519 = 256;
76
+ const SC_ADDRESS = 4; // soroban account: we refuse it
77
+ const ASSET_NATIVE = 0, ASSET_ALPHANUM4 = 1, ASSET_ALPHANUM12 = 2;
78
+ const OP_PAYMENT = 1;
79
+ const PRECOND_NONE = 0, PRECOND_TIME = 1, PRECOND_V2 = 2;
80
+ const MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2, MEMO_HASH = 3, MEMO_RETURN = 4;
81
+ const STROOPS = 10000000;
82
+
83
+ // ------------------------------------------------------------------ strkey
84
+ const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
85
+
86
+ /** Stellar account id (G...) from a 32-byte ed25519 public key. */
87
+ function encodeAccountEd25519(pub) {
88
+ if (pub.length !== 32) throw new Error("public key must be 32 bytes");
89
+ return encodeStrKey(6 << 3, pub);
90
+ }
91
+
92
+ /** 32-byte public key from a Stellar account id. */
93
+ function decodeAccountEd25519(str) {
94
+ if (typeof str !== "string" || str[0] !== "G") {
95
+ throw new Error("not a Stellar account id (expected G...)");
96
+ }
97
+ return decodeStrKey(str, 6 << 3, 32);
98
+ }
99
+
100
+ /**
101
+ * Stellar secret seed (S...) -> 32-byte seed. Same base32 envelope as an
102
+ * account id, different version byte.
103
+ */
104
+ function decodeSecretSeed(str) {
105
+ if (typeof str !== "string" || str[0] !== "S") {
106
+ throw new Error("not a Stellar secret seed (expected S...)");
107
+ }
108
+ return decodeStrKey(str, 18 << 3, 32);
109
+ }
110
+
111
+ /** Stellar secret seed (S...) from a 32-byte seed. */
112
+ function encodeSecretSeed(seed) {
113
+ if (seed.length !== 32) throw new Error("seed must be 32 bytes");
114
+ return encodeStrKey(18 << 3, seed);
115
+ }
116
+
117
+ function encodeStrKey(version, body) {
118
+ const bytes = [version, ...body, 0, 0];
119
+ const crc = crc16Xmodem(bytes.slice(0, 1 + body.length));
120
+ bytes[bytes.length - 2] = crc & 255;
121
+ bytes[bytes.length - 1] = (crc >> 8) & 255;
122
+ const bits = [];
123
+ for (const byte of bytes) for (let k = 7; k >= 0; k--) bits.push((byte >> k) & 1);
124
+ let out = "";
125
+ for (let k = 0; k + 5 <= bits.length; k += 5) {
126
+ out += B32[bits[k] * 16 + bits[k + 1] * 8 + bits[k + 2] * 4 + bits[k + 3] * 2 + bits[k + 4]];
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function decodeStrKey(str, version, bodyLen) {
132
+ if (str.length !== 56) throw new Error("strkey must be 56 characters");
133
+ const bits = [];
134
+ for (const ch of str) {
135
+ const v = B32.indexOf(ch);
136
+ if (v < 0) throw new Error("not a valid strkey: bad character " + ch);
137
+ for (let k = 4; k >= 0; k--) bits.push((v >> k) & 1);
138
+ }
139
+ const bytes = [];
140
+ for (let k = 0; k + 8 <= bits.length; k += 8) {
141
+ let byte = 0;
142
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[k + j];
143
+ bytes.push(byte);
144
+ }
145
+ if (bytes[0] !== version) throw new Error("wrong strkey version byte");
146
+ const body = Uint8Array.from(bytes.slice(1, 1 + bodyLen));
147
+ const checksum = (bytes[1 + bodyLen] | (bytes[2 + bodyLen] << 8)) >>> 0;
148
+ if (checksum !== crc16Xmodem(bytes.slice(0, 1 + bodyLen))) {
149
+ throw new Error("strkey checksum does not match — the key was mistyped");
150
+ }
151
+ return body;
152
+ }
153
+
154
+ /** CRC16-XMODEM, the checksum Stellar strkeys use. */
155
+ function crc16Xmodem(bytes) {
156
+ let crc = 0;
157
+ for (const b of bytes) {
158
+ crc ^= b << 8;
159
+ for (let i = 0; i < 8; i++) {
160
+ crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
161
+ }
162
+ }
163
+ return crc & 0xffff;
164
+ }
165
+
166
+ /**
167
+ * Turn a Stellar secret seed into a WebCrypto signing key.
168
+ *
169
+ * The result is NON-extractable: the app can sign with it, but nothing — not
170
+ * the page's own JavaScript, not an injected script — can read the seed back
171
+ * out of it. That is the point of the phone holding the approval half.
172
+ * The public half is captured at enrolment and stored separately.
173
+ */
174
+ async function importSecretSeed(seedStr) {
175
+ const seed = decodeSecretSeed(seedStr);
176
+ const pkcs8 = new Uint8Array(16 + 32);
177
+ pkcs8.set([
178
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
179
+ 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
180
+ ], 0);
181
+ pkcs8.set(seed, 16);
182
+ const privateKey = await crypto.subtle.importKey(
183
+ "pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
184
+ seed.fill(0); // drop the plaintext copy as soon as we can
185
+ return privateKey;
186
+ }
187
+
188
+ // ------------------------------------------------------------------ parsing
189
+ function readMuxedAccount(r) {
190
+ const type = r.u32();
191
+ if (type === KEY_TYPE_ED25519) return { kind: "ed25519", pub: r.fixed(32) };
192
+ if (type === KEY_TYPE_MUXED_ED25519) {
193
+ const id = r.u64();
194
+ const pub = r.fixed(32);
195
+ return { kind: "muxed", id: id.toString(), pub };
196
+ }
197
+ if (type === SC_ADDRESS) throw new Error("a smart-contract address cannot approve a payment");
198
+ throw new Error("unknown account key type " + type);
199
+ }
200
+
201
+ function readAsset(r) {
202
+ const type = r.u32();
203
+ if (type === ASSET_NATIVE) return { code: "XLM", issuer: null };
204
+ if (type === ASSET_ALPHANUM4 || type === ASSET_ALPHANUM12) {
205
+ const code = asciiTrim(r.fixed(type === ASSET_ALPHANUM4 ? 4 : 12));
206
+ return { code, issuer: encodeAccountEd25519(readPublicKey(r)) };
207
+ }
208
+ throw new Error("asset type " + type + " is not supported; refusing to sign");
209
+ }
210
+
211
+ /**
212
+ * Asset issuers use public_key, which is a union: a 4-byte key-type word
213
+ * before the 32 bytes. Missing that word shifts every later field by four
214
+ * bytes and silently misreads the amount, so it is read explicitly.
215
+ */
216
+ function readPublicKey(r) {
217
+ const type = r.u32();
218
+ if (type !== KEY_TYPE_ED25519) {
219
+ throw new Error("issuer key type " + type + " is not supported; refusing to sign");
220
+ }
221
+ return r.fixed(32);
222
+ }
223
+
224
+ function asciiTrim(bytes) {
225
+ let s = "";
226
+ for (const b of bytes) { if (b === 0) break; s += String.fromCharCode(b); }
227
+ if (!/^[A-Za-z0-9]{1,12}$/.test(s)) {
228
+ throw new Error("asset code is not alphanumeric: " + JSON.stringify(s));
229
+ }
230
+ return s;
231
+ }
232
+
233
+ function readMemo(r) {
234
+ const type = r.u32();
235
+ if (type === MEMO_NONE) return null;
236
+ if (type === MEMO_TEXT) {
237
+ const s = r.varOpaque(28);
238
+ let text = "";
239
+ for (const b of s) {
240
+ if (b < 0x20 || b > 0x7e) throw new Error("memo text is not printable");
241
+ text += String.fromCharCode(b);
242
+ }
243
+ return text;
244
+ }
245
+ if (type === MEMO_ID) return "id:" + r.u64().toString();
246
+ if (type === MEMO_HASH) return "hash:" + hex(r.fixed(32));
247
+ if (type === MEMO_RETURN) return "return:" + hex(r.fixed(32));
248
+ throw new Error("unknown memo type " + type);
249
+ }
250
+
251
+ function readPreconditions(r) {
252
+ const type = r.u32();
253
+ if (type === PRECOND_NONE) return null;
254
+ if (type === PRECOND_TIME) {
255
+ const minTime = r.u64().toString(), maxTime = r.u64().toString();
256
+ return { minTime, maxTime };
257
+ }
258
+ if (type === PRECOND_V2) {
259
+ // timeBounds? ledgerBounds? minSeqNum? minSeqAge minSeqLedgerGap extraSigners[2]
260
+ const hasTime = r.u32();
261
+ const bounds = hasTime
262
+ ? { minTime: r.u64().toString(), maxTime: r.u64().toString() }
263
+ : null;
264
+ const hasLedger = r.u32();
265
+ if (hasLedger) throw new Error("ledger bounds are not supported; refusing to sign");
266
+ const hasSeq = r.u32();
267
+ if (hasSeq) throw new Error("sequence preconditions are not supported; refusing to sign");
268
+ r.u64(); // minSeqAge
269
+ r.u32(); // minSeqLedgerGap
270
+ const n = r.u32();
271
+ if (n !== 0) throw new Error("extra signers are not supported; refusing to sign");
272
+ return bounds;
273
+ }
274
+ throw new Error("unknown precondition type " + type);
275
+ }
276
+
277
+ function readOperation(r) {
278
+ const hasSource = r.u32();
279
+ const source = hasSource ? readMuxedAccount(r) : null;
280
+ const type = r.u32(); // OperationBody union switch
281
+ if (type !== OP_PAYMENT) {
282
+ throw new Error("operation type " + type + " is not a payment; this app only signs payments");
283
+ }
284
+ const destination = readMuxedAccount(r);
285
+ const asset = readAsset(r);
286
+ const amount = r.i64();
287
+ if (amount < 0n) throw new Error("payment amount is negative; refusing to sign");
288
+ return { type, source, destination, asset, amount: amount.toString() };
289
+ }
290
+
291
+ function readTransaction(r) {
292
+ const source = readMuxedAccount(r);
293
+ const fee = r.u32();
294
+ const seq = r.u64();
295
+ const bounds = readPreconditions(r);
296
+ const memo = readMemo(r);
297
+ const numOps = r.u32();
298
+ if (numOps !== 1) {
299
+ throw new Error("this app signs single-payment transactions, and this one has "
300
+ + numOps + " operations");
301
+ }
302
+ const ops = [];
303
+ for (let i = 0; i < numOps; i++) ops.push(readOperation(r));
304
+ // TransactionExt is a union: v0 carries nothing at all.
305
+ const ext = r.u32();
306
+ if (ext !== 0) throw new Error("transaction carries soroban data; refusing to sign");
307
+ return { source, fee, seq, bounds, memo, ops };
308
+ }
309
+
310
+ function readDecorativeSignature(r) {
311
+ const hint = r.fixed(4);
312
+ const sig = r.varOpaque(64);
313
+ return { hint, sig };
314
+ }
315
+
316
+ /** Parse a base64 TransactionEnvelope, returning the tx plus its raw bytes. */
317
+ function parseEnvelope(base64) {
318
+ const bytes = base64ToBytes(base64);
319
+ const r = new Reader(bytes);
320
+ const type = r.u32();
321
+ if (type !== ENVELOPE_TYPE_TX) {
322
+ throw new Error("expected a v1 transaction envelope (type 2), got " + type
323
+ + " — a fee-bump or v0 envelope is not supported");
324
+ }
325
+ const txStart = r.i;
326
+ const tx = readTransaction(r);
327
+ const txEnd = r.i;
328
+ const n = r.u32();
329
+ if (n === 0 || n > 20) throw new Error("envelope has " + n + " signatures; expected 1 or 2");
330
+ const signatures = [];
331
+ for (let i = 0; i < n; i++) signatures.push(readDecorativeSignature(r));
332
+ if (r.i !== bytes.length) {
333
+ throw new Error("trailing bytes after the envelope (" + (bytes.length - r.i) + ")");
334
+ }
335
+ return { tx, signatures, txBytes: bytes.slice(txStart, txEnd), bytes };
336
+ }
337
+
338
+ // ------------------------------------------------------------------ hashing
339
+ /**
340
+ * The hash a Stellar signature covers:
341
+ * sha256( sha256(passphrase) || ENVELOPE_TYPE_TX(4) || transactionBytes )
342
+ *
343
+ * The leading 32 bytes are the hash of the network passphrase, not the
344
+ * passphrase itself. Recomputing this on the phone is what stops a swapped
345
+ * transaction: the human is shown this value, and the bytes actually signed
346
+ * have to produce it.
347
+ */
348
+ async function transactionHash(base64, passphraseStr) {
349
+ const env = parseEnvelope(base64);
350
+ const networkId = new Uint8Array(await crypto.subtle.digest(
351
+ "SHA-256", new TextEncoder().encode(passphraseStr)));
352
+ const buf = new Uint8Array(32 + 4 + env.txBytes.length);
353
+ buf.set(networkId, 0);
354
+ buf[32] = 0; buf[33] = 0; buf[34] = 0; buf[35] = ENVELOPE_TYPE_TX;
355
+ buf.set(env.txBytes, 36);
356
+ const digest = await crypto.subtle.digest("SHA-256", buf);
357
+ return { hash: hex(new Uint8Array(digest)), env };
358
+ }
359
+
360
+ // ------------------------------------------------------------------ signing
361
+ /**
362
+ * The bytes an approver signature must cover: the transaction hash itself,
363
+ * as 32 raw bytes. NOT sha256 of the hex text the human reads. Signing the
364
+ * wrong preimage produces a well-formed envelope the ledger refuses with
365
+ * BAD_AUTH, so this is exported rather than left to each caller to guess.
366
+ */
367
+ function signaturePreimage(hashHex) { return hexToBytes(hashHex); }
368
+
369
+ /** Append one ed25519 signature to the envelope. Returns new base64 XDR. */
370
+ function addSignature(base64, signatureBytes, pubKeyBytes) {
371
+ if (signatureBytes.length !== 64) throw new Error("signature must be 64 bytes");
372
+ if (pubKeyBytes.length !== 32) throw new Error("public key must be 32 bytes");
373
+ const env = parseEnvelope(base64);
374
+ if (env.signatures.length >= 2) {
375
+ throw new Error("envelope already has " + env.signatures.length + " signatures");
376
+ }
377
+ const w = new Writer();
378
+ w.u32(ENVELOPE_TYPE_TX);
379
+ w.fixed(env.txBytes);
380
+ const sigs = env.signatures.concat([{
381
+ hint: pubKeyBytes.slice(28), sig: signatureBytes,
382
+ }]);
383
+ w.u32(sigs.length);
384
+ for (const s of sigs) { w.fixed(s.hint); w.varOpaque(s.sig); }
385
+ return bytesToBase64(w.bytes());
386
+ }
387
+
388
+ // ------------------------------------------------------------------ amounts
389
+ /** int64 stroops -> "4.5000000", the way the ledger prints it. */
390
+ function formatAmount(stroopAmount) {
391
+ const v = typeof stroopAmount === "bigint" ? stroopAmount : BigInt(stroopAmount);
392
+ if (v < 0n) throw new Error("negative payment amount");
393
+ const whole = v / BigInt(STROOPS);
394
+ const frac = v % BigInt(STROOPS);
395
+ return whole.toString() + "." + frac.toString().padStart(7, "0");
396
+ }
397
+
398
+ // ------------------------------------------------------------------ base64
399
+ function base64ToBytes(s) {
400
+ const bin = atob(s);
401
+ const out = new Uint8Array(bin.length);
402
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
403
+ return out;
404
+ }
405
+ function bytesToBase64(bytes) {
406
+ let bin = "";
407
+ for (const b of bytes) bin += String.fromCharCode(b);
408
+ return btoa(bin);
409
+ }
410
+ function hexToBytes(s) {
411
+ if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2) throw new Error("not a hex string");
412
+ const out = new Uint8Array(s.length / 2);
413
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
414
+ return out;
415
+ }
416
+
417
+ function hex(bytes) {
418
+ let s = "";
419
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
420
+ return s;
421
+ }
422
+
423
+ const NETWORK_ID = {
424
+ testnet: "Test SDF Network ; September 2015",
425
+ public: "Public Global Stellar Network ; September 2015",
426
+ };
427
+
428
+ root.PaydluxXdr = {
429
+ parseEnvelope, transactionHash, addSignature, formatAmount,
430
+ hex, hexToBytes, signaturePreimage,
431
+ encodeAccountEd25519, decodeAccountEd25519,
432
+ decodeSecretSeed, encodeSecretSeed, importSecretSeed,
433
+ NETWORK_ID, crc16Xmodem,
434
+ ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
435
+ };
436
+ })(typeof globalThis !== "undefined" ? globalThis : this);