@formstr/signer 0.2.2 → 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 +80 -5
- package/dist/index.cjs +368 -15
- 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 +361 -8
- 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,7 +135,77 @@ 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
|
|
|
136
|
-
**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.
|
|
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.
|
|
137
209
|
|
|
138
210
|
## NIP-46 app identity (required for nostrconnect)
|
|
139
211
|
|
|
@@ -191,7 +263,7 @@ const detach = attachLoginListeners(container, signer, {
|
|
|
191
263
|
// later: detach();
|
|
192
264
|
```
|
|
193
265
|
|
|
194
|
-
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.
|
|
195
267
|
|
|
196
268
|
## Errors
|
|
197
269
|
|
|
@@ -199,11 +271,12 @@ All `loginWith*` methods reject with bare `Error` instances. Categories you can
|
|
|
199
271
|
|
|
200
272
|
- **Validation** — empty passphrase, empty relays, malformed bunker URI.
|
|
201
273
|
- **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
|
|
202
|
-
- **External denial** — extension/bunker/Android signer rejects the request.
|
|
203
|
-
- **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.
|
|
204
276
|
- **Configuration** —
|
|
205
277
|
- `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
|
|
206
278
|
- `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
|
|
279
|
+
- `loginWithNip55Web` throws if the environment is not an Android browser with clipboard access.
|
|
207
280
|
|
|
208
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.
|
|
209
282
|
|
|
@@ -246,6 +319,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
246
319
|
| `.nostr-signer__tab--extension` | NIP-07 tab |
|
|
247
320
|
| `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
|
|
248
321
|
| `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
|
|
322
|
+
| `.nostr-signer__tab--nip55web` | NIP-55 browser/`nostrsigner` tab |
|
|
249
323
|
| `.nostr-signer__tab--android` | NIP-55 Android tab |
|
|
250
324
|
|
|
251
325
|
### Panels
|
|
@@ -258,6 +332,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
258
332
|
| `.nostr-signer__panel--extension` | extension panel |
|
|
259
333
|
| `.nostr-signer__panel--bunker` | bunker URI panel |
|
|
260
334
|
| `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
|
|
335
|
+
| `.nostr-signer__panel--nip55web` | NIP-55 browser/`nostrsigner` panel |
|
|
261
336
|
| `.nostr-signer__panel--android` | Android signer panel |
|
|
262
337
|
| `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
|
|
263
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
|
|
@@ -400,7 +403,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
|
|
|
400
403
|
"@formstr/signer: android signer did not return a package name and none was supplied"
|
|
401
404
|
);
|
|
402
405
|
}
|
|
403
|
-
const { pubkey, npub } =
|
|
406
|
+
const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
|
|
404
407
|
return {
|
|
405
408
|
signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
|
|
406
409
|
pubkey,
|
|
@@ -408,7 +411,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
|
|
|
408
411
|
packageName: resolvedPackage
|
|
409
412
|
};
|
|
410
413
|
}
|
|
411
|
-
function
|
|
414
|
+
function normalizeNip55Identifier(rawIdentifier) {
|
|
412
415
|
if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
|
|
413
416
|
const pubkey = rawIdentifier.toLowerCase();
|
|
414
417
|
return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
|
|
@@ -418,16 +421,264 @@ function normalizeAndroidIdentifier(rawIdentifier) {
|
|
|
418
421
|
decoded = import_nostr_tools4.nip19.decode(rawIdentifier);
|
|
419
422
|
} catch (e) {
|
|
420
423
|
throw new Error(
|
|
421
|
-
`@formstr/signer:
|
|
424
|
+
`@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
|
|
422
425
|
);
|
|
423
426
|
}
|
|
424
|
-
if (decoded.type
|
|
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) };
|
|
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() {
|
|
458
|
+
return {
|
|
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
|
+
}
|
|
471
|
+
};
|
|
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
|
+
}
|
|
425
557
|
throw new Error(
|
|
426
|
-
|
|
558
|
+
"@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
|
|
427
559
|
);
|
|
428
560
|
}
|
|
429
|
-
|
|
430
|
-
|
|
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
|
+
};
|
|
431
682
|
|
|
432
683
|
// src/core/signer.ts
|
|
433
684
|
var ACCOUNTS_KEY = "accounts";
|
|
@@ -435,6 +686,7 @@ var ACTIVE_KEY = "active-pubkey";
|
|
|
435
686
|
var Signer = class {
|
|
436
687
|
#storage;
|
|
437
688
|
#defaultAndroidPlugin;
|
|
689
|
+
#nip55WebTransport;
|
|
438
690
|
#appMetadata;
|
|
439
691
|
#accounts = [];
|
|
440
692
|
#activePubkey = null;
|
|
@@ -443,6 +695,7 @@ var Signer = class {
|
|
|
443
695
|
constructor(config = {}) {
|
|
444
696
|
this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
|
|
445
697
|
this.#defaultAndroidPlugin = config.androidSignerPlugin;
|
|
698
|
+
this.#nip55WebTransport = config.nip55WebTransport;
|
|
446
699
|
this.#appMetadata = {
|
|
447
700
|
name: config.appName,
|
|
448
701
|
url: config.appUrl,
|
|
@@ -476,8 +729,28 @@ var Signer = class {
|
|
|
476
729
|
else this.#accounts.push(account);
|
|
477
730
|
this.#persistAccounts();
|
|
478
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
|
+
}
|
|
479
751
|
#setActive(account, signer) {
|
|
480
752
|
const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
|
|
753
|
+
if (this.#activeSigner !== signer) this.#closeActiveSigner();
|
|
481
754
|
this.#activePubkey = account.pubkey;
|
|
482
755
|
this.#activeSigner = signer;
|
|
483
756
|
this.#persistActive();
|
|
@@ -519,8 +792,8 @@ var Signer = class {
|
|
|
519
792
|
if (!ncryptsec) throw new Error("loginWithNcryptsec: ncryptsec required");
|
|
520
793
|
if (!passphrase) throw new Error("loginWithNcryptsec: passphrase required");
|
|
521
794
|
const secretKey = decryptNcryptsec(ncryptsec, passphrase);
|
|
522
|
-
const pubkey = (0,
|
|
523
|
-
const npub =
|
|
795
|
+
const pubkey = (0, import_nostr_tools6.getPublicKey)(secretKey);
|
|
796
|
+
const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
|
|
524
797
|
const account = { npub, pubkey, method: "ncryptsec", ncryptsec };
|
|
525
798
|
this.#upsertAccount(account);
|
|
526
799
|
this.#setActive(account, new LocalSigner(secretKey));
|
|
@@ -535,7 +808,7 @@ var Signer = class {
|
|
|
535
808
|
async loginWithExtension() {
|
|
536
809
|
const extension = new ExtensionSigner();
|
|
537
810
|
const pubkey = await extension.getPublicKey();
|
|
538
|
-
const npub =
|
|
811
|
+
const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
|
|
539
812
|
const account = { npub, pubkey, method: "extension" };
|
|
540
813
|
this.#upsertAccount(account);
|
|
541
814
|
this.#setActive(account, extension);
|
|
@@ -552,7 +825,7 @@ var Signer = class {
|
|
|
552
825
|
*/
|
|
553
826
|
async loginWithBunkerUri(uri, options = {}) {
|
|
554
827
|
const result = await connectWithBunkerUri(uri, options);
|
|
555
|
-
const npub =
|
|
828
|
+
const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
|
|
556
829
|
const account = {
|
|
557
830
|
npub,
|
|
558
831
|
pubkey: result.pubkey,
|
|
@@ -604,7 +877,7 @@ var Signer = class {
|
|
|
604
877
|
});
|
|
605
878
|
options.onUri(init.uri);
|
|
606
879
|
const result = await init.complete;
|
|
607
|
-
const npub =
|
|
880
|
+
const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
|
|
608
881
|
const account = {
|
|
609
882
|
npub,
|
|
610
883
|
pubkey: result.pubkey,
|
|
@@ -669,6 +942,69 @@ var Signer = class {
|
|
|
669
942
|
this.#setActive(account, result.signer);
|
|
670
943
|
return account;
|
|
671
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
|
+
}
|
|
672
1008
|
/** Snapshot of every persisted account, in insertion order. */
|
|
673
1009
|
listAccounts() {
|
|
674
1010
|
return [...this.#accounts];
|
|
@@ -722,6 +1058,10 @@ var Signer = class {
|
|
|
722
1058
|
* {@link loginWithAndroidSigner} performs and that — on Amber —
|
|
723
1059
|
* surfaces as a permission prompt every cold start.
|
|
724
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
|
+
*
|
|
725
1065
|
* - `ncryptsec`: returns `null`. There is no silent path — the user's
|
|
726
1066
|
* passphrase isn't (and shouldn't be) persisted. The caller must
|
|
727
1067
|
* drive the passphrase prompt and call {@link loginWithNcryptsec}.
|
|
@@ -770,6 +1110,14 @@ var Signer = class {
|
|
|
770
1110
|
this.#setActive(account, signer);
|
|
771
1111
|
return signer;
|
|
772
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
|
+
}
|
|
773
1121
|
case "ncryptsec":
|
|
774
1122
|
return null;
|
|
775
1123
|
}
|
|
@@ -784,6 +1132,7 @@ var Signer = class {
|
|
|
784
1132
|
async switchAccount(pubkey) {
|
|
785
1133
|
const account = this.#accounts.find((a) => a.pubkey === pubkey);
|
|
786
1134
|
if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
|
|
1135
|
+
this.#closeActiveSigner();
|
|
787
1136
|
this.#activePubkey = pubkey;
|
|
788
1137
|
this.#activeSigner = null;
|
|
789
1138
|
this.#persistActive();
|
|
@@ -800,6 +1149,7 @@ var Signer = class {
|
|
|
800
1149
|
this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
|
|
801
1150
|
this.#persistAccounts();
|
|
802
1151
|
if (this.#activePubkey === target) {
|
|
1152
|
+
this.#closeActiveSigner();
|
|
803
1153
|
this.#activePubkey = null;
|
|
804
1154
|
this.#activeSigner = null;
|
|
805
1155
|
this.#persistActive();
|
|
@@ -827,7 +1177,9 @@ function createSigner(config = {}) {
|
|
|
827
1177
|
BunkerSigner,
|
|
828
1178
|
ExtensionSigner,
|
|
829
1179
|
LocalSigner,
|
|
1180
|
+
Nip55WebSigner,
|
|
830
1181
|
Signer,
|
|
1182
|
+
browserNip55Transport,
|
|
831
1183
|
bytesToHex,
|
|
832
1184
|
connectWithBunkerUri,
|
|
833
1185
|
createSigner,
|
|
@@ -838,6 +1190,7 @@ function createSigner(config = {}) {
|
|
|
838
1190
|
hexToBytes,
|
|
839
1191
|
initiateNostrConnect,
|
|
840
1192
|
localStorageAdapter,
|
|
841
|
-
loginWithAndroidSigner
|
|
1193
|
+
loginWithAndroidSigner,
|
|
1194
|
+
normalizeNip55Identifier
|
|
842
1195
|
});
|
|
843
1196
|
//# sourceMappingURL=index.cjs.map
|