@formstr/signer 0.2.1 → 0.3.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 +81 -4
- package/dist/index.cjs +381 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +374 -12
- package/dist/index.js.map +1 -1
- package/dist/{signer-BepGdtJs.d.cts → signer-oU50RBqS.d.cts} +232 -2
- package/dist/{signer-BepGdtJs.d.ts → signer-oU50RBqS.d.ts} +232 -2
- package/dist/ui/index.cjs +31 -2
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +21 -4
- package/dist/ui/index.d.ts +21 -4
- package/dist/ui/index.js +31 -2
- package/dist/ui/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @formstr/signer
|
|
2
2
|
|
|
3
|
-
A vanilla TypeScript Nostr signer with an optional unstyled login UI. Supports NIP-07 (browser extension), NIP-46 (bunker URI + nostrconnect QR), NIP-49 (ncryptsec at rest),
|
|
3
|
+
A vanilla TypeScript Nostr signer with an optional unstyled login UI. Supports NIP-07 (browser extension), NIP-46 (bunker URI + nostrconnect QR), NIP-49 (ncryptsec at rest), NIP-55 (Android external signer apps, via a Capacitor plugin **or** a plain browser), and a pure-web signer-app flow.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -28,6 +28,7 @@ await signer.loginWithExtension();
|
|
|
28
28
|
await signer.loginWithBunkerUri('bunker://...');
|
|
29
29
|
await signer.loginWithNostrConnect({ relays: ['wss://relay.example'], onUri: (uri) => /* show QR */ });
|
|
30
30
|
await signer.loginWithAndroidSigner({ packageName: 'com.greenart7c3.nostrsigner' });
|
|
31
|
+
await signer.loginWithNip55Web(); // Android browser, no Capacitor shell — see below
|
|
31
32
|
|
|
32
33
|
// Sign events — the active signer never exposes the privkey
|
|
33
34
|
const active = signer.getActiveSigner()!;
|
|
@@ -79,6 +80,7 @@ Per-method behavior:
|
|
|
79
80
|
| `extension` | constructs `ExtensionSigner` (stateless wrapper around `window.nostr`) | no |
|
|
80
81
|
| `nip46` | reuses persisted `clientSecretKey` + `remoteSignerPubkey` + `relays` to attach a `BunkerSigner` — **skips the `connect` request**, which is what triggers a fresh approval prompt every reload | no |
|
|
81
82
|
| `android` | builds the `AndroidSigner` directly from cached `pubkey` + `npub` + `androidPackageName`, **bypassing the plugin's `getPublicKey` content-provider call** | no |
|
|
83
|
+
| `nip55-web` | builds a `Nip55WebSigner` from the cached `pubkey`; it does not open the signer app until the next sign/encrypt call | no |
|
|
82
84
|
| `ncryptsec` | returns `null` — the passphrase is not (and must not be) persisted; caller drives the prompt and calls `loginWithNcryptsec(account.ncryptsec, passphrase)` | n/a (by design) |
|
|
83
85
|
|
|
84
86
|
`unlock()` returns `null` (without emitting an event) when there is no active account, when the account is missing fields it needs to resume, when method is `nip46` but no `pool` was supplied, or when method is `android` but no plugin is configured. On success it emits the same `login`/`switch` event the corresponding `loginWith*` would.
|
|
@@ -133,6 +135,78 @@ const myPlugin: AndroidSignerPlugin = {
|
|
|
133
135
|
|
|
134
136
|
The interface signatures intentionally mirror `nostr-signer-capacitor-plugin`'s exported `NostrSignerPlugin` so the real wrapper is directly assignable. If you write a custom plugin, the package's test suite includes a compile-time conformance guard (`tests/helpers/mockAndroidPlugin.ts`) you can model your own check on — wire it up in your CI and you'll catch any drift the moment the upstream wrapper changes shape.
|
|
135
137
|
|
|
138
|
+
**Identifier shape.** The `npub` field returned by `getPublicKey` is permissive: the package accepts either a bech32 `npub1…` string (the NIP-55 spec shape) or a 32-byte hex pubkey (what current Amber builds actually return). Whichever you hand back, the package normalizes internally — `StoredAccount.npub` is always bech32 and `StoredAccount.pubkey` is always lowercase hex. Anything else surfaces as a debuggable error including a preview of what was received. The same normalization (plus `nprofile1…`) is shared with the browser flow below.
|
|
139
|
+
|
|
140
|
+
## NIP-55 in a plain browser (`loginWithNip55Web`)
|
|
141
|
+
|
|
142
|
+
The Capacitor plugin above only works inside a native Android shell. `loginWithNip55Web()` covers the other case: a **plain browser on Android** (mobile web, a PWA) talking to any installed NIP-55 signer app with no native bridge.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
await signer.loginWithNip55Web();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The mechanism:
|
|
149
|
+
|
|
150
|
+
1. **Plant a sentinel.** The package overwrites the clipboard with a random `__formstr_nip55_sentinel_…__` string. This is what makes the result identifiable — otherwise a clipboard left over from an earlier approval would look like a fresh answer and resolve immediately.
|
|
151
|
+
2. **Open the intent.** It opens `intent:#Intent;scheme=nostrsigner;S.type=…;end` via `window.open`. No package is named, so Android resolves it against every app that registered the scheme — one signer opens directly, several show the standard "Open with" chooser. This is not Amber-specific.
|
|
152
|
+
3. **Poll the clipboard.** The signer app signs and copies the result to the clipboard. The package polls `navigator.clipboard.readText()` every `pollIntervalMs` (default 500ms) until the value differs from the sentinel, then resolves.
|
|
153
|
+
|
|
154
|
+
### Why polling, not `visibilitychange`
|
|
155
|
+
|
|
156
|
+
The obvious design — read the clipboard when the user returns to the tab — **does not work on Android Chrome**, verified on a Pixel emulator running Amber 6.6.4:
|
|
157
|
+
|
|
158
|
+
- Returning from the signer app fires **no** `visibilitychange` or `focus` event. `window.open` produces a brief hide/show blip *before* the signer is even open (a `blur`, two `visibilitychange`s and a `focus` within ~400ms), and then nothing on the actual return. An event-driven read therefore never runs.
|
|
159
|
+
- Even when a read is attempted right after returning, Chrome rejects it with `NotAllowedError: Document is not focused` — the page is visible but not focused, and it does not regain focus on its own.
|
|
160
|
+
- `setInterval`, by contrast, keeps ticking while the tab is backgrounded, so polling observes the result regardless.
|
|
161
|
+
|
|
162
|
+
This is why the transport interface has `readClipboard`/`writeClipboard` but no foreground callback: the browser simply does not provide a reliable return signal.
|
|
163
|
+
|
|
164
|
+
### Browser only — not for native builds
|
|
165
|
+
|
|
166
|
+
This flow is for a plain browser. Inside a Capacitor native shell the same
|
|
167
|
+
device already has the real NIP-55 plugin, which supports every method and
|
|
168
|
+
needs neither the clipboard nor a per-operation approval — so this path is
|
|
169
|
+
disabled there (Capacitor injects a `Capacitor.isNativePlatform()` global,
|
|
170
|
+
which the package checks without depending on `@capacitor/core`).
|
|
171
|
+
|
|
172
|
+
Gate your UI on `signer.supportsNip55Web()`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
container.innerHTML = renderLoginHtml({
|
|
176
|
+
includeNip55Web: signer.supportsNip55Web(),
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
In a native build that omits the tab and `loginWithNip55Web()` throws a
|
|
181
|
+
message pointing at `loginWithAndroidSigner()`.
|
|
182
|
+
|
|
183
|
+
`supportsNip55Web()` is a **capability** check, not an availability one:
|
|
184
|
+
there is no web API to detect an installed Android app, so it cannot tell
|
|
185
|
+
you whether a signer app is actually present. It answers "could this flow
|
|
186
|
+
possibly work here", not "will it succeed".
|
|
187
|
+
|
|
188
|
+
### What to know
|
|
189
|
+
|
|
190
|
+
- **Android + secure context only.** `supportsNip55Web()` requires an Android user agent, the async clipboard API, and not being in a native shell. `loginWithNip55Web()` throws before persisting anything when unsupported. `http://localhost` counts as a secure context, which is handy for local testing.
|
|
191
|
+
- **Chrome will ask to read the clipboard** the first time; approve it, or every read fails.
|
|
192
|
+
- **One approval per operation.** There is no background channel, so `getPublicKey`, every `signEvent`, and every `nip04`/`nip44` call re-opens the app. `unlock()` resumes the account from its cached pubkey without opening the app; the first real signing call prompts.
|
|
193
|
+
- **No rejection signal, but there is a timeout.** NIP-55's reject path is an Android intent extra a browser never sees, so a denial is indistinguishable from the user never returning. The package therefore times requests out (`timeoutMs`, default 120s; `0` disables) and rejects with a clear message. Pass a `signal` to cancel yourself — aborting rejects with `name === 'AbortError'`, matching the NIP-46 flow.
|
|
194
|
+
- **Signatures are verified.** `signEvent` computes the event id, sends the complete unsigned event, then checks the returned 128-char hex signature with `verifyEvent` before returning it.
|
|
195
|
+
- **The clipboard is clobbered** by the sentinel write. This is inherent to the transport; warn users if your app cares about clipboard contents.
|
|
196
|
+
- **Prefer NIP-46 when you can.** The NIP-55 spec itself recommends NIP-46 for web clients precisely because this flow can't run in the background. Keep the browser NIP-55 path for users who want their existing signer app without a pairing step.
|
|
197
|
+
|
|
198
|
+
The environment bridge is pluggable for tests and unusual hosts:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import type { Nip55WebTransport } from '@formstr/signer';
|
|
202
|
+
|
|
203
|
+
const s = createSigner({ nip55WebTransport: myTransport });
|
|
204
|
+
// or per call:
|
|
205
|
+
await s.loginWithNip55Web({ transport: myTransport });
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`browserNip55Transport()` is exported as the default implementation. `pollIntervalMs` (default 500) trades latency against how often the clipboard is read. `Nip55WebSigner` also exposes a `close()` that cancels an in-flight request and stops its poll; `Signer` calls it automatically when the active signer is replaced (a new login/unlock, `switchAccount`, or `logout`), so a pending request never leaks past its session.
|
|
209
|
+
|
|
136
210
|
## NIP-46 app identity (required for nostrconnect)
|
|
137
211
|
|
|
138
212
|
The nostrconnect URI you generate must include a `name` (and ideally `url`/`image`) so remote signer apps can show the user *which app* is asking to pair. Without it:
|
|
@@ -189,7 +263,7 @@ const detach = attachLoginListeners(container, signer, {
|
|
|
189
263
|
// later: detach();
|
|
190
264
|
```
|
|
191
265
|
|
|
192
|
-
The login modal renders one tab per method (Create, Existing key, Extension, Bunker URI, Remote QR, Android). The Android tab is always rendered but its list of installed signers is fetched lazily on activation via `signer.listAndroidSignerApps()` — it errors clearly if no Android plugin is configured (e.g. when running on web).
|
|
266
|
+
The login modal renders one tab per method (Create, Existing key, Extension, Bunker URI, Remote QR, Signer app, Android). The Android tab is always rendered but its list of installed signers is fetched lazily on activation via `signer.listAndroidSignerApps()` — it errors clearly if no Android plugin is configured (e.g. when running on web). The Signer app tab drives `loginWithNip55Web()` and needs no plugin; pass `renderLoginHtml({ includeNip55Web: signer.supportsNip55Web() })` to hide it in native builds.
|
|
193
267
|
|
|
194
268
|
## Errors
|
|
195
269
|
|
|
@@ -197,11 +271,12 @@ All `loginWith*` methods reject with bare `Error` instances. Categories you can
|
|
|
197
271
|
|
|
198
272
|
- **Validation** — empty passphrase, empty relays, malformed bunker URI.
|
|
199
273
|
- **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
|
|
200
|
-
- **External denial** — extension/bunker/Android signer rejects the request.
|
|
201
|
-
- **Transport** — NIP-46 relay unreachable, pairing timeout, abort.
|
|
274
|
+
- **External denial** — extension/bunker/Android signer rejects the request. The browser NIP-55 flow has no denial signal (see its section), so pair it with a timeout.
|
|
275
|
+
- **Transport** — NIP-46 relay unreachable, pairing timeout, abort; browser NIP-55 empty/inaccessible clipboard, or an unverifiable signature.
|
|
202
276
|
- **Configuration** —
|
|
203
277
|
- `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
|
|
204
278
|
- `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
|
|
279
|
+
- `loginWithNip55Web` throws if the environment is not an Android browser with clipboard access.
|
|
205
280
|
|
|
206
281
|
Error messages are prefixed with `@formstr/signer:` for messages the package generates itself. Errors from `nostr-tools` or the Capacitor plugin propagate unchanged. There is currently no typed `code` field — discriminate by string match or by which method threw.
|
|
207
282
|
|
|
@@ -244,6 +319,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
244
319
|
| `.nostr-signer__tab--extension` | NIP-07 tab |
|
|
245
320
|
| `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
|
|
246
321
|
| `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
|
|
322
|
+
| `.nostr-signer__tab--nip55web` | NIP-55 browser/`nostrsigner` tab |
|
|
247
323
|
| `.nostr-signer__tab--android` | NIP-55 Android tab |
|
|
248
324
|
|
|
249
325
|
### Panels
|
|
@@ -256,6 +332,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
256
332
|
| `.nostr-signer__panel--extension` | extension panel |
|
|
257
333
|
| `.nostr-signer__panel--bunker` | bunker URI panel |
|
|
258
334
|
| `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
|
|
335
|
+
| `.nostr-signer__panel--nip55web` | NIP-55 browser/`nostrsigner` panel |
|
|
259
336
|
| `.nostr-signer__panel--android` | Android signer panel |
|
|
260
337
|
| `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
|
|
261
338
|
|
package/dist/index.cjs
CHANGED
|
@@ -24,7 +24,9 @@ __export(src_exports, {
|
|
|
24
24
|
BunkerSigner: () => BunkerSigner,
|
|
25
25
|
ExtensionSigner: () => ExtensionSigner,
|
|
26
26
|
LocalSigner: () => LocalSigner,
|
|
27
|
+
Nip55WebSigner: () => Nip55WebSigner,
|
|
27
28
|
Signer: () => Signer,
|
|
29
|
+
browserNip55Transport: () => browserNip55Transport,
|
|
28
30
|
bytesToHex: () => bytesToHex,
|
|
29
31
|
connectWithBunkerUri: () => connectWithBunkerUri,
|
|
30
32
|
createSigner: () => createSigner,
|
|
@@ -35,12 +37,13 @@ __export(src_exports, {
|
|
|
35
37
|
hexToBytes: () => hexToBytes,
|
|
36
38
|
initiateNostrConnect: () => initiateNostrConnect,
|
|
37
39
|
localStorageAdapter: () => localStorageAdapter,
|
|
38
|
-
loginWithAndroidSigner: () => loginWithAndroidSigner
|
|
40
|
+
loginWithAndroidSigner: () => loginWithAndroidSigner,
|
|
41
|
+
normalizeNip55Identifier: () => normalizeNip55Identifier
|
|
39
42
|
});
|
|
40
43
|
module.exports = __toCommonJS(src_exports);
|
|
41
44
|
|
|
42
45
|
// src/core/signer.ts
|
|
43
|
-
var
|
|
46
|
+
var import_nostr_tools6 = require("nostr-tools");
|
|
44
47
|
var import_nip462 = require("nostr-tools/nip46");
|
|
45
48
|
|
|
46
49
|
// src/core/storage.ts
|
|
@@ -388,37 +391,294 @@ function describeIdentifier(value) {
|
|
|
388
391
|
const suffix = value.length > 12 ? "\u2026" : "";
|
|
389
392
|
return `"${prefix}${suffix}" (length=${value.length})`;
|
|
390
393
|
}
|
|
394
|
+
var HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
|
|
391
395
|
async function loginWithAndroidSigner(plugin, packageName) {
|
|
392
396
|
if (packageName) {
|
|
393
397
|
await plugin.setPackageName(packageName);
|
|
394
398
|
}
|
|
395
|
-
const { npub, package: pluginPackage } = await plugin.getPublicKey(packageName);
|
|
399
|
+
const { npub: rawIdentifier, package: pluginPackage } = await plugin.getPublicKey(packageName);
|
|
396
400
|
const resolvedPackage = pluginPackage || packageName;
|
|
397
401
|
if (!resolvedPackage) {
|
|
398
402
|
throw new Error(
|
|
399
403
|
"@formstr/signer: android signer did not return a package name and none was supplied"
|
|
400
404
|
);
|
|
401
405
|
}
|
|
406
|
+
const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
|
|
407
|
+
return {
|
|
408
|
+
signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
|
|
409
|
+
pubkey,
|
|
410
|
+
npub,
|
|
411
|
+
packageName: resolvedPackage
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function normalizeNip55Identifier(rawIdentifier) {
|
|
415
|
+
if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
|
|
416
|
+
const pubkey = rawIdentifier.toLowerCase();
|
|
417
|
+
return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
|
|
418
|
+
}
|
|
402
419
|
let decoded;
|
|
403
420
|
try {
|
|
404
|
-
decoded = import_nostr_tools4.nip19.decode(
|
|
421
|
+
decoded = import_nostr_tools4.nip19.decode(rawIdentifier);
|
|
405
422
|
} catch (e) {
|
|
406
423
|
throw new Error(
|
|
407
|
-
`@formstr/signer:
|
|
424
|
+
`@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
|
|
408
425
|
);
|
|
409
426
|
}
|
|
410
|
-
if (decoded.type
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
427
|
+
if (decoded.type === "npub") {
|
|
428
|
+
return { pubkey: decoded.data, npub: rawIdentifier };
|
|
429
|
+
}
|
|
430
|
+
if (decoded.type === "nprofile") {
|
|
431
|
+
const pubkey = decoded.data.pubkey;
|
|
432
|
+
return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
|
|
414
433
|
}
|
|
434
|
+
throw new Error(
|
|
435
|
+
`@formstr/signer: signer returned a non-pubkey identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/nip55Web.ts
|
|
440
|
+
var import_nostr_tools5 = require("nostr-tools");
|
|
441
|
+
var DEFAULT_POLL_INTERVAL_MS = 500;
|
|
442
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
443
|
+
var sentinelCounter = 0;
|
|
444
|
+
function makeSentinel() {
|
|
445
|
+
sentinelCounter += 1;
|
|
446
|
+
return `__formstr_nip55_sentinel_${Date.now()}_${sentinelCounter}__`;
|
|
447
|
+
}
|
|
448
|
+
function abortError() {
|
|
449
|
+
const error = new Error("@formstr/signer: NIP-55 request aborted");
|
|
450
|
+
error.name = "AbortError";
|
|
451
|
+
return error;
|
|
452
|
+
}
|
|
453
|
+
function isNativeShell() {
|
|
454
|
+
const cap = globalThis.Capacitor;
|
|
455
|
+
return typeof cap?.isNativePlatform === "function" && cap.isNativePlatform();
|
|
456
|
+
}
|
|
457
|
+
function browserNip55Transport() {
|
|
415
458
|
return {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
459
|
+
isSupported() {
|
|
460
|
+
return typeof navigator !== "undefined" && !isNativeShell() && /Android/i.test(navigator.userAgent) && typeof navigator.clipboard?.readText === "function";
|
|
461
|
+
},
|
|
462
|
+
open(intent) {
|
|
463
|
+
window.open(intent, "_blank");
|
|
464
|
+
},
|
|
465
|
+
readClipboard() {
|
|
466
|
+
return navigator.clipboard.readText();
|
|
467
|
+
},
|
|
468
|
+
writeClipboard(text) {
|
|
469
|
+
return navigator.clipboard.writeText(text);
|
|
470
|
+
}
|
|
420
471
|
};
|
|
421
472
|
}
|
|
473
|
+
var Nip55WebSigner = class _Nip55WebSigner {
|
|
474
|
+
#transport;
|
|
475
|
+
#pollIntervalMs;
|
|
476
|
+
#timeoutMs;
|
|
477
|
+
#signal;
|
|
478
|
+
#debug;
|
|
479
|
+
#pending = null;
|
|
480
|
+
#pubkey = null;
|
|
481
|
+
constructor(options = {}) {
|
|
482
|
+
this.#transport = options.transport ?? browserNip55Transport();
|
|
483
|
+
this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
484
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
485
|
+
this.#signal = options.signal;
|
|
486
|
+
this.#debug = options.debug;
|
|
487
|
+
this.#pubkey = options.pubkey ?? null;
|
|
488
|
+
}
|
|
489
|
+
#log(message) {
|
|
490
|
+
this.#debug?.(message);
|
|
491
|
+
}
|
|
492
|
+
/** True when the configured transport can actually open a signer app. */
|
|
493
|
+
isSupported() {
|
|
494
|
+
return this.#transport.isSupported();
|
|
495
|
+
}
|
|
496
|
+
async getPublicKey() {
|
|
497
|
+
if (this.#pubkey !== null) return this.#pubkey;
|
|
498
|
+
this.#checkSupport();
|
|
499
|
+
const raw = await this.#request(_Nip55WebSigner.getPublicKeyIntent());
|
|
500
|
+
const { pubkey } = normalizeNip55Identifier(raw);
|
|
501
|
+
this.#pubkey = pubkey;
|
|
502
|
+
return pubkey;
|
|
503
|
+
}
|
|
504
|
+
async signEvent(event) {
|
|
505
|
+
this.#checkSupport();
|
|
506
|
+
const pubkey = this.#pubkey ?? await this.getPublicKey();
|
|
507
|
+
const unsigned = { ...event, pubkey };
|
|
508
|
+
const draftWithId = { ...unsigned, id: (0, import_nostr_tools5.getEventHash)(unsigned) };
|
|
509
|
+
const sig = (await this.#request(_Nip55WebSigner.signEventIntent(draftWithId))).trim();
|
|
510
|
+
if (!/^[0-9a-f]{128}$/i.test(sig)) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
"@formstr/signer: NIP-55 signer did not return a hex signature"
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const signed = { ...draftWithId, sig: sig.toLowerCase() };
|
|
516
|
+
if (!(0, import_nostr_tools5.verifyEvent)(signed)) {
|
|
517
|
+
throw new Error("@formstr/signer: NIP-55 signer returned an invalid signature");
|
|
518
|
+
}
|
|
519
|
+
return signed;
|
|
520
|
+
}
|
|
521
|
+
async nip04Encrypt(peerPubkey, plaintext) {
|
|
522
|
+
this.#checkSupport();
|
|
523
|
+
return this.#request(_Nip55WebSigner.nip04EncryptIntent(peerPubkey, plaintext));
|
|
524
|
+
}
|
|
525
|
+
async nip04Decrypt(peerPubkey, ciphertext) {
|
|
526
|
+
this.#checkSupport();
|
|
527
|
+
return this.#request(_Nip55WebSigner.nip04DecryptIntent(peerPubkey, ciphertext));
|
|
528
|
+
}
|
|
529
|
+
async nip44Encrypt(peerPubkey, plaintext) {
|
|
530
|
+
this.#checkSupport();
|
|
531
|
+
return this.#request(_Nip55WebSigner.nip44EncryptIntent(peerPubkey, plaintext));
|
|
532
|
+
}
|
|
533
|
+
async nip44Decrypt(peerPubkey, ciphertext) {
|
|
534
|
+
this.#checkSupport();
|
|
535
|
+
return this.#request(_Nip55WebSigner.nip44DecryptIntent(peerPubkey, ciphertext));
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Cancel any in-flight request and stop its clipboard poll. Subsequent
|
|
539
|
+
* operations still work — this is a teardown of live resources, not a
|
|
540
|
+
* permanent disable (the {@link Signer} calls it when replacing the
|
|
541
|
+
* active signer).
|
|
542
|
+
*/
|
|
543
|
+
close() {
|
|
544
|
+
if (this.#pending) {
|
|
545
|
+
const pending = this.#pending;
|
|
546
|
+
this.#settle();
|
|
547
|
+
pending.reject(abortError());
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
#checkSupport() {
|
|
551
|
+
if (this.#transport.isSupported()) return;
|
|
552
|
+
if (isNativeShell()) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
"@formstr/signer: the browser NIP-55 flow is not for native builds \u2014 use loginWithAndroidSigner() with the Capacitor plugin instead"
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
throw new Error(
|
|
558
|
+
"@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
/** One poll tick: read the clipboard and settle if the signer answered. */
|
|
562
|
+
#poll = async (pending) => {
|
|
563
|
+
let text;
|
|
564
|
+
try {
|
|
565
|
+
text = await this.#transport.readClipboard();
|
|
566
|
+
} catch (error) {
|
|
567
|
+
this.#log(`clipboard read failed: ${error.message}`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (this.#pending !== pending) return;
|
|
571
|
+
const trimmed = text.trim();
|
|
572
|
+
if (trimmed.length === 0) return;
|
|
573
|
+
if (pending.sentinel !== null && trimmed === pending.sentinel) return;
|
|
574
|
+
this.#log(`clipboard result (${trimmed.length} chars)`);
|
|
575
|
+
this.#settle();
|
|
576
|
+
pending.resolve(trimmed);
|
|
577
|
+
};
|
|
578
|
+
#request(intent) {
|
|
579
|
+
this.#checkAborted();
|
|
580
|
+
this.#cancelPending();
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
const pending = {
|
|
583
|
+
resolve,
|
|
584
|
+
reject,
|
|
585
|
+
sentinel: null,
|
|
586
|
+
poll: null,
|
|
587
|
+
timer: null,
|
|
588
|
+
onAbort: null
|
|
589
|
+
};
|
|
590
|
+
this.#pending = pending;
|
|
591
|
+
if (this.#signal) {
|
|
592
|
+
const onAbort = () => this.#fail(pending, abortError());
|
|
593
|
+
this.#signal.addEventListener("abort", onAbort);
|
|
594
|
+
pending.onAbort = onAbort;
|
|
595
|
+
}
|
|
596
|
+
if (this.#timeoutMs > 0) {
|
|
597
|
+
pending.timer = setTimeout(() => {
|
|
598
|
+
this.#fail(
|
|
599
|
+
pending,
|
|
600
|
+
new Error(
|
|
601
|
+
`@formstr/signer: NIP-55 request timed out after ${this.#timeoutMs}ms (the signer app never returned a result)`
|
|
602
|
+
)
|
|
603
|
+
);
|
|
604
|
+
}, this.#timeoutMs);
|
|
605
|
+
}
|
|
606
|
+
void (async () => {
|
|
607
|
+
try {
|
|
608
|
+
const sentinel = makeSentinel();
|
|
609
|
+
await this.#transport.writeClipboard(sentinel);
|
|
610
|
+
if (this.#pending === pending) pending.sentinel = sentinel;
|
|
611
|
+
this.#log("planted clipboard sentinel");
|
|
612
|
+
} catch (error) {
|
|
613
|
+
this.#log(`sentinel write failed: ${error.message}`);
|
|
614
|
+
}
|
|
615
|
+
if (this.#pending !== pending) return;
|
|
616
|
+
try {
|
|
617
|
+
this.#log(`opening signer app: ${intent.slice(0, 80)}\u2026`);
|
|
618
|
+
this.#transport.open(intent);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
this.#fail(pending, error);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
pending.poll = setInterval(() => {
|
|
624
|
+
void this.#poll(pending);
|
|
625
|
+
}, this.#pollIntervalMs);
|
|
626
|
+
})();
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Reject the current request. Every caller is cleared on settle — the
|
|
631
|
+
* timeout timer and abort listener are removed, and `open()` is
|
|
632
|
+
* synchronous — so `pending` is always the active request here.
|
|
633
|
+
*/
|
|
634
|
+
#fail(pending, error) {
|
|
635
|
+
this.#settle();
|
|
636
|
+
pending.reject(error);
|
|
637
|
+
}
|
|
638
|
+
#settle() {
|
|
639
|
+
const pending = this.#pending;
|
|
640
|
+
this.#pending = null;
|
|
641
|
+
if (pending?.poll) clearInterval(pending.poll);
|
|
642
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
643
|
+
if (pending?.onAbort && this.#signal) {
|
|
644
|
+
this.#signal.removeEventListener("abort", pending.onAbort);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
#cancelPending() {
|
|
648
|
+
if (!this.#pending) return;
|
|
649
|
+
const pending = this.#pending;
|
|
650
|
+
this.#settle();
|
|
651
|
+
pending.reject(new Error("@formstr/signer: NIP-55 request superseded"));
|
|
652
|
+
}
|
|
653
|
+
#checkAborted() {
|
|
654
|
+
if (this.#signal?.aborted) throw abortError();
|
|
655
|
+
}
|
|
656
|
+
static getPublicKeyIntent() {
|
|
657
|
+
return "intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end";
|
|
658
|
+
}
|
|
659
|
+
static signEventIntent(draft) {
|
|
660
|
+
return `intent:${encodeURIComponent(
|
|
661
|
+
JSON.stringify(draft)
|
|
662
|
+
)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
|
|
663
|
+
}
|
|
664
|
+
static nip04EncryptIntent(peerPubkey, plaintext) {
|
|
665
|
+
return _Nip55WebSigner.#cryptoIntent("nip04_encrypt", peerPubkey, plaintext);
|
|
666
|
+
}
|
|
667
|
+
static nip04DecryptIntent(peerPubkey, ciphertext) {
|
|
668
|
+
return _Nip55WebSigner.#cryptoIntent("nip04_decrypt", peerPubkey, ciphertext);
|
|
669
|
+
}
|
|
670
|
+
static nip44EncryptIntent(peerPubkey, plaintext) {
|
|
671
|
+
return _Nip55WebSigner.#cryptoIntent("nip44_encrypt", peerPubkey, plaintext);
|
|
672
|
+
}
|
|
673
|
+
static nip44DecryptIntent(peerPubkey, ciphertext) {
|
|
674
|
+
return _Nip55WebSigner.#cryptoIntent("nip44_decrypt", peerPubkey, ciphertext);
|
|
675
|
+
}
|
|
676
|
+
static #cryptoIntent(type, peerPubkey, payload) {
|
|
677
|
+
return `intent:${encodeURIComponent(
|
|
678
|
+
payload
|
|
679
|
+
)}#Intent;scheme=nostrsigner;S.pubKey=${peerPubkey};S.compressionType=none;S.returnType=signature;S.type=${type};end`;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
422
682
|
|
|
423
683
|
// src/core/signer.ts
|
|
424
684
|
var ACCOUNTS_KEY = "accounts";
|
|
@@ -426,6 +686,7 @@ var ACTIVE_KEY = "active-pubkey";
|
|
|
426
686
|
var Signer = class {
|
|
427
687
|
#storage;
|
|
428
688
|
#defaultAndroidPlugin;
|
|
689
|
+
#nip55WebTransport;
|
|
429
690
|
#appMetadata;
|
|
430
691
|
#accounts = [];
|
|
431
692
|
#activePubkey = null;
|
|
@@ -434,6 +695,7 @@ var Signer = class {
|
|
|
434
695
|
constructor(config = {}) {
|
|
435
696
|
this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
|
|
436
697
|
this.#defaultAndroidPlugin = config.androidSignerPlugin;
|
|
698
|
+
this.#nip55WebTransport = config.nip55WebTransport;
|
|
437
699
|
this.#appMetadata = {
|
|
438
700
|
name: config.appName,
|
|
439
701
|
url: config.appUrl,
|
|
@@ -467,8 +729,28 @@ var Signer = class {
|
|
|
467
729
|
else this.#accounts.push(account);
|
|
468
730
|
this.#persistAccounts();
|
|
469
731
|
}
|
|
732
|
+
/**
|
|
733
|
+
* Release the currently-held signer, if it has a `close()`. Called
|
|
734
|
+
* whenever the active signer is replaced or cleared so live resources
|
|
735
|
+
* (bunker subscriptions, `visibilitychange` listeners, in-flight NIP-55
|
|
736
|
+
* requests) don't outlive the session. Errors are swallowed — teardown
|
|
737
|
+
* must never block a login/switch/logout.
|
|
738
|
+
*/
|
|
739
|
+
#closeActiveSigner() {
|
|
740
|
+
const signer = this.#activeSigner;
|
|
741
|
+
if (!signer?.close) return;
|
|
742
|
+
try {
|
|
743
|
+
const result = signer.close();
|
|
744
|
+
if (result && typeof result.catch === "function") {
|
|
745
|
+
result.catch(() => {
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
} catch {
|
|
749
|
+
}
|
|
750
|
+
}
|
|
470
751
|
#setActive(account, signer) {
|
|
471
752
|
const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
|
|
753
|
+
if (this.#activeSigner !== signer) this.#closeActiveSigner();
|
|
472
754
|
this.#activePubkey = account.pubkey;
|
|
473
755
|
this.#activeSigner = signer;
|
|
474
756
|
this.#persistActive();
|
|
@@ -510,8 +792,8 @@ var Signer = class {
|
|
|
510
792
|
if (!ncryptsec) throw new Error("loginWithNcryptsec: ncryptsec required");
|
|
511
793
|
if (!passphrase) throw new Error("loginWithNcryptsec: passphrase required");
|
|
512
794
|
const secretKey = decryptNcryptsec(ncryptsec, passphrase);
|
|
513
|
-
const pubkey = (0,
|
|
514
|
-
const npub =
|
|
795
|
+
const pubkey = (0, import_nostr_tools6.getPublicKey)(secretKey);
|
|
796
|
+
const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
|
|
515
797
|
const account = { npub, pubkey, method: "ncryptsec", ncryptsec };
|
|
516
798
|
this.#upsertAccount(account);
|
|
517
799
|
this.#setActive(account, new LocalSigner(secretKey));
|
|
@@ -526,7 +808,7 @@ var Signer = class {
|
|
|
526
808
|
async loginWithExtension() {
|
|
527
809
|
const extension = new ExtensionSigner();
|
|
528
810
|
const pubkey = await extension.getPublicKey();
|
|
529
|
-
const npub =
|
|
811
|
+
const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
|
|
530
812
|
const account = { npub, pubkey, method: "extension" };
|
|
531
813
|
this.#upsertAccount(account);
|
|
532
814
|
this.#setActive(account, extension);
|
|
@@ -543,7 +825,7 @@ var Signer = class {
|
|
|
543
825
|
*/
|
|
544
826
|
async loginWithBunkerUri(uri, options = {}) {
|
|
545
827
|
const result = await connectWithBunkerUri(uri, options);
|
|
546
|
-
const npub =
|
|
828
|
+
const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
|
|
547
829
|
const account = {
|
|
548
830
|
npub,
|
|
549
831
|
pubkey: result.pubkey,
|
|
@@ -595,7 +877,7 @@ var Signer = class {
|
|
|
595
877
|
});
|
|
596
878
|
options.onUri(init.uri);
|
|
597
879
|
const result = await init.complete;
|
|
598
|
-
const npub =
|
|
880
|
+
const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
|
|
599
881
|
const account = {
|
|
600
882
|
npub,
|
|
601
883
|
pubkey: result.pubkey,
|
|
@@ -660,6 +942,69 @@ var Signer = class {
|
|
|
660
942
|
this.#setActive(account, result.signer);
|
|
661
943
|
return account;
|
|
662
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Whether `loginWithNip55Web` can run in this environment — a plain
|
|
947
|
+
* Android browser with async clipboard access. False in a Capacitor
|
|
948
|
+
* native shell (use {@link loginWithAndroidSigner} there), and on
|
|
949
|
+
* desktop/iOS/SSR.
|
|
950
|
+
*
|
|
951
|
+
* A **capability** check, not an availability one: there is no web API
|
|
952
|
+
* to detect an installed Android app, so this says nothing about
|
|
953
|
+
* whether a signer app is actually installed. Use it to hide the
|
|
954
|
+
* browser flow where it cannot work, not to promise that it will.
|
|
955
|
+
*/
|
|
956
|
+
supportsNip55Web(transport) {
|
|
957
|
+
const t = transport ?? this.#nip55WebTransport ?? browserNip55Transport();
|
|
958
|
+
return t.isSupported();
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Sign in via a NIP-55 Android external signer (Amber, etc) **from a
|
|
962
|
+
* plain browser**, with no Capacitor/native bridge. Opens the installed
|
|
963
|
+
* signer app through a `nostrsigner` intent and reads the result back
|
|
964
|
+
* from the clipboard once the user returns to the tab. Because the
|
|
965
|
+
* intent names no package, this works with any app that registered the
|
|
966
|
+
* `nostrsigner` scheme — one opens directly, several show the Android
|
|
967
|
+
* "Open with" chooser.
|
|
968
|
+
*
|
|
969
|
+
* Not for native builds — inside a Capacitor shell use
|
|
970
|
+
* {@link loginWithAndroidSigner}, which needs no clipboard and no
|
|
971
|
+
* per-operation approval.
|
|
972
|
+
*
|
|
973
|
+
* Every operation is a separate approval, and a rejection is
|
|
974
|
+
* indistinguishable from the user simply not returning, so callers must
|
|
975
|
+
* impose their own timeout. Prefer NIP-46 when a persistent session is
|
|
976
|
+
* acceptable — the NIP-55 spec recommends it for web clients.
|
|
977
|
+
*
|
|
978
|
+
* @throws if the environment cannot run the flow (not Android, no async
|
|
979
|
+
* clipboard) or the signer returns an unexpected value.
|
|
980
|
+
*/
|
|
981
|
+
async loginWithNip55Web(options = {}) {
|
|
982
|
+
const transport = options.transport ?? this.#nip55WebTransport;
|
|
983
|
+
const pairing = new Nip55WebSigner({
|
|
984
|
+
transport,
|
|
985
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
986
|
+
timeoutMs: options.timeoutMs,
|
|
987
|
+
signal: options.signal,
|
|
988
|
+
debug: options.debug
|
|
989
|
+
});
|
|
990
|
+
const pubkey = await pairing.getPublicKey();
|
|
991
|
+
const signer = new Nip55WebSigner({
|
|
992
|
+
transport,
|
|
993
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
994
|
+
timeoutMs: options.timeoutMs,
|
|
995
|
+
pubkey,
|
|
996
|
+
debug: options.debug
|
|
997
|
+
});
|
|
998
|
+
const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
|
|
999
|
+
const account = {
|
|
1000
|
+
npub,
|
|
1001
|
+
pubkey,
|
|
1002
|
+
method: "nip55-web"
|
|
1003
|
+
};
|
|
1004
|
+
this.#upsertAccount(account);
|
|
1005
|
+
this.#setActive(account, signer);
|
|
1006
|
+
return account;
|
|
1007
|
+
}
|
|
663
1008
|
/** Snapshot of every persisted account, in insertion order. */
|
|
664
1009
|
listAccounts() {
|
|
665
1010
|
return [...this.#accounts];
|
|
@@ -713,6 +1058,10 @@ var Signer = class {
|
|
|
713
1058
|
* {@link loginWithAndroidSigner} performs and that — on Amber —
|
|
714
1059
|
* surfaces as a permission prompt every cold start.
|
|
715
1060
|
*
|
|
1061
|
+
* - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
|
|
1062
|
+
* `pubkey` cached. Like `android`, this opens no signer app during
|
|
1063
|
+
* unlock; the first sign/encrypt call is what prompts.
|
|
1064
|
+
*
|
|
716
1065
|
* - `ncryptsec`: returns `null`. There is no silent path — the user's
|
|
717
1066
|
* passphrase isn't (and shouldn't be) persisted. The caller must
|
|
718
1067
|
* drive the passphrase prompt and call {@link loginWithNcryptsec}.
|
|
@@ -761,6 +1110,14 @@ var Signer = class {
|
|
|
761
1110
|
this.#setActive(account, signer);
|
|
762
1111
|
return signer;
|
|
763
1112
|
}
|
|
1113
|
+
case "nip55-web": {
|
|
1114
|
+
const signer = new Nip55WebSigner({
|
|
1115
|
+
transport: this.#nip55WebTransport,
|
|
1116
|
+
pubkey: account.pubkey
|
|
1117
|
+
});
|
|
1118
|
+
this.#setActive(account, signer);
|
|
1119
|
+
return signer;
|
|
1120
|
+
}
|
|
764
1121
|
case "ncryptsec":
|
|
765
1122
|
return null;
|
|
766
1123
|
}
|
|
@@ -775,6 +1132,7 @@ var Signer = class {
|
|
|
775
1132
|
async switchAccount(pubkey) {
|
|
776
1133
|
const account = this.#accounts.find((a) => a.pubkey === pubkey);
|
|
777
1134
|
if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
|
|
1135
|
+
this.#closeActiveSigner();
|
|
778
1136
|
this.#activePubkey = pubkey;
|
|
779
1137
|
this.#activeSigner = null;
|
|
780
1138
|
this.#persistActive();
|
|
@@ -791,6 +1149,7 @@ var Signer = class {
|
|
|
791
1149
|
this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
|
|
792
1150
|
this.#persistAccounts();
|
|
793
1151
|
if (this.#activePubkey === target) {
|
|
1152
|
+
this.#closeActiveSigner();
|
|
794
1153
|
this.#activePubkey = null;
|
|
795
1154
|
this.#activeSigner = null;
|
|
796
1155
|
this.#persistActive();
|
|
@@ -818,7 +1177,9 @@ function createSigner(config = {}) {
|
|
|
818
1177
|
BunkerSigner,
|
|
819
1178
|
ExtensionSigner,
|
|
820
1179
|
LocalSigner,
|
|
1180
|
+
Nip55WebSigner,
|
|
821
1181
|
Signer,
|
|
1182
|
+
browserNip55Transport,
|
|
822
1183
|
bytesToHex,
|
|
823
1184
|
connectWithBunkerUri,
|
|
824
1185
|
createSigner,
|
|
@@ -829,6 +1190,7 @@ function createSigner(config = {}) {
|
|
|
829
1190
|
hexToBytes,
|
|
830
1191
|
initiateNostrConnect,
|
|
831
1192
|
localStorageAdapter,
|
|
832
|
-
loginWithAndroidSigner
|
|
1193
|
+
loginWithAndroidSigner,
|
|
1194
|
+
normalizeNip55Identifier
|
|
833
1195
|
});
|
|
834
1196
|
//# sourceMappingURL=index.cjs.map
|