@paydlux/agent 0.1.8 → 0.1.10
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 +8 -2
- package/dist/cli.js +58 -3
- package/dist/desk.d.ts +16 -0
- package/dist/desk.js +123 -49
- package/package.json +5 -1
- package/phone-app/app.js +405 -226
- package/phone-app/icon-192.png +0 -0
- package/phone-app/icon-512.png +0 -0
- package/phone-app/index.html +81 -18
- package/phone-app/manifest.webmanifest +15 -0
- package/phone-app/xdr.js +45 -5
|
Binary file
|
|
Binary file
|
package/phone-app/index.html
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<meta name="theme-color" content="#0b0f14">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
9
|
+
<meta name="apple-mobile-web-app-title" content="Paydlux">
|
|
7
10
|
<title>Paydlux Approver</title>
|
|
11
|
+
<link rel="manifest" href="manifest.webmanifest">
|
|
12
|
+
<link rel="icon" href="icon-192.png" sizes="192x192">
|
|
13
|
+
<link rel="apple-touch-icon" href="icon-192.png">
|
|
8
14
|
<!--
|
|
9
|
-
The approver half of a Paydlux vault.
|
|
15
|
+
The approver half of a Paydlux vault: a phone app, not a page you paste into.
|
|
10
16
|
|
|
11
|
-
This
|
|
12
|
-
It
|
|
13
|
-
the transaction hash. Signing is a purely local operation: no
|
|
14
|
-
change what was signed.
|
|
17
|
+
This app holds ONE key: the approver's. It cannot spend anything by itself.
|
|
18
|
+
It lists the payments waiting on you, shows a human what they are approving,
|
|
19
|
+
and signs the transaction hash. Signing is a purely local operation: no
|
|
20
|
+
network call can change what was signed.
|
|
15
21
|
|
|
16
|
-
The
|
|
22
|
+
The app talks to one origin only — the desk that served it — to fetch the
|
|
17
23
|
pending transaction and hand back the signature. connect-src 'self' in the
|
|
18
24
|
desk's Content-Security-Policy enforces that.
|
|
19
25
|
|
|
@@ -28,7 +34,10 @@
|
|
|
28
34
|
transaction without reading it. The human reading the amount and payee is part
|
|
29
35
|
of the security model, not a UX step.
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
The static shell (this file, app.js, xdr.js, the icons) carries no secrets and
|
|
38
|
+
no payment data, so the desk serves it without the portal key — that is what
|
|
39
|
+
lets it be installed to a home screen. Every route that carries data or moves
|
|
40
|
+
money still needs the key.
|
|
32
41
|
-->
|
|
33
42
|
<style>
|
|
34
43
|
:root {
|
|
@@ -67,13 +76,30 @@
|
|
|
67
76
|
.tag { display:inline-block; font-size:11px; padding:3px 8px; border-radius:999px; letter-spacing:.06em; text-transform:uppercase }
|
|
68
77
|
.tag.ok { background:rgba(61,220,151,.14); color:var(--ok) }
|
|
69
78
|
.tag.warn { background:rgba(255,180,84,.14); color:var(--warn) }
|
|
70
|
-
.tag.bad { background:rgba(
|
|
79
|
+
.tag.bad { background:rgba(257,107,107,.14); color:var(--bad) }
|
|
71
80
|
.err { color:var(--bad); font-size:13px; margin-top:8px; word-break:break-word }
|
|
72
81
|
.hint { color:var(--dim); font-size:12px; margin-top:8px }
|
|
73
82
|
.hidden { display:none }
|
|
74
83
|
.out { background:#0d131a; border:1px solid var(--line); border-radius:10px; padding:10px;
|
|
75
84
|
font:12px/1.4 ui-monospace,Menlo,monospace; word-break:break-all; max-height:180px; overflow:auto }
|
|
76
85
|
.who { font-family:ui-monospace,Menlo,monospace; font-size:12px; color:var(--dim); word-break:break-all }
|
|
86
|
+
|
|
87
|
+
/* ------------------------------------------------------------ queue */
|
|
88
|
+
.qhead { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:6px }
|
|
89
|
+
.qhead h2 { font-size:13px; margin:0; text-transform:uppercase; letter-spacing:.06em; color:var(--dim) }
|
|
90
|
+
.qhead .when { font-size:11px; color:var(--dim) }
|
|
91
|
+
.item { border:1px solid var(--line); border-radius:12px; padding:12px; margin-top:10px; background:#0d131a }
|
|
92
|
+
.item .top { display:flex; justify-content:space-between; align-items:baseline; gap:10px }
|
|
93
|
+
.item .amt { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums }
|
|
94
|
+
.item .uri { font-size:12px; color:var(--dim); word-break:break-all; margin-top:2px }
|
|
95
|
+
.item .wants { font-size:13px; margin-top:6px; word-break:break-word }
|
|
96
|
+
.item .foot { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:8px }
|
|
97
|
+
.item .ago { font-size:11px; color:var(--dim) }
|
|
98
|
+
.item .btns { display:flex; gap:8px }
|
|
99
|
+
.item .btns button { width:auto; margin:0; padding:9px 16px; font-size:14px }
|
|
100
|
+
.empty { color:var(--dim); font-size:14px; padding:14px 2px }
|
|
101
|
+
.hist { opacity:.62 }
|
|
102
|
+
.hist .amt { font-size:16px }
|
|
77
103
|
</style>
|
|
78
104
|
</head>
|
|
79
105
|
<body>
|
|
@@ -104,21 +130,38 @@
|
|
|
104
130
|
<button class="ghost" id="forget">Remove key from this device</button>
|
|
105
131
|
</div>
|
|
106
132
|
|
|
107
|
-
<!--
|
|
108
|
-
<div class="panel">
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
133
|
+
<!-- -------------------------------------------------------- not linked -->
|
|
134
|
+
<div class="panel hidden" id="nolink">
|
|
135
|
+
<div class="tag bad">No approval link</div>
|
|
136
|
+
<div class="hint" style="margin-top:10px">
|
|
137
|
+
This app shows payments only to whoever holds the seller's approval link. Open the
|
|
138
|
+
link the seller sent you once — the app then remembers it on this device.
|
|
139
|
+
</div>
|
|
140
|
+
<label for="link">Approval link</label>
|
|
141
|
+
<input id="link" type="url" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
142
|
+
placeholder="https://desk.paydlux.com/phone/?k=...">
|
|
143
|
+
<button id="linkBtn">Use this link</button>
|
|
144
|
+
<div class="err" id="linkErr"></div>
|
|
114
145
|
</div>
|
|
115
146
|
|
|
147
|
+
<!-- ------------------------------------------------------------- queue -->
|
|
148
|
+
<div class="panel hidden" id="queue">
|
|
149
|
+
<div class="qhead">
|
|
150
|
+
<h2>Waiting for you</h2>
|
|
151
|
+
<span class="when" id="qwhen"></span>
|
|
152
|
+
</div>
|
|
153
|
+
<div id="qlist"><div class="empty">loading…</div></div>
|
|
154
|
+
<div class="err" id="qerr"></div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- ----------------------------------------------------------- review -->
|
|
116
158
|
<div class="panel hidden" id="review">
|
|
117
159
|
<div class="amount" id="amount"></div>
|
|
118
160
|
<dl class="kv">
|
|
119
161
|
<dt>Token</dt><dd id="token"></dd>
|
|
120
162
|
<dt>Payee</dt><dd id="payee"></dd>
|
|
121
163
|
<dt>From</dt><dd id="vault"></dd>
|
|
164
|
+
<dt>Wants</dt><dd id="wants"></dd>
|
|
122
165
|
<dt>Reference</dt><dd id="memo"></dd>
|
|
123
166
|
<dt>Deadline</dt><dd id="deadline"></dd>
|
|
124
167
|
<dt>Network</dt><dd id="network"></dd>
|
|
@@ -133,10 +176,30 @@
|
|
|
133
176
|
<div class="err" id="signErr"></div>
|
|
134
177
|
</div>
|
|
135
178
|
|
|
136
|
-
<!--
|
|
179
|
+
<!-- --------------------------------------------------------- pasted xdr -->
|
|
180
|
+
<!-- Hidden until there is nothing in the queue. A human who just imported a key
|
|
181
|
+
should see their payments, not a box asking them to paste 400 characters of
|
|
182
|
+
base64 they were never asked for. This path is the fallback for when the
|
|
183
|
+
seller sends a transaction by message instead of a link. -->
|
|
184
|
+
<div class="panel hidden" id="paste">
|
|
185
|
+
<details>
|
|
186
|
+
<summary style="font-size:13px;color:var(--dim)">Sign a transaction the seller sent by hand</summary>
|
|
187
|
+
<label for="xdr">Pending transaction (XDR)</label>
|
|
188
|
+
<textarea id="xdr" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
189
|
+
placeholder="Paste the transaction the seller sent you"></textarea>
|
|
190
|
+
<button id="read">Read it</button>
|
|
191
|
+
<div class="hint">
|
|
192
|
+
A hand-sent transaction has no trusted source, so the app will make you type back
|
|
193
|
+
the hash the seller quoted before it signs.
|
|
194
|
+
</div>
|
|
195
|
+
<div class="err" id="readErr"></div>
|
|
196
|
+
</details>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- ------------------------------------------------------------ result -->
|
|
137
200
|
<div class="panel hidden" id="result">
|
|
138
|
-
<div class="tag ok">Signed</div>
|
|
139
|
-
<label>Signed transaction — send this back to the seller</label>
|
|
201
|
+
<div class="tag ok" id="resultTag">Signed</div>
|
|
202
|
+
<label id="resultLabel">Signed transaction — send this back to the seller</label>
|
|
140
203
|
<div class="out" id="signed"></div>
|
|
141
204
|
<button id="copy">Copy</button>
|
|
142
205
|
<button class="ghost" id="again">Done</button>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Paydlux Approver",
|
|
3
|
+
"short_name": "Paydlux",
|
|
4
|
+
"description": "Signs Paydlux payment approvals on this device. The approver key never leaves it.",
|
|
5
|
+
"start_url": "/phone/",
|
|
6
|
+
"scope": "/phone/",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"orientation": "portrait",
|
|
9
|
+
"background_color": "#0b0f14",
|
|
10
|
+
"theme_color": "#0b0f14",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
+
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
|
14
|
+
]
|
|
15
|
+
}
|
package/phone-app/xdr.js
CHANGED
|
@@ -172,6 +172,49 @@
|
|
|
172
172
|
* The public half is captured at enrolment and stored separately.
|
|
173
173
|
*/
|
|
174
174
|
async function importSecretSeed(seedStr) {
|
|
175
|
+
const pkcs8 = pkcs8FromSeed(seedStr);
|
|
176
|
+
const privateKey = await crypto.subtle.importKey(
|
|
177
|
+
"pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
|
|
178
|
+
pkcs8.fill(0); // drop the plaintext copy as soon as we can
|
|
179
|
+
return privateKey;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Import a seed AND read back its public half, in one step.
|
|
184
|
+
*
|
|
185
|
+
* A non-extractable key cannot be asked for its own public bytes, and the
|
|
186
|
+
* approver app needs those bytes to add its signature to a transaction. So the
|
|
187
|
+
* seed is imported a second time as an extractable key purely to export the
|
|
188
|
+
* 32-byte public half; that temporary key is discarded immediately and never
|
|
189
|
+
* stored. What the app keeps is the non-extractable key plus a public value
|
|
190
|
+
* that is public anyway.
|
|
191
|
+
*
|
|
192
|
+
* The export goes through JWK rather than "raw": WebCrypto defines raw only for
|
|
193
|
+
* OKP *public* keys, so exporting raw from a private key is refused (verified
|
|
194
|
+
* on Node 26). The JWK form of an Ed25519 private key carries the public half
|
|
195
|
+
* in `x`, base64url.
|
|
196
|
+
*/
|
|
197
|
+
async function importSecretSeedPair(seedStr) {
|
|
198
|
+
const key = await importSecretSeed(seedStr);
|
|
199
|
+
const tmp = await crypto.subtle.importKey(
|
|
200
|
+
"pkcs8", pkcs8FromSeed(seedStr), { name: "Ed25519" }, true, ["sign"]);
|
|
201
|
+
let pub;
|
|
202
|
+
try {
|
|
203
|
+
const jwk = await crypto.subtle.exportKey("jwk", tmp);
|
|
204
|
+
pub = base64UrlToBytes(jwk.x);
|
|
205
|
+
} catch {
|
|
206
|
+
pub = new Uint8Array(await crypto.subtle.exportKey("raw", tmp));
|
|
207
|
+
}
|
|
208
|
+
if (pub.length !== 32) throw new Error("unexpected public key length " + pub.length);
|
|
209
|
+
return { key, pub, address: encodeAccountEd25519(pub) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function base64UrlToBytes(s) {
|
|
213
|
+
if (typeof s !== "string" || !s) throw new Error("missing public key in the exported key");
|
|
214
|
+
return base64ToBytes(s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - s.length % 4) % 4));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pkcs8FromSeed(seedStr) {
|
|
175
218
|
const seed = decodeSecretSeed(seedStr);
|
|
176
219
|
const pkcs8 = new Uint8Array(16 + 32);
|
|
177
220
|
pkcs8.set([
|
|
@@ -179,10 +222,7 @@
|
|
|
179
222
|
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
180
223
|
], 0);
|
|
181
224
|
pkcs8.set(seed, 16);
|
|
182
|
-
|
|
183
|
-
"pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
|
|
184
|
-
seed.fill(0); // drop the plaintext copy as soon as we can
|
|
185
|
-
return privateKey;
|
|
225
|
+
return pkcs8;
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
// ------------------------------------------------------------------ parsing
|
|
@@ -429,7 +469,7 @@
|
|
|
429
469
|
parseEnvelope, transactionHash, addSignature, formatAmount,
|
|
430
470
|
hex, hexToBytes, signaturePreimage,
|
|
431
471
|
encodeAccountEd25519, decodeAccountEd25519,
|
|
432
|
-
decodeSecretSeed, encodeSecretSeed, importSecretSeed,
|
|
472
|
+
decodeSecretSeed, encodeSecretSeed, importSecretSeed, importSecretSeedPair,
|
|
433
473
|
NETWORK_ID, crc16Xmodem,
|
|
434
474
|
ENVELOPE_TYPE_TX, OP_PAYMENT, STROOPS,
|
|
435
475
|
};
|