@paydlux/agent 0.1.3 → 0.1.4
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 +24 -12
- package/dist/desk.js +86 -0
- package/dist/gateway.d.ts +8 -0
- package/dist/gateway.js +1 -0
- package/dist/portal.js +10 -2
- package/package.json +5 -1
- package/phone-app/app.js +253 -0
- package/phone-app/index.html +148 -0
- package/phone-app/xdr.js +436 -0
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
|
|
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,8 +43,8 @@ stellar/onboard.ts keys → funded vault → printed agent key
|
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
45
|
npm i
|
|
45
|
-
npx tsx stellar/demo.ts
|
|
46
|
-
npx tsx stellar/story.ts --public
|
|
46
|
+
npx tsx stellar/demo.ts # 17 checks, live testnet
|
|
47
|
+
npx tsx stellar/story.ts --public --split # unguessable link; approve on your phone
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
No wallet, no faucet, no config.
|
|
@@ -54,17 +55,28 @@ The vault needs two signatures. If both keys sit in one process, that is really
|
|
|
54
55
|
one key. So the approval half is split off:
|
|
55
56
|
|
|
56
57
|
```bash
|
|
57
|
-
npx tsx stellar/phone-check.ts
|
|
58
|
-
npx tsx stellar/e2e-phone.ts
|
|
58
|
+
npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
|
|
59
|
+
npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
|
|
60
|
+
npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
|
|
61
|
+
npx tsx phone-app/desk-app-check.ts # 28 checks: the real app over real HTTP, end to end
|
|
59
62
|
```
|
|
60
63
|
|
|
61
|
-
The seller builds the payment and signs it with their own half. The human
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
The seller builds the payment and signs it with their own half. The human opens
|
|
65
|
+
one link — `/phone/?id=...` — and the approver app in `phone-app/` shows them the
|
|
66
|
+
amount, the token, the payee and the deadline, then signs on the device.
|
|
67
|
+
|
|
68
|
+
The app is three static files with no dependencies and no build step. It carries
|
|
69
|
+
its own Stellar transaction decoder (`phone-app/xdr.js`) rather than the SDK, and
|
|
70
|
+
refuses any transaction shape it does not fully understand: path payments,
|
|
71
|
+
account merges, signer changes and multi-operation transactions are rejected
|
|
72
|
+
outright rather than guessed at. The key is imported into WebCrypto as
|
|
73
|
+
**non-extractable** and only the `CryptoKey` object itself is persisted, so the
|
|
74
|
+
seed is never in localStorage, IndexedDB, or a file. The page is served with
|
|
75
|
+
`connect-src 'self'`, so an injected script cannot phone the key home.
|
|
76
|
+
|
|
77
|
+
The approver seed never travels to the seller's machine, and the signature is
|
|
78
|
+
over the transaction hash — change the amount or the payee and it stops counting.
|
|
79
|
+
`tools/phone-sign.ts` is the same flow for a terminal instead of a browser.
|
|
68
80
|
|
|
69
81
|
Use it as a package instead of cloning:
|
|
70
82
|
|
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"); }
|
|
@@ -227,6 +253,66 @@ export class ApprovalDesk {
|
|
|
227
253
|
}
|
|
228
254
|
if (url.pathname === "/list")
|
|
229
255
|
return send(200, this.list());
|
|
256
|
+
// The approver app. Served from the desk so the human opens one link on
|
|
257
|
+
// their phone instead of pasting XDR around. The app itself makes no
|
|
258
|
+
// requests after loading, and the portalKey gate above already covers
|
|
259
|
+
// these paths: without the key, nothing here is reachable.
|
|
260
|
+
// The app uses relative script srcs, so it must be served from a
|
|
261
|
+
// directory URL. /phone without the slash would resolve app.js to
|
|
262
|
+
// /app.js, so send it to the canonical path with the key intact.
|
|
263
|
+
if (url.pathname === "/phone") {
|
|
264
|
+
res.writeHead(302, { location: "/phone/" + url.search });
|
|
265
|
+
res.end();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
269
|
+
res.writeHead(200, {
|
|
270
|
+
"content-type": "text/html; charset=utf-8",
|
|
271
|
+
// The key lives in IndexedDB behind a non-extractable CryptoKey;
|
|
272
|
+
// an injected script is the realistic threat, so lock the origin.
|
|
273
|
+
// No inline script on the page: app.js and xdr.js are separate
|
|
274
|
+
// files, so script-src 'self' actually holds. connect-src 'self' is
|
|
275
|
+
// what stops the app talking anywhere but the desk that served it.
|
|
276
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
277
|
+
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
278
|
+
"form-action 'none'; frame-ancestors 'none'",
|
|
279
|
+
"referrer-policy": "no-referrer",
|
|
280
|
+
"x-content-type-options": "nosniff",
|
|
281
|
+
});
|
|
282
|
+
res.end(readAppFile("index.html"));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (url.pathname.startsWith("/phone/")) {
|
|
286
|
+
const file = url.pathname.slice("/phone/".length);
|
|
287
|
+
if (!/^[a-z]+\.(js|css)$/.test(file))
|
|
288
|
+
return send(404, { error: "not found" });
|
|
289
|
+
res.writeHead(200, {
|
|
290
|
+
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
291
|
+
"cache-control": "no-store",
|
|
292
|
+
"x-content-type-options": "nosniff",
|
|
293
|
+
});
|
|
294
|
+
res.end(readAppFile(file));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// The app fetches the pending transaction by handle rather than asking
|
|
298
|
+
// the human to paste 400 characters of base64 on a phone.
|
|
299
|
+
const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
|
|
300
|
+
if (ch) {
|
|
301
|
+
const rec = this.records.get(ch[1]);
|
|
302
|
+
if (!rec)
|
|
303
|
+
return send(404, { error: "unknown handle" });
|
|
304
|
+
// Opening the phone link IS the approval gesture, so build the
|
|
305
|
+
// challenge here if nobody pressed the button yet. The human should
|
|
306
|
+
// need one link, not a button on a desktop plus a link on a phone.
|
|
307
|
+
if (rec.status === "pending")
|
|
308
|
+
await this.approve(rec.id);
|
|
309
|
+
if (!rec.challenge)
|
|
310
|
+
return send(409, { error: `not awaiting a phone signature (it is ${rec.status})` });
|
|
311
|
+
return send(200, {
|
|
312
|
+
id: rec.id, network: rec.challenge.network,
|
|
313
|
+
hash: rec.challenge.hash, xdr: rec.challenge.xdr,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
230
316
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
|
231
317
|
if (m && req.method === "POST") {
|
|
232
318
|
if (m[1] === "signed") {
|
package/dist/gateway.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ 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;
|
package/dist/gateway.js
CHANGED
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
|
+
"version": "0.1.4",
|
|
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
|
],
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
"story:stellar": "tsx stellar/story.ts",
|
|
29
32
|
"test": "cd contracts && forge test",
|
|
30
33
|
"test:stellar": "tsx stellar/demo.ts",
|
|
34
|
+
"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
35
|
"typecheck": "tsc --noEmit"
|
|
32
36
|
},
|
|
33
37
|
"keywords": [
|
package/phone-app/app.js
ADDED
|
@@ -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…)</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 — a changed amount or payee produces a different hash.
|
|
130
|
+
</div>
|
|
131
|
+
<button id="approve">Approve & 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 — 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>
|
package/phone-app/xdr.js
ADDED
|
@@ -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);
|