@paydlux/agent 0.1.2 → 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 +33 -3
- package/dist/desk.d.ts +35 -2
- package/dist/desk.js +240 -2
- package/dist/gateway.d.ts +8 -0
- package/dist/gateway.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/phone.d.ts +65 -0
- package/dist/phone.js +163 -0
- package/dist/portal.js +22 -5
- package/dist/vault.d.ts +2 -1
- package/dist/vault.js +28 -1
- 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/dist/phone.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Paydlux — the approval half that lives on a phone.
|
|
2
|
+
//
|
|
3
|
+
// The problem this solves: until now the human's key was a string in the
|
|
4
|
+
// seller's own process. That makes "2 of 2" a lie — one machine held both
|
|
5
|
+
// halves, so whoever owned that machine was the human.
|
|
6
|
+
//
|
|
7
|
+
// Split it the way banks and hardware wallets do:
|
|
8
|
+
//
|
|
9
|
+
// laptop (seller) builds the payment, signs with the principal key,
|
|
10
|
+
// sends the UNSIGNED transaction out
|
|
11
|
+
// phone (human) decodes it, shows a person what it says, signs with the
|
|
12
|
+
// approver key, sends the signature back
|
|
13
|
+
// laptop (seller) submits the now-complete transaction
|
|
14
|
+
//
|
|
15
|
+
// The approver seed never travels to the laptop. The phone needs no network to
|
|
16
|
+
// sign — it only needs the transaction, and it can refuse.
|
|
17
|
+
//
|
|
18
|
+
// The signature is over the transaction hash, so the phone is signing THIS
|
|
19
|
+
// payment: change the amount or the payee and the signature stops counting.
|
|
20
|
+
import { Asset, Keypair, Memo, Operation, StrKey, TransactionBuilder, } from "@stellar/stellar-sdk";
|
|
21
|
+
import { passphrase, StellarTxError, txResultName } from "./vault.js";
|
|
22
|
+
/**
|
|
23
|
+
* Build the payment and sign it with the principal key only. The result is
|
|
24
|
+
* deliberately incomplete: it reaches the vault's threshold of 2 only once the
|
|
25
|
+
* approver's signature is added.
|
|
26
|
+
*/
|
|
27
|
+
export async function createApprovalChallenge(opts) {
|
|
28
|
+
const pass = passphrase(opts.network);
|
|
29
|
+
const acc = await opts.horizon.loadAccount(opts.vaultAddress);
|
|
30
|
+
const asset = opts.tokenCode === "XLM" ? Asset.native() : new Asset(opts.tokenCode, opts.tokenIssuer);
|
|
31
|
+
const built = new TransactionBuilder(acc, { fee: "100", networkPassphrase: pass })
|
|
32
|
+
.addOperation(Operation.payment({ destination: opts.payee, asset, amount: opts.amount }))
|
|
33
|
+
.setTimeout(opts.decideWithinSeconds ?? 1800);
|
|
34
|
+
if (opts.memo)
|
|
35
|
+
built.addMemo(Memo.text(opts.memo));
|
|
36
|
+
const tx = built.build();
|
|
37
|
+
tx.sign(opts.principal);
|
|
38
|
+
return {
|
|
39
|
+
id: opts.id,
|
|
40
|
+
network: opts.network,
|
|
41
|
+
xdr: tx.toXDR(),
|
|
42
|
+
summary: {
|
|
43
|
+
payee: opts.payee,
|
|
44
|
+
amount: opts.amount,
|
|
45
|
+
token: opts.tokenCode,
|
|
46
|
+
tokenIssuer: opts.tokenIssuer,
|
|
47
|
+
from: opts.vaultAddress,
|
|
48
|
+
memo: opts.memo,
|
|
49
|
+
expiresAt: tx.timeBounds ? Number(tx.timeBounds.maxTime) || undefined : undefined,
|
|
50
|
+
},
|
|
51
|
+
hash: Buffer.from(tx.hash()).toString("hex"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What a person (or a phone app) does with a challenge: read it, decide, sign.
|
|
56
|
+
*
|
|
57
|
+
* This function does not talk to the network. It cannot submit anything. It
|
|
58
|
+
* cannot change the amount. It either adds a valid signature or returns the
|
|
59
|
+
* challenge untouched. That is the whole point of running it on the phone.
|
|
60
|
+
*/
|
|
61
|
+
export function signChallenge(challenge, approverSeed) {
|
|
62
|
+
const pass = passphrase(challenge.network);
|
|
63
|
+
const tx = TransactionBuilder.fromXDR(challenge.xdr, pass);
|
|
64
|
+
const hash = Buffer.from(tx.hash()).toString("hex");
|
|
65
|
+
if (hash !== challenge.hash) {
|
|
66
|
+
throw new Error("challenge was altered in transit — hash does not match, refusing to sign");
|
|
67
|
+
}
|
|
68
|
+
tx.sign(Keypair.fromSecret(approverSeed));
|
|
69
|
+
return tx.toXDR();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read a challenge without any secrets, to show a human. Deliberately separate
|
|
73
|
+
* from signing so a UI can render it on an untrusted machine.
|
|
74
|
+
*/
|
|
75
|
+
export function describeChallenge(challenge) {
|
|
76
|
+
const s = challenge.summary;
|
|
77
|
+
return [
|
|
78
|
+
`pay ${s.amount} ${s.token}`,
|
|
79
|
+
`to ${s.payee}`,
|
|
80
|
+
`from ${s.from}`,
|
|
81
|
+
`issuer ${s.tokenIssuer}`,
|
|
82
|
+
s.memo ? `note ${readableMemo(s.memo)}` : null,
|
|
83
|
+
`network ${challenge.network}`,
|
|
84
|
+
`signs ${challenge.hash.slice(0, 16)}…`,
|
|
85
|
+
].filter(Boolean).join("\n");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The seller's side: take the phone's signature back and submit. Fails loudly
|
|
89
|
+
* if the transaction is still short of the vault's threshold.
|
|
90
|
+
*/
|
|
91
|
+
export async function submitApproved(signedXdr, network, horizon) {
|
|
92
|
+
const pass = passphrase(network);
|
|
93
|
+
const tx = TransactionBuilder.fromXDR(signedXdr, pass);
|
|
94
|
+
if (!tx.signatures.length)
|
|
95
|
+
throw new Error("no signatures on the returned transaction");
|
|
96
|
+
try {
|
|
97
|
+
await horizon.submitTransaction(tx);
|
|
98
|
+
return { hash: Buffer.from(tx.hash()).toString("hex"), ok: true };
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// Horizon answers 400 with the reason buried in the result codes. Surface
|
|
102
|
+
// the ledger's own word (BAD_AUTH, OP_INNER, ...) instead of "400".
|
|
103
|
+
let codes;
|
|
104
|
+
try {
|
|
105
|
+
codes = e?.resultResultCodes?.()?.results;
|
|
106
|
+
}
|
|
107
|
+
catch { /* shape differs */ }
|
|
108
|
+
codes ??= e?.response?.data?.extras?.result_codes?.results;
|
|
109
|
+
const resultXdr = e?.response?.data?.extras?.result_xdr;
|
|
110
|
+
const detail = codes?.join(",")
|
|
111
|
+
|| (resultXdr ? txResultName(resultXdr) : undefined)
|
|
112
|
+
|| resultXdr
|
|
113
|
+
|| e.message;
|
|
114
|
+
throw new StellarTxError(detail, e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A memo can arrive as text or as raw bytes depending on how the transaction was
|
|
119
|
+
* decoded. Show a human words, never a byte list.
|
|
120
|
+
*/
|
|
121
|
+
function readableMemo(memo) {
|
|
122
|
+
if (typeof memo === "string")
|
|
123
|
+
return memo;
|
|
124
|
+
if (Array.isArray(memo) || memo instanceof Uint8Array) {
|
|
125
|
+
try {
|
|
126
|
+
return Buffer.from(memo).toString("utf8");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return String(memo);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return String(memo ?? "");
|
|
133
|
+
}
|
|
134
|
+
/** A stable, scannable form of a challenge — what a phone would read from a QR code. */
|
|
135
|
+
export function challengeQrPayload(challenge) {
|
|
136
|
+
return `paydlux:1\n${challenge.id}\n${challenge.network}\n${challenge.hash}\n${challenge.xdr}`;
|
|
137
|
+
}
|
|
138
|
+
/** Parse what a QR code carried. Rejects a payload whose hash disagrees. */
|
|
139
|
+
export function parseChallengeQr(payload) {
|
|
140
|
+
const [tag, id, network, hash, xdr] = payload.split("\n");
|
|
141
|
+
if (tag !== "paydlux:1")
|
|
142
|
+
throw new Error("not a paydlux approval payload");
|
|
143
|
+
if (network !== "testnet" && network !== "public")
|
|
144
|
+
throw new Error("unknown network");
|
|
145
|
+
const tx = TransactionBuilder.fromXDR(xdr, passphrase(network));
|
|
146
|
+
const actual = Buffer.from(tx.hash()).toString("hex");
|
|
147
|
+
if (actual !== hash)
|
|
148
|
+
throw new Error("payload was tampered with: hash mismatch");
|
|
149
|
+
const op = tx.operations[0];
|
|
150
|
+
const asset = op.asset;
|
|
151
|
+
return {
|
|
152
|
+
id, network: network, xdr, hash,
|
|
153
|
+
summary: {
|
|
154
|
+
payee: op.destination,
|
|
155
|
+
amount: op.amount,
|
|
156
|
+
token: asset?.getCode ? asset.getCode() : "XLM",
|
|
157
|
+
tokenIssuer: asset?.getIssuer ? (asset.getIssuer() ?? "native") : "native",
|
|
158
|
+
from: tx.source,
|
|
159
|
+
memo: readableMemo(tx.memo?.value),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export { StrKey };
|
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">
|
|
@@ -23,6 +23,9 @@ export function portalPage(records, vault, network, qs = "") {
|
|
|
23
23
|
.st{font-size:12px;text-transform:uppercase;letter-spacing:.06em}
|
|
24
24
|
.settled{color:var(--green)} .denied,.failed{color:#e07a6b} .pending{color:var(--gold)}
|
|
25
25
|
.tx{font-family:ui-monospace,monospace;font-size:11px;color:var(--muted);word-break:break-all}
|
|
26
|
+
.qr{font-size:10px;white-space:pre-wrap;background:#141715;padding:8px;border-radius:8px;color:var(--text)}
|
|
27
|
+
details summary{cursor:pointer}
|
|
28
|
+
.phlink{color:var(--gold);text-decoration:underline;font-size:12px}
|
|
26
29
|
.foot{margin-top:28px;color:var(--muted);font-size:12px}
|
|
27
30
|
@media(max-width:640px){.body{max-width:180px}}
|
|
28
31
|
</style></head><body>
|
|
@@ -38,17 +41,31 @@ export function portalPage(records, vault, network, qs = "") {
|
|
|
38
41
|
document.querySelector('tbody').innerHTML=d.map(row).join('')||'<tr><td colspan="5" class="empty">nothing waiting</td></tr>';
|
|
39
42
|
document.getElementById('t').textContent=new Date().toLocaleTimeString()}
|
|
40
43
|
function row(x){const st='<span class="st '+x.status+'">'+x.status+'</span>'+(x.tx?'<div class="tx">'+x.tx+'</div>':'')+(x.reason?'<div class="tx">'+x.reason+'</div>':'');
|
|
41
|
-
const btns=x.status==='pending'?'<button class="yes" onclick="act(
|
|
42
|
-
|
|
44
|
+
const btns=x.status==='pending'?'<button class="yes" onclick="act(\'approve\',\''+x.id+'\')">Approve</button><button class="no" onclick="act(\'deny\',\''+x.id+'\')">Deny</button>':'';
|
|
45
|
+
const ph=x.status==='awaiting_phone'?'<div class="tx">awaiting your phone</div>':'';
|
|
46
|
+
return '<tr><td>'+new Date(x.at).toLocaleTimeString()+'</td><td><div class="uri">'+x.uri+'</div><div class="body">'+(x.body||'')+'</div></td><td class="amt">'+x.price+'</td><td>'+st+ph+'</td><td style="min-width:130px">'+btns+'</td></tr>'}
|
|
43
47
|
setInterval(load,3000);
|
|
44
48
|
</script></body></html>`;
|
|
45
49
|
}
|
|
46
|
-
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("?", "&")}`;
|
|
47
54
|
const btns = r.status === "pending"
|
|
48
55
|
? `<button class="yes" onclick="act('approve','${r.id}')">Approve</button>
|
|
49
56
|
<button class="no" onclick="act('deny','${r.id}')">Deny</button>`
|
|
50
57
|
: "";
|
|
51
|
-
|
|
58
|
+
// Split mode: the desk has no approver key, so approving hands the payment to
|
|
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.
|
|
63
|
+
const phone = r.status === "awaiting_phone" && r.challenge
|
|
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>
|
|
66
|
+
<details class="tx"><summary>approval payload for the phone</summary><pre class="qr">${r.qr}</pre></details>`
|
|
67
|
+
: "";
|
|
68
|
+
const meta = [phone, r.tx && `<div class="tx">${r.tx}</div>`, r.reason && `<div class="tx">${r.reason}</div>`].filter(Boolean).join("");
|
|
52
69
|
return `<tr>
|
|
53
70
|
<td>${new Date(r.at).toLocaleTimeString()}</td>
|
|
54
71
|
<td><div class="uri">${r.uri}</div><div class="body">${r.body || ""}</div></td>
|
package/dist/vault.d.ts
CHANGED
|
@@ -8,7 +8,8 @@ export declare function hex(b: Uint8Array | string): string;
|
|
|
8
8
|
* only these two accounts are real money. Never accept any other issuer.
|
|
9
9
|
*/
|
|
10
10
|
export declare const USDC_ISSUER: Record<Network, string>;
|
|
11
|
-
/**
|
|
11
|
+
/** Turn Horizon's result_xdr into a readable code, e.g. "BAD_AUTH". */
|
|
12
|
+
export declare function txResultName(resultXdr: string): string | undefined;
|
|
12
13
|
export declare class StellarTxError extends Error {
|
|
13
14
|
code: string;
|
|
14
15
|
constructor(code: string, cause?: unknown);
|
package/dist/vault.js
CHANGED
|
@@ -29,6 +29,31 @@ export const USDC_ISSUER = {
|
|
|
29
29
|
testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
|
|
30
30
|
};
|
|
31
31
|
/** A rejected Stellar transaction, with the ledger's own reason code. */
|
|
32
|
+
// Horizon hands back the transaction result as XDR:
|
|
33
|
+
// struct TransactionResult { int64 feeCharged; TransactionResultCode resultCode; ... }
|
|
34
|
+
// so the code is the signed int32 at byte offset 8, not the first 8 bytes (that
|
|
35
|
+
// is the fee). -6 = txBAD_AUTH = "not enough signatures", which is exactly what
|
|
36
|
+
// a half-signed vault payment looks like.
|
|
37
|
+
const TX_RESULT_CODES = {
|
|
38
|
+
0: "SUCCESS", [-1]: "FAILED", [-2]: "TOO_EARLY", [-3]: "TOO_LATE",
|
|
39
|
+
[-4]: "MISSING_OPERATION", [-5]: "BAD_SEQ", [-6]: "BAD_AUTH",
|
|
40
|
+
[-7]: "INSUFFICIENT_BALANCE", [-8]: "NO_ACCOUNT", [-9]: "INSUFFICIENT_FEE",
|
|
41
|
+
[-10]: "BAD_AUTH_EXTRA", [-11]: "INTERNAL_ERROR", [-12]: "NOT_SUPPORTED",
|
|
42
|
+
[-13]: "ACCOUNT_EXISTS", [-14]: "INSUFFICIENT_REWARDS",
|
|
43
|
+
};
|
|
44
|
+
/** Turn Horizon's result_xdr into a readable code, e.g. "BAD_AUTH". */
|
|
45
|
+
export function txResultName(resultXdr) {
|
|
46
|
+
try {
|
|
47
|
+
const b = Buffer.from(resultXdr, "base64");
|
|
48
|
+
if (b.length < 12)
|
|
49
|
+
return undefined;
|
|
50
|
+
const n = b.readInt32BE(8);
|
|
51
|
+
return TX_RESULT_CODES[n] ?? `code ${n}`;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
32
57
|
export class StellarTxError extends Error {
|
|
33
58
|
code;
|
|
34
59
|
constructor(code, cause) {
|
|
@@ -293,7 +318,9 @@ async function sub(h, _pass, tx, ...signers) {
|
|
|
293
318
|
}
|
|
294
319
|
catch { /* shape differs */ }
|
|
295
320
|
codes ??= e?.response?.data?.extras?.result_codes?.results;
|
|
296
|
-
const
|
|
321
|
+
const resultXdr = e?.response?.data?.extras?.result_xdr;
|
|
322
|
+
const named = resultXdr ? txResultName(resultXdr) : undefined;
|
|
323
|
+
const detail = codes?.join(",") || named || resultXdr || e?.message || String(e);
|
|
297
324
|
throw new StellarTxError(detail, e);
|
|
298
325
|
}
|
|
299
326
|
}
|
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
|
+
})();
|