pakstr 0.29.1 → 0.31.0
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 +11 -1
- package/android-template/app/src/debug/AndroidManifest.xml +5 -2
- package/android-template/app/src/main/AndroidManifest.xml +1 -0
- package/desktop-runtime/README.md +108 -0
- package/desktop-runtime/dist/main.js +22211 -0
- package/desktop-runtime/dist/preload.js +53 -0
- package/dist/cli.js +17 -3
- package/dist/commands/build.js +1 -0
- package/dist/commands/buildDesktop.js +140 -0
- package/dist/desktop/common/artifacts.js +16 -0
- package/dist/desktop/common/asar.js +60 -0
- package/dist/desktop/common/electron.js +50 -0
- package/dist/desktop/common/files.js +30 -0
- package/dist/desktop/common/fuses.js +62 -0
- package/dist/desktop/common/packager.js +46 -0
- package/dist/desktop/linux.js +425 -0
- package/dist/desktop/macos.js +125 -0
- package/dist/desktop/stage.js +117 -0
- package/dist/desktop/windows.js +384 -0
- package/dist/desktop/zip.js +204 -0
- package/dist/shared/nip46Pairing.js +278 -0
- package/package.json +14 -3
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Nip46PairingClient = exports.NIP46_EVENT_KIND = void 0;
|
|
4
|
+
exports.generatePairingSecret = generatePairingSecret;
|
|
5
|
+
exports.createNostrConnectPairingUri = createNostrConnectPairingUri;
|
|
6
|
+
exports.createPairedNostrConnectSigner = createPairedNostrConnectSigner;
|
|
7
|
+
const event_1 = require("applesauce-core/helpers/event");
|
|
8
|
+
const applesauce_signers_1 = require("applesauce-signers");
|
|
9
|
+
/**
|
|
10
|
+
* Strict client-initiated NIP-46 pairing (`nostrconnect://`).
|
|
11
|
+
*
|
|
12
|
+
* A TypeScript port of the Android `Nip46PairingClient` security rules, for
|
|
13
|
+
* runtimes without the Android template (e.g. a desktop shell). It has no
|
|
14
|
+
* Node, Electron, or Android dependencies.
|
|
15
|
+
*
|
|
16
|
+
* Why this exists: applesauce-signers 6.2.2 `NostrConnectSigner` accepts a
|
|
17
|
+
* bare `"ack"` from ANY pubkey while no remote signer is known yet, so anyone
|
|
18
|
+
* who learns the client pubkey (e.g. a relay that sees the `#p` filter) can
|
|
19
|
+
* complete pairing without the secret. This module performs the pairing
|
|
20
|
+
* itself, accepting only an exact secret match, and hands the paired remote to
|
|
21
|
+
* `NostrConnectSigner`, whose own author check then drops other pubkeys.
|
|
22
|
+
*/
|
|
23
|
+
exports.NIP46_EVENT_KIND = 24133;
|
|
24
|
+
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
|
25
|
+
const SECRET_BYTES = 32;
|
|
26
|
+
const MAX_SECRET_LENGTH = 256;
|
|
27
|
+
const MAX_PERMISSIONS_LENGTH = 4096;
|
|
28
|
+
const MAX_NAME_LENGTH = 256;
|
|
29
|
+
/** 32 random bytes, lowercase hex (same shape as Android's generateSecret). */
|
|
30
|
+
function generatePairingSecret() {
|
|
31
|
+
const bytes = new Uint8Array(SECRET_BYTES);
|
|
32
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
33
|
+
try {
|
|
34
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
bytes.fill(0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a `nostrconnect://` URI with the same parameters and order as
|
|
42
|
+
* Android's `Nip46PairingUri`: relay..., perms, name, secret.
|
|
43
|
+
*/
|
|
44
|
+
function createNostrConnectPairingUri(options) {
|
|
45
|
+
const { clientPubkey, relays, secret, permissions, name } = options;
|
|
46
|
+
if (!HEX_PUBKEY.test(clientPubkey))
|
|
47
|
+
throw new Error("Invalid client public key");
|
|
48
|
+
if (relays.length === 0)
|
|
49
|
+
throw new Error("At least one relay is required");
|
|
50
|
+
relays.forEach(assertRelayUrl);
|
|
51
|
+
if (!isBoundedNonBlank(secret, MAX_SECRET_LENGTH))
|
|
52
|
+
throw new Error("Invalid pairing secret");
|
|
53
|
+
if (permissions !== undefined && !isBoundedNonBlank(permissions, MAX_PERMISSIONS_LENGTH)) {
|
|
54
|
+
throw new Error("Invalid requested permissions");
|
|
55
|
+
}
|
|
56
|
+
if (name !== undefined && !isBoundedNonBlank(name, MAX_NAME_LENGTH))
|
|
57
|
+
throw new Error("Invalid client name");
|
|
58
|
+
const params = new URLSearchParams();
|
|
59
|
+
for (const relay of relays)
|
|
60
|
+
params.append("relay", relay);
|
|
61
|
+
if (permissions !== undefined)
|
|
62
|
+
params.append("perms", permissions);
|
|
63
|
+
if (name !== undefined)
|
|
64
|
+
params.append("name", name);
|
|
65
|
+
params.append("secret", secret);
|
|
66
|
+
return `nostrconnect://${clientPubkey}?${params.toString()}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Listens for the remote signer's `connect` response. Pairing completes only
|
|
70
|
+
* for a kind-24133 event that is addressed to the client (`p` tag), carries a
|
|
71
|
+
* valid signature, NIP-44-decrypts with the client key, and whose response has
|
|
72
|
+
* `result` exactly equal to the secret and no `error`. The first such author
|
|
73
|
+
* wins; everything else is ignored.
|
|
74
|
+
*/
|
|
75
|
+
class Nip46PairingClient {
|
|
76
|
+
relays;
|
|
77
|
+
clientSigner;
|
|
78
|
+
clientPubkey;
|
|
79
|
+
secret;
|
|
80
|
+
remote = null;
|
|
81
|
+
closed = false;
|
|
82
|
+
ready = false;
|
|
83
|
+
subscription;
|
|
84
|
+
readyWaiters = [];
|
|
85
|
+
pairingWaiters = [];
|
|
86
|
+
constructor(relays, clientSigner, clientPubkey, secret) {
|
|
87
|
+
this.relays = relays;
|
|
88
|
+
this.clientSigner = clientSigner;
|
|
89
|
+
this.clientPubkey = clientPubkey;
|
|
90
|
+
this.secret = secret;
|
|
91
|
+
}
|
|
92
|
+
/** Subscribe for pairing responses. Expose the pairing URI only after this resolves. */
|
|
93
|
+
static async start(options) {
|
|
94
|
+
if (options.relays.length === 0)
|
|
95
|
+
throw new Error("At least one relay is required");
|
|
96
|
+
if (!isBoundedNonBlank(options.secret, MAX_SECRET_LENGTH))
|
|
97
|
+
throw new Error("Invalid pairing secret");
|
|
98
|
+
const subscriptionMethod = options.subscriptionMethod ?? options.pool?.subscription.bind(options.pool);
|
|
99
|
+
if (!subscriptionMethod)
|
|
100
|
+
throw new Error("A subscriptionMethod or pool is required");
|
|
101
|
+
const clientPubkey = await options.clientSigner.getPublicKey();
|
|
102
|
+
const client = new Nip46PairingClient([...options.relays], options.clientSigner, clientPubkey, options.secret);
|
|
103
|
+
const source = subscriptionMethod(client.relays, [{ kinds: [exports.NIP46_EVENT_KIND], "#p": [clientPubkey] }]);
|
|
104
|
+
if (!isSubscribable(source)) {
|
|
105
|
+
throw new TypeError("subscriptionMethod must return a subscribable (e.g. an rxjs Observable)");
|
|
106
|
+
}
|
|
107
|
+
client.subscription = source.subscribe({
|
|
108
|
+
next: (value) => {
|
|
109
|
+
if (value === "EOSE")
|
|
110
|
+
client.markReady();
|
|
111
|
+
else if (value && typeof value === "object")
|
|
112
|
+
void client.handleEvent(value);
|
|
113
|
+
},
|
|
114
|
+
// Fail closed: a dead subscription can never deliver the response.
|
|
115
|
+
error: () => client.close(),
|
|
116
|
+
complete: () => client.close(),
|
|
117
|
+
});
|
|
118
|
+
return client;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolves true once a relay confirms the subscription (EOSE), so a
|
|
122
|
+
* response to a just-exposed URI cannot be missed. False on timeout, close,
|
|
123
|
+
* or subscription failure. Pools that never emit "EOSE" cannot signal this.
|
|
124
|
+
*/
|
|
125
|
+
awaitRelayReady(timeoutMs) {
|
|
126
|
+
if (this.ready)
|
|
127
|
+
return Promise.resolve(true);
|
|
128
|
+
if (this.closed)
|
|
129
|
+
return Promise.resolve(false);
|
|
130
|
+
return waitFor(this.readyWaiters, timeoutMs, false);
|
|
131
|
+
}
|
|
132
|
+
/** The remote signer pubkey, or null on timeout or close. */
|
|
133
|
+
waitForSigner(timeoutMs) {
|
|
134
|
+
if (this.remote)
|
|
135
|
+
return Promise.resolve(this.remote);
|
|
136
|
+
if (this.closed)
|
|
137
|
+
return Promise.resolve(null);
|
|
138
|
+
return waitFor(this.pairingWaiters, timeoutMs, null);
|
|
139
|
+
}
|
|
140
|
+
/** Feed one candidate event. Resolves true only if it completed pairing. */
|
|
141
|
+
async handleEvent(event) {
|
|
142
|
+
if (this.closed || this.remote)
|
|
143
|
+
return false;
|
|
144
|
+
if (!event || typeof event !== "object")
|
|
145
|
+
return false;
|
|
146
|
+
const e = event;
|
|
147
|
+
const author = e.pubkey;
|
|
148
|
+
if (typeof author !== "string" || !HEX_PUBKEY.test(author))
|
|
149
|
+
return false;
|
|
150
|
+
if (e.kind !== exports.NIP46_EVENT_KIND || !this.isAddressedToClient(e.tags) || typeof e.content !== "string")
|
|
151
|
+
return false;
|
|
152
|
+
if (!hasValidSignature(e))
|
|
153
|
+
return false;
|
|
154
|
+
let response;
|
|
155
|
+
try {
|
|
156
|
+
response = JSON.parse(await this.clientSigner.nip44.decrypt(author, e.content));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (!response || typeof response !== "object" || Array.isArray(response))
|
|
162
|
+
return false;
|
|
163
|
+
const { result, error } = response;
|
|
164
|
+
if (typeof result !== "string" || !constantTimeEqual(result, this.secret))
|
|
165
|
+
return false;
|
|
166
|
+
if (error !== undefined && error !== null)
|
|
167
|
+
return false;
|
|
168
|
+
// Re-check after the async decrypt: the first valid author wins.
|
|
169
|
+
if (this.closed || this.remote)
|
|
170
|
+
return false;
|
|
171
|
+
this.remote = author;
|
|
172
|
+
this.unsubscribe();
|
|
173
|
+
for (const resolve of this.pairingWaiters.splice(0))
|
|
174
|
+
resolve(author);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
/** Idempotent; unblocks any waiter with a negative result. */
|
|
178
|
+
close() {
|
|
179
|
+
if (this.closed)
|
|
180
|
+
return;
|
|
181
|
+
this.closed = true;
|
|
182
|
+
this.unsubscribe();
|
|
183
|
+
for (const resolve of this.readyWaiters.splice(0))
|
|
184
|
+
resolve(false);
|
|
185
|
+
for (const resolve of this.pairingWaiters.splice(0))
|
|
186
|
+
resolve(null);
|
|
187
|
+
}
|
|
188
|
+
markReady() {
|
|
189
|
+
if (this.ready || this.closed)
|
|
190
|
+
return;
|
|
191
|
+
this.ready = true;
|
|
192
|
+
for (const resolve of this.readyWaiters.splice(0))
|
|
193
|
+
resolve(true);
|
|
194
|
+
}
|
|
195
|
+
unsubscribe() {
|
|
196
|
+
const subscription = this.subscription;
|
|
197
|
+
this.subscription = undefined;
|
|
198
|
+
subscription?.unsubscribe();
|
|
199
|
+
}
|
|
200
|
+
isAddressedToClient(tags) {
|
|
201
|
+
return Array.isArray(tags) && tags.some((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0] === "p" && tag[1] === this.clientPubkey);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
exports.Nip46PairingClient = Nip46PairingClient;
|
|
205
|
+
/**
|
|
206
|
+
* A `NostrConnectSigner` bound to an already-paired remote. Because `remote`
|
|
207
|
+
* is set before any event arrives, the library ignores responses from every
|
|
208
|
+
* other pubkey and never takes its lenient `"ack"` pairing path.
|
|
209
|
+
*/
|
|
210
|
+
async function createPairedNostrConnectSigner(options) {
|
|
211
|
+
if (!HEX_PUBKEY.test(options.remote))
|
|
212
|
+
throw new Error("Invalid remote signer public key");
|
|
213
|
+
const signer = new applesauce_signers_1.NostrConnectSigner({
|
|
214
|
+
relays: [...options.relays],
|
|
215
|
+
remote: options.remote,
|
|
216
|
+
signer: options.clientSigner,
|
|
217
|
+
pool: options.pool,
|
|
218
|
+
subscriptionMethod: options.subscriptionMethod,
|
|
219
|
+
publishMethod: options.publishMethod,
|
|
220
|
+
onAuth: options.onAuth,
|
|
221
|
+
});
|
|
222
|
+
await signer.open();
|
|
223
|
+
// The state the library itself reaches after pairing, without an extra
|
|
224
|
+
// `connect` round trip that some signers would prompt for again.
|
|
225
|
+
signer.isConnected = true;
|
|
226
|
+
return signer;
|
|
227
|
+
}
|
|
228
|
+
function hasValidSignature(e) {
|
|
229
|
+
// Verify a fresh copy: nostr-tools caches the verdict on the object itself.
|
|
230
|
+
const copy = { id: e.id, pubkey: e.pubkey, created_at: e.created_at, kind: e.kind, tags: e.tags, content: e.content, sig: e.sig };
|
|
231
|
+
try {
|
|
232
|
+
return (0, event_1.verifyEvent)(copy);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function constantTimeEqual(a, b) {
|
|
239
|
+
if (a.length !== b.length)
|
|
240
|
+
return false;
|
|
241
|
+
let diff = 0;
|
|
242
|
+
for (let i = 0; i < a.length; i++)
|
|
243
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
244
|
+
return diff === 0;
|
|
245
|
+
}
|
|
246
|
+
function isBoundedNonBlank(value, max) {
|
|
247
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max;
|
|
248
|
+
}
|
|
249
|
+
function assertRelayUrl(value) {
|
|
250
|
+
let url;
|
|
251
|
+
try {
|
|
252
|
+
url = new URL(value);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
throw new Error("Invalid relay URL");
|
|
256
|
+
}
|
|
257
|
+
if (url.protocol !== "wss:" || !url.hostname || url.username || url.password || value.includes("#")) {
|
|
258
|
+
throw new Error("Invalid relay URL");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function isSubscribable(value) {
|
|
262
|
+
return !!value && typeof value.subscribe === "function";
|
|
263
|
+
}
|
|
264
|
+
function waitFor(waiters, timeoutMs, onTimeout) {
|
|
265
|
+
return new Promise((resolve) => {
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
const index = waiters.indexOf(settle);
|
|
268
|
+
if (index !== -1)
|
|
269
|
+
waiters.splice(index, 1);
|
|
270
|
+
resolve(onTimeout);
|
|
271
|
+
}, timeoutMs);
|
|
272
|
+
function settle(value) {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
resolve(value);
|
|
275
|
+
}
|
|
276
|
+
waiters.push(settle);
|
|
277
|
+
});
|
|
278
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pakstr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "CLI for packaging Nostr web apps into Android APKs",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
|
-
"node": ">=22"
|
|
14
|
+
"node": ">=22.12.0"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
"android-template/build.gradle.kts",
|
|
25
25
|
"android-template/settings.gradle.kts",
|
|
26
26
|
"android-template/gradle.properties",
|
|
27
|
+
"desktop-runtime/dist/main.js",
|
|
28
|
+
"desktop-runtime/dist/preload.js",
|
|
27
29
|
"README.md"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
30
32
|
"prebuild": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
31
|
-
"build": "tsc",
|
|
33
|
+
"build": "tsc && node desktop-runtime/build.js",
|
|
34
|
+
"prepack": "node -e \"for (const f of ['main.js', 'preload.js']) if (!require('fs').statSync('desktop-runtime/dist/' + f, { throwIfNoEntry: false })?.isFile()) { console.error('desktop-runtime/dist/' + f + ' is missing: run npm run build before packing'); process.exit(1); }\"",
|
|
32
35
|
"type-test": "tsc -p tsconfig.type-tests.json",
|
|
33
36
|
"test": "npm run build && npm run type-test && node --test test/*.test.js",
|
|
34
37
|
"start": "node dist/index.js",
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
"@types/js-yaml": "^4.0.9",
|
|
49
52
|
"@types/node": "^26.1.0",
|
|
50
53
|
"@types/unzipper": "^0.10.11",
|
|
54
|
+
"esbuild": "^0.28.2",
|
|
51
55
|
"typescript": "^6.0.3"
|
|
52
56
|
},
|
|
53
57
|
"dependencies": {
|
|
@@ -63,5 +67,12 @@
|
|
|
63
67
|
"repository": {
|
|
64
68
|
"type": "git",
|
|
65
69
|
"url": "https://git.nostrdev.com/stuff/pakstr.git"
|
|
70
|
+
},
|
|
71
|
+
"optionalDependencies": {
|
|
72
|
+
"@electron/asar": "^4.3.1",
|
|
73
|
+
"@electron/fuses": "^2.1.3",
|
|
74
|
+
"@electron/get": "^5.1.0",
|
|
75
|
+
"@electron/packager": "^20.3.0",
|
|
76
|
+
"resedit": "^2.0.3"
|
|
66
77
|
}
|
|
67
78
|
}
|