@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/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,12 +43,41 @@ 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.
|
|
50
51
|
|
|
52
|
+
### The human key does not live on the seller's machine
|
|
53
|
+
|
|
54
|
+
The vault needs two signatures. If both keys sit in one process, that is really
|
|
55
|
+
one key. So the approval half is split off:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
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
|
|
62
|
+
```
|
|
63
|
+
|
|
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.
|
|
80
|
+
|
|
51
81
|
Use it as a package instead of cloning:
|
|
52
82
|
|
|
53
83
|
```bash
|
package/dist/desk.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import type { StellarVault } from "./vault.ts";
|
|
3
|
+
import { type ApprovalChallenge } from "./phone.ts";
|
|
3
4
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
4
5
|
* random, and compared without leaking timing. */
|
|
5
6
|
export declare function newPortalKey(): string;
|
|
@@ -9,10 +10,14 @@ export type DeskRecord = {
|
|
|
9
10
|
uri: string;
|
|
10
11
|
price: string;
|
|
11
12
|
body: string;
|
|
12
|
-
status: "pending" | "approved" | "denied" | "settled" | "failed";
|
|
13
|
+
status: "pending" | "awaiting_phone" | "approved" | "denied" | "settled" | "failed";
|
|
13
14
|
content?: string;
|
|
14
15
|
tx?: string;
|
|
15
16
|
reason?: string;
|
|
17
|
+
/** split mode: what the phone is asked to sign, and what it sent back */
|
|
18
|
+
challenge?: ApprovalChallenge;
|
|
19
|
+
qr?: string;
|
|
20
|
+
signedXdr?: string;
|
|
16
21
|
};
|
|
17
22
|
export type DeskStore = DeskRecord;
|
|
18
23
|
export type DeskConfig = {
|
|
@@ -21,6 +26,14 @@ export type DeskConfig = {
|
|
|
21
26
|
/** seeds of the two human keys. DEV ONLY — in production these live on the phone. */
|
|
22
27
|
principal?: string;
|
|
23
28
|
approver?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Split signing: the desk holds only the agent/principal half. Approving
|
|
31
|
+
* produces a challenge the phone signs offline; the signature comes back and
|
|
32
|
+
* the desk submits it. The approver seed never touches this machine.
|
|
33
|
+
*/
|
|
34
|
+
split?: boolean;
|
|
35
|
+
/** how long a human has to decide before the payment is dead */
|
|
36
|
+
decideWithinSeconds?: number;
|
|
24
37
|
port: number;
|
|
25
38
|
/** If set, the portal and its actions require this key in the URL.
|
|
26
39
|
* Required before putting the desk on any public URL. */
|
|
@@ -29,15 +42,35 @@ export type DeskConfig = {
|
|
|
29
42
|
export declare class ApprovalDesk {
|
|
30
43
|
private records;
|
|
31
44
|
private seq;
|
|
45
|
+
private server?;
|
|
32
46
|
cfg: DeskConfig;
|
|
33
47
|
constructor(cfg: DeskConfig);
|
|
34
48
|
enqueue(uri: string, price: string, body: string, content: string): string;
|
|
35
49
|
get(id: string): DeskRecord | null;
|
|
36
50
|
list(): DeskRecord[];
|
|
37
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* The human clicked Approve.
|
|
53
|
+
*
|
|
54
|
+
* dev mode (approver seed present): sign and submit here. One machine holds
|
|
55
|
+
* both keys, which is fine for a testnet demo and nothing else.
|
|
56
|
+
* split mode (no approver seed): build the challenge, sign it with the
|
|
57
|
+
* principal only, and hand it to the phone. The money moves when
|
|
58
|
+
* /signed comes back with the phone's signature.
|
|
59
|
+
*/
|
|
38
60
|
approve(id: string): Promise<DeskRecord>;
|
|
61
|
+
/** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
|
|
62
|
+
private requestPhoneSignature;
|
|
63
|
+
/**
|
|
64
|
+
* Split mode step 2: the phone answered. `signedXdr` is the transaction with
|
|
65
|
+
* the approver signature added, returned by signChallenge() on the phone.
|
|
66
|
+
* The desk cannot forge this: it does not hold the approver key, and the
|
|
67
|
+
* ledger rejects it if the signature is not over this exact transaction.
|
|
68
|
+
*/
|
|
69
|
+
complete(id: string, signedXdr: string): Promise<DeskRecord>;
|
|
39
70
|
deny(id: string, reason?: string): DeskRecord;
|
|
40
71
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
41
72
|
api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
|
|
42
73
|
listen(port?: number): Promise<void>;
|
|
74
|
+
/** Stop serving. Without this the process never exits. */
|
|
75
|
+
close(): Promise<void>;
|
|
43
76
|
}
|
package/dist/desk.js
CHANGED
|
@@ -10,12 +10,93 @@
|
|
|
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";
|
|
18
|
+
import { TransactionBuilder } from "@stellar/stellar-sdk";
|
|
19
|
+
import { passphrase } from "./vault.js";
|
|
15
20
|
import { portalPage } from "./portal.js";
|
|
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
|
+
}
|
|
16
45
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
17
46
|
* random, and compared without leaking timing. */
|
|
18
47
|
export function newPortalKey() { return randomBytes(16).toString("base64url"); }
|
|
48
|
+
/**
|
|
49
|
+
* Check that a signature the phone sent back belongs to THIS payment.
|
|
50
|
+
* Returns a reason string if it does not, or null if it is fine.
|
|
51
|
+
*/
|
|
52
|
+
function verifyAgainstChallenge(signedXdr, rec) {
|
|
53
|
+
const ch = rec.challenge;
|
|
54
|
+
let signed;
|
|
55
|
+
try {
|
|
56
|
+
signed = TransactionBuilder.fromXDR(signedXdr, passphrase(recNetwork(rec)));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "the phone returned something that is not a valid transaction for this network";
|
|
60
|
+
}
|
|
61
|
+
// 1. same transaction the human was shown: same hash, so the amount, payee,
|
|
62
|
+
// source account and sequence are all identical to what was approved.
|
|
63
|
+
const hash = Buffer.from(signed.hash()).toString("hex");
|
|
64
|
+
if (hash !== ch.hash) {
|
|
65
|
+
return `signed a different transaction (${hash.slice(0, 12)}… was not issued for ${rec.id}, which is ${ch.hash.slice(0, 12)}…)`;
|
|
66
|
+
}
|
|
67
|
+
// 2. the phone added exactly one signature; nothing else changed.
|
|
68
|
+
if (signed.signatures.length < 2) {
|
|
69
|
+
return `only ${signed.signatures.length} signature(s); the vault needs 2`;
|
|
70
|
+
}
|
|
71
|
+
// 3. belt and braces: the payment still says what the record says.
|
|
72
|
+
const op = signed.operations?.[0];
|
|
73
|
+
if (!op || op.type !== "payment")
|
|
74
|
+
return "the transaction is not a payment";
|
|
75
|
+
if (op.destination !== rec.challenge.summary.payee)
|
|
76
|
+
return "payee does not match the approval";
|
|
77
|
+
const want = rec.challenge.summary.amount;
|
|
78
|
+
if (op.amount !== want && Number(op.amount) !== Number(want)) {
|
|
79
|
+
return `amount ${op.amount} does not match the approved ${want}`;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function recNetwork(rec) {
|
|
84
|
+
return rec.challenge.network;
|
|
85
|
+
}
|
|
86
|
+
function readBody(req) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let data = "";
|
|
89
|
+
req.on("data", (c) => {
|
|
90
|
+
data += c;
|
|
91
|
+
if (data.length > 64_000) {
|
|
92
|
+
reject(new Error("body too large"));
|
|
93
|
+
req.destroy();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
req.on("end", () => resolve(data));
|
|
97
|
+
req.on("error", reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
19
100
|
function same(a, b) {
|
|
20
101
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
21
102
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
@@ -23,6 +104,7 @@ function same(a, b) {
|
|
|
23
104
|
export class ApprovalDesk {
|
|
24
105
|
records = new Map();
|
|
25
106
|
seq = 0;
|
|
107
|
+
server;
|
|
26
108
|
cfg;
|
|
27
109
|
constructor(cfg) { this.cfg = cfg; }
|
|
28
110
|
enqueue(uri, price, body, content) {
|
|
@@ -33,13 +115,23 @@ export class ApprovalDesk {
|
|
|
33
115
|
}
|
|
34
116
|
get(id) { return this.records.get(id) ?? null; }
|
|
35
117
|
list() { return [...this.records.values()]; }
|
|
36
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* The human clicked Approve.
|
|
120
|
+
*
|
|
121
|
+
* dev mode (approver seed present): sign and submit here. One machine holds
|
|
122
|
+
* both keys, which is fine for a testnet demo and nothing else.
|
|
123
|
+
* split mode (no approver seed): build the challenge, sign it with the
|
|
124
|
+
* principal only, and hand it to the phone. The money moves when
|
|
125
|
+
* /signed comes back with the phone's signature.
|
|
126
|
+
*/
|
|
37
127
|
async approve(id) {
|
|
38
128
|
const rec = this.records.get(id);
|
|
39
129
|
if (!rec)
|
|
40
130
|
throw new Error("unknown handle " + id);
|
|
41
131
|
if (rec.status !== "pending")
|
|
42
132
|
throw new Error(`already ${rec.status}`);
|
|
133
|
+
if (this.cfg.split)
|
|
134
|
+
return this.requestPhoneSignature(rec);
|
|
43
135
|
if (!this.cfg.principal || !this.cfg.approver) {
|
|
44
136
|
rec.status = "failed";
|
|
45
137
|
rec.reason = "no human keys configured (portal signing not wired yet)";
|
|
@@ -56,6 +148,75 @@ export class ApprovalDesk {
|
|
|
56
148
|
}
|
|
57
149
|
return rec;
|
|
58
150
|
}
|
|
151
|
+
/** Split mode step 1: ask the phone to sign. Nothing is submitted yet. */
|
|
152
|
+
async requestPhoneSignature(rec) {
|
|
153
|
+
if (!this.cfg.principal) {
|
|
154
|
+
rec.status = "failed";
|
|
155
|
+
rec.reason = "split mode needs the principal seed on the seller's side";
|
|
156
|
+
return rec;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const challenge = await createApprovalChallenge({
|
|
160
|
+
vaultAddress: this.cfg.vault.address,
|
|
161
|
+
principal: Keypair.fromSecret(this.cfg.principal),
|
|
162
|
+
payee: this.cfg.payee,
|
|
163
|
+
amount: rec.price,
|
|
164
|
+
tokenCode: this.cfg.vault.token.getCode(),
|
|
165
|
+
tokenIssuer: this.cfg.vault.token.getIssuer() ?? "",
|
|
166
|
+
network: this.cfg.vault.network,
|
|
167
|
+
horizon: this.cfg.vault.horizon,
|
|
168
|
+
id: rec.id,
|
|
169
|
+
memo: rec.id,
|
|
170
|
+
decideWithinSeconds: this.cfg.decideWithinSeconds,
|
|
171
|
+
});
|
|
172
|
+
rec.challenge = challenge;
|
|
173
|
+
rec.qr = challengeQrPayload(challenge);
|
|
174
|
+
rec.status = "awaiting_phone";
|
|
175
|
+
console.log(`[desk] ${rec.id} sent to the phone: ${rec.price} for ${rec.uri}`);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
rec.status = "failed";
|
|
179
|
+
rec.reason = e.message;
|
|
180
|
+
}
|
|
181
|
+
return rec;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Split mode step 2: the phone answered. `signedXdr` is the transaction with
|
|
185
|
+
* the approver signature added, returned by signChallenge() on the phone.
|
|
186
|
+
* The desk cannot forge this: it does not hold the approver key, and the
|
|
187
|
+
* ledger rejects it if the signature is not over this exact transaction.
|
|
188
|
+
*/
|
|
189
|
+
async complete(id, signedXdr) {
|
|
190
|
+
const rec = this.records.get(id);
|
|
191
|
+
if (!rec)
|
|
192
|
+
throw new Error("unknown handle " + id);
|
|
193
|
+
if (rec.status !== "awaiting_phone")
|
|
194
|
+
throw new Error(`not awaiting a phone signature (it is ${rec.status})`);
|
|
195
|
+
if (!rec.challenge)
|
|
196
|
+
throw new Error("no challenge was issued for " + id);
|
|
197
|
+
// Bind the answer to the question. Without this check the desk would submit
|
|
198
|
+
// any signed transaction posted to this route — including one the phone
|
|
199
|
+
// signed for a DIFFERENT payment, at a different price. That is how a
|
|
200
|
+
// payments bug becomes a wrong-amount bug.
|
|
201
|
+
const problem = verifyAgainstChallenge(signedXdr, rec);
|
|
202
|
+
if (problem) {
|
|
203
|
+
rec.status = "failed";
|
|
204
|
+
rec.reason = problem;
|
|
205
|
+
console.log(`[desk] ${id} rejected: ${problem}`);
|
|
206
|
+
return rec;
|
|
207
|
+
}
|
|
208
|
+
rec.signedXdr = signedXdr;
|
|
209
|
+
try {
|
|
210
|
+
const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
|
|
211
|
+
rec.status = "settled";
|
|
212
|
+
rec.tx = res.hash;
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
rec.status = "failed";
|
|
216
|
+
rec.reason = e.message;
|
|
217
|
+
}
|
|
218
|
+
return rec;
|
|
219
|
+
}
|
|
59
220
|
deny(id, reason = "denied by the approver") {
|
|
60
221
|
const rec = this.records.get(id);
|
|
61
222
|
if (!rec)
|
|
@@ -70,6 +231,7 @@ export class ApprovalDesk {
|
|
|
70
231
|
api(handler) { return handler; }
|
|
71
232
|
listen(port = this.cfg.port) {
|
|
72
233
|
const srv = createServer(async (req, res) => {
|
|
234
|
+
// (server handle kept below so close() can stop it)
|
|
73
235
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
74
236
|
// A public demo link must not be an open approval button. If a portalKey
|
|
75
237
|
// is configured, every route needs it, or the desk answers 403.
|
|
@@ -91,8 +253,75 @@ export class ApprovalDesk {
|
|
|
91
253
|
}
|
|
92
254
|
if (url.pathname === "/list")
|
|
93
255
|
return send(200, this.list());
|
|
94
|
-
|
|
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
|
+
}
|
|
316
|
+
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
|
95
317
|
if (m && req.method === "POST") {
|
|
318
|
+
if (m[1] === "signed") {
|
|
319
|
+
const body = await readBody(req);
|
|
320
|
+
const { signedXdr } = JSON.parse(body || "{}");
|
|
321
|
+
if (!signedXdr)
|
|
322
|
+
return send(400, { error: "signedXdr is required" });
|
|
323
|
+
return send(200, await this.complete(m[2], String(signedXdr)));
|
|
324
|
+
}
|
|
96
325
|
const rec = m[1] === "approve" ? await this.approve(m[2]) : this.deny(m[2]);
|
|
97
326
|
return send(200, rec);
|
|
98
327
|
}
|
|
@@ -102,6 +331,15 @@ export class ApprovalDesk {
|
|
|
102
331
|
return send(400, { error: e.message });
|
|
103
332
|
}
|
|
104
333
|
});
|
|
334
|
+
this.server = srv;
|
|
105
335
|
return new Promise((r) => srv.listen(port, () => r()));
|
|
106
336
|
}
|
|
337
|
+
/** Stop serving. Without this the process never exits. */
|
|
338
|
+
close() {
|
|
339
|
+
return new Promise((r) => {
|
|
340
|
+
if (!this.server)
|
|
341
|
+
return r();
|
|
342
|
+
this.server.close(() => r());
|
|
343
|
+
});
|
|
344
|
+
}
|
|
107
345
|
}
|
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/index.d.ts
CHANGED
|
@@ -9,3 +9,6 @@ export type { DeskConfig, DeskRecord } from "./desk.ts";
|
|
|
9
9
|
export { portalPage } from "./portal.ts";
|
|
10
10
|
export { PolicyEngine, DEFAULT_POLICY } from "./policy.ts";
|
|
11
11
|
export type { Policy, SpendEvent, Verdict } from "./policy.ts";
|
|
12
|
+
export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.ts";
|
|
13
|
+
export { txResultName } from "./vault.ts";
|
|
14
|
+
export type { ApprovalChallenge } from "./phone.ts";
|
package/dist/index.js
CHANGED
|
@@ -7,3 +7,6 @@ export { PaydluxAgent } from "./agent.js";
|
|
|
7
7
|
export { ApprovalDesk, newPortalKey } from "./desk.js";
|
|
8
8
|
export { portalPage } from "./portal.js";
|
|
9
9
|
export { PolicyEngine, DEFAULT_POLICY } from "./policy.js";
|
|
10
|
+
// Split signing: the human's key lives on a phone, not in the seller's process.
|
|
11
|
+
export { createApprovalChallenge, signChallenge, describeChallenge, submitApproved, challengeQrPayload, parseChallengeQr, } from "./phone.js";
|
|
12
|
+
export { txResultName } from "./vault.js";
|
package/dist/phone.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Keypair, StrKey } from "@stellar/stellar-sdk";
|
|
2
|
+
import type { Network } from "./vault.ts";
|
|
3
|
+
/** What the seller sends to the phone. Unsigned on purpose. */
|
|
4
|
+
export type ApprovalChallenge = {
|
|
5
|
+
id: string;
|
|
6
|
+
network: Network;
|
|
7
|
+
/** the transaction, as XDR, signed by the principal only */
|
|
8
|
+
xdr: string;
|
|
9
|
+
/** what a human should read before signing */
|
|
10
|
+
summary: {
|
|
11
|
+
payee: string;
|
|
12
|
+
amount: string;
|
|
13
|
+
token: string;
|
|
14
|
+
tokenIssuer: string;
|
|
15
|
+
from: string;
|
|
16
|
+
memo?: string;
|
|
17
|
+
expiresAt?: number;
|
|
18
|
+
};
|
|
19
|
+
hash: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Build the payment and sign it with the principal key only. The result is
|
|
23
|
+
* deliberately incomplete: it reaches the vault's threshold of 2 only once the
|
|
24
|
+
* approver's signature is added.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createApprovalChallenge(opts: {
|
|
27
|
+
vaultAddress: string;
|
|
28
|
+
principal: Keypair;
|
|
29
|
+
payee: string;
|
|
30
|
+
amount: string;
|
|
31
|
+
tokenCode: string;
|
|
32
|
+
tokenIssuer: string;
|
|
33
|
+
network: Network;
|
|
34
|
+
horizon: import("@stellar/stellar-sdk").Horizon.Server;
|
|
35
|
+
id: string;
|
|
36
|
+
memo?: string;
|
|
37
|
+
/** how long the human has to decide; the tx is dead after this */
|
|
38
|
+
decideWithinSeconds?: number;
|
|
39
|
+
}): Promise<ApprovalChallenge>;
|
|
40
|
+
/**
|
|
41
|
+
* What a person (or a phone app) does with a challenge: read it, decide, sign.
|
|
42
|
+
*
|
|
43
|
+
* This function does not talk to the network. It cannot submit anything. It
|
|
44
|
+
* cannot change the amount. It either adds a valid signature or returns the
|
|
45
|
+
* challenge untouched. That is the whole point of running it on the phone.
|
|
46
|
+
*/
|
|
47
|
+
export declare function signChallenge(challenge: ApprovalChallenge, approverSeed: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Read a challenge without any secrets, to show a human. Deliberately separate
|
|
50
|
+
* from signing so a UI can render it on an untrusted machine.
|
|
51
|
+
*/
|
|
52
|
+
export declare function describeChallenge(challenge: ApprovalChallenge): string;
|
|
53
|
+
/**
|
|
54
|
+
* The seller's side: take the phone's signature back and submit. Fails loudly
|
|
55
|
+
* if the transaction is still short of the vault's threshold.
|
|
56
|
+
*/
|
|
57
|
+
export declare function submitApproved(signedXdr: string, network: Network, horizon: import("@stellar/stellar-sdk").Horizon.Server): Promise<{
|
|
58
|
+
hash: string;
|
|
59
|
+
ok: true;
|
|
60
|
+
}>;
|
|
61
|
+
/** A stable, scannable form of a challenge — what a phone would read from a QR code. */
|
|
62
|
+
export declare function challengeQrPayload(challenge: ApprovalChallenge): string;
|
|
63
|
+
/** Parse what a QR code carried. Rejects a payload whose hash disagrees. */
|
|
64
|
+
export declare function parseChallengeQr(payload: string): ApprovalChallenge;
|
|
65
|
+
export { StrKey };
|