@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.
Binary file
Binary file
@@ -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 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.
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 page talks to one origin only — the desk that served it — to fetch 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
- Serve it over http(s) on a device you control: npx tsx tools/phone-serve.ts
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(255,107,107,.14); color:var(--bad) }
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
- <!-- --------------------------------------------------------- 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>
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 &mdash; 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&hellip;</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
- <!-- ----------------------------------------------------------- result -->
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 &mdash; send this back to the seller</label>
201
+ <div class="tag ok" id="resultTag">Signed</div>
202
+ <label id="resultLabel">Signed transaction &mdash; 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
- 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;
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
  };