@formstr/signer 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abhay Raizada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # @formstr/signer
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), and NIP-55 (Android external signer apps).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @formstr/signer
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createSigner } from '@formstr/signer';
15
+
16
+ // `appName` is required if you plan to use nostrconnect (NIP-46 QR flow) —
17
+ // see "NIP-46 app identity" below. Other login methods don't depend on it.
18
+ const signer = createSigner({ appName: 'my-app' });
19
+
20
+ // Create a new account (NIP-49 ncryptsec encrypted at rest)
21
+ const { npub, ncryptsec } = await signer.createAccount('my-passphrase');
22
+
23
+ // Subsequent sessions: log in with the encrypted nsec
24
+ await signer.loginWithNcryptsec(ncryptsec, 'my-passphrase');
25
+
26
+ // Or any of the other methods
27
+ await signer.loginWithExtension();
28
+ await signer.loginWithBunkerUri('bunker://...');
29
+ await signer.loginWithNostrConnect({ relays: ['wss://relay.example'], onUri: (uri) => /* show QR */ });
30
+ await signer.loginWithAndroidSigner({ packageName: 'com.greenart7c3.nostrsigner' });
31
+
32
+ // Sign events — the active signer never exposes the privkey
33
+ const active = signer.getActiveSigner()!;
34
+ const signed = await active.signEvent({ kind: 1, content: 'gm', tags: [], created_at: 0 });
35
+ ```
36
+
37
+ ## Account model
38
+
39
+ A `StoredAccount` is the persisted record for one identity. Accounts survive page reloads via the configured `StorageAdapter`. Listing, switching, and removing are independent of unlock state.
40
+
41
+ ```ts
42
+ signer.listAccounts(); // every persisted account
43
+ signer.getActiveAccount(); // currently selected — present even when locked
44
+ signer.getActiveSigner(); // unlocked signer — null until re-auth
45
+ await signer.switchAccount(pubkey);
46
+ await signer.logout(pubkey); // pubkey defaults to active
47
+
48
+ const unsub = signer.onChange((ev) => {
49
+ // ev.type is 'login' | 'switch' | 'logout'
50
+ });
51
+ ```
52
+
53
+ ## Hydration & locked state
54
+
55
+ The single most important thing to understand about this package: **after a fresh page load, every account starts locked**. That means:
56
+
57
+ - `listAccounts()` returns the saved accounts.
58
+ - `getActiveAccount()` returns the account that was active before reload.
59
+ - `getActiveSigner()` returns **`null`**, regardless of method.
60
+
61
+ To unlock, call the matching `loginWith*` again:
62
+
63
+ | Method | Unlock action |
64
+ | --- | --- |
65
+ | `ncryptsec` | prompt for the passphrase, call `loginWithNcryptsec(account.ncryptsec, passphrase)` |
66
+ | `extension` | call `loginWithExtension()` — the extension may auto-grant if previously approved |
67
+ | `nip46` | call `loginWithBunkerUri(account.nip46.uri, { clientSecretKey: hexToBytes(account.nip46.clientSecretKey) })` to resume |
68
+ | `android` | call `loginWithAndroidSigner({ packageName: account.androidPackageName })` |
69
+
70
+ The pattern in the UI is: **always render off `getActiveAccount()`, gate signing on `getActiveSigner()`**. Show "logged in as @alice" plus an "Unlock" button when the signer is null.
71
+
72
+ ## The `ActiveSigner` contract
73
+
74
+ Every unlock path produces an `ActiveSigner`. The interface is intentionally small:
75
+
76
+ ```ts
77
+ interface ActiveSigner {
78
+ getPublicKey(): Promise<string>;
79
+ signEvent(event: EventTemplate): Promise<NostrEvent>;
80
+ nip04Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
81
+ nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
82
+ nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
83
+ nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
84
+ }
85
+ ```
86
+
87
+ - `signEvent` accepts an `EventTemplate` (no `pubkey`/`id`/`sig`); the implementation fills those in and returns a fully-signed event.
88
+ - `peerPubkey` is the counterparty's 32-byte x-only hex pubkey.
89
+ - **There is no `getPrivateKey()`.** That omission is the package's central security invariant — the raw key is unreachable through this surface, even for the local-key signer.
90
+
91
+ Building a custom signer (hardware wallet, MPC, browser-stored hot wallet) is a matter of conforming to this interface. The package does not currently expose a way to register a custom signer type into a `StoredAccount`; you'd hold the instance yourself and pass it into your event-signing path directly.
92
+
93
+ ## NIP-55 (Android) custom plugins
94
+
95
+ The default expectation is that you pass [`nostr-signer-capacitor-plugin`](https://www.npmjs.com/package/nostr-signer-capacitor-plugin) as `androidSignerPlugin`. To plug in a different implementation, conform to `AndroidSignerPlugin`:
96
+
97
+ ```ts
98
+ import type { AndroidSignerPlugin } from '@formstr/signer';
99
+
100
+ const myPlugin: AndroidSignerPlugin = {
101
+ setPackageName(packageName) { /* ... */ },
102
+ getInstalledSignerApps() { /* ... */ },
103
+ getPublicKey(packageName, permissions) { /* ... */ },
104
+ signEvent(packageName, eventJson, id, npub) { /* ... */ },
105
+ nip04Encrypt(packageName, plainText, id, pubKey, npub) { /* ... */ },
106
+ // nip04Decrypt, nip44Encrypt, nip44Decrypt likewise
107
+ };
108
+ ```
109
+
110
+ 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.
111
+
112
+ ## NIP-46 app identity (required for nostrconnect)
113
+
114
+ 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:
115
+
116
+ - **Amber** receives the request but never surfaces an approve/deny prompt — the consent UI requires a recognizable client identity. The pairing silently stalls.
117
+ - Other signers will at minimum display a shortened pubkey hex instead of your app name.
118
+
119
+ The package enforces this at runtime — `loginWithNostrConnect()` throws if no name can be resolved:
120
+
121
+ ```
122
+ @formstr/signer: loginWithNostrConnect requires an app name. Set `appName` in createSigner() or pass `metadata.name` to loginWithNostrConnect().
123
+ ```
124
+
125
+ Two ways to supply it. Set once at construction (recommended for most apps):
126
+
127
+ ```ts
128
+ const signer = createSigner({
129
+ appName: 'my-app',
130
+ appUrl: 'https://my-app.example', // optional
131
+ appImage: 'https://my-app.example/icon.png', // optional
132
+ });
133
+ await signer.loginWithNostrConnect({ relays: ['wss://...'], onUri });
134
+ ```
135
+
136
+ Or override per call:
137
+
138
+ ```ts
139
+ await signer.loginWithNostrConnect({
140
+ relays: ['wss://...'],
141
+ onUri,
142
+ metadata: { name: 'temporary-name', url: '...', image: '...' },
143
+ });
144
+ ```
145
+
146
+ Per-call `metadata.*` fields override config defaults field-by-field — set `metadata.name` without `metadata.url`, and the config's `appUrl` still applies.
147
+
148
+ The bunker:// flow (`loginWithBunkerUri`) is unaffected — NIP-46 has no spec slot for client metadata in that flow, and Amber recognizes apps via the secret embedded in the bunker URI it generated. App name shown there comes from what the user named the connection inside Amber.
149
+
150
+ ## UI helpers
151
+
152
+ The UI module returns HTML strings. The calling code injects the markup wherever it wants and then calls `attach*Listeners()` to wire events.
153
+
154
+ ```ts
155
+ import { renderLoginHtml, attachLoginListeners } from '@formstr/signer/ui';
156
+ import '@formstr/signer/styles.css'; // optional
157
+
158
+ const container = document.getElementById('signer-root')!;
159
+ container.innerHTML = renderLoginHtml();
160
+ const detach = attachLoginListeners(container, signer, {
161
+ onLogin: ({ npub }) => console.log('logged in', npub),
162
+ onError: (err) => console.error(err),
163
+ });
164
+
165
+ // later: detach();
166
+ ```
167
+
168
+ 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).
169
+
170
+ ## Errors
171
+
172
+ All `loginWith*` methods reject with bare `Error` instances. Categories you can expect:
173
+
174
+ - **Validation** — empty passphrase, empty relays, malformed bunker URI.
175
+ - **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
176
+ - **External denial** — extension/bunker/Android signer rejects the request.
177
+ - **Transport** — NIP-46 relay unreachable, pairing timeout, abort.
178
+ - **Configuration** —
179
+ - `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
180
+ - `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
181
+
182
+ 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.
183
+
184
+ ## Security model
185
+
186
+ - **Identity nsec at rest:** always encrypted with the user's passphrase (NIP-49). Account creation always requires a passphrase — there is no "guest" raw-nsec path.
187
+ - **Decrypted privkey:** held in memory only, for the lifetime of the page. Never written to `localStorage` or `sessionStorage`. Lost on page reload — the user re-enters the passphrase.
188
+ - **Active signer interface:** exposes `signEvent`, `nip04*`, `nip44*`, `getPublicKey` only. There is no method that returns the raw private key.
189
+ - **NIP-46 relays:** for bunker URIs, relays come from the URI. For the nostrconnect QR flow, the UI prompts the user for relays. There is no hardcoded fallback relay list.
190
+ - **NIP-46 client secret:** the per-account ephemeral keypair used to talk to the remote signer is stored in plaintext in the configured storage adapter. This is a deliberate tradeoff — see the threat-model note below.
191
+
192
+ ### Threat-model note on the NIP-46 client secret
193
+
194
+ The client secret is *not* the user's identity key — it is a disposable session key the remote signer recognizes as the client. An attacker with same-origin storage access could impersonate the client to the remote signer; whether that results in unauthorized signatures depends on whether the user has granted blanket permissions on the bunker side (out of our control). Encrypting this secret with a derivable key would not defend against same-origin XSS (the realistic attacker), so we keep it plaintext rather than adding security theater.
195
+
196
+ ## BEM class catalog
197
+
198
+ The UI ships with these class names. Override in your own CSS.
199
+
200
+ ### Layout
201
+
202
+ | Class | Purpose |
203
+ | --- | --- |
204
+ | `.nostr-signer__root` | top-level wrapper |
205
+ | `.nostr-signer__modal` | modal box |
206
+ | `.nostr-signer__header` | header bar |
207
+ | `.nostr-signer__title` | heading text |
208
+ | `.nostr-signer__close` | close button |
209
+ | `.nostr-signer__body` | scroll region containing the panels |
210
+
211
+ ### Tabs
212
+
213
+ | Class | Purpose |
214
+ | --- | --- |
215
+ | `.nostr-signer__tabs` | tab bar |
216
+ | `.nostr-signer__tab` | a tab button |
217
+ | `.nostr-signer__tab--active` | currently selected tab |
218
+ | `.nostr-signer__tab--create` | Create-account tab |
219
+ | `.nostr-signer__tab--ncryptsec` | NIP-49 ncryptsec tab |
220
+ | `.nostr-signer__tab--extension` | NIP-07 tab |
221
+ | `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
222
+ | `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
223
+ | `.nostr-signer__tab--android` | NIP-55 Android tab |
224
+
225
+ ### Panels
226
+
227
+ | Class | Purpose |
228
+ | --- | --- |
229
+ | `.nostr-signer__panel` | base panel |
230
+ | `.nostr-signer__panel--create` | create-account panel |
231
+ | `.nostr-signer__panel--ncryptsec` | ncryptsec panel |
232
+ | `.nostr-signer__panel--extension` | extension panel |
233
+ | `.nostr-signer__panel--bunker` | bunker URI panel |
234
+ | `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
235
+ | `.nostr-signer__panel--android` | Android signer panel |
236
+ | `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
237
+
238
+ ### Forms / inputs
239
+
240
+ | Class | Purpose |
241
+ | --- | --- |
242
+ | `.nostr-signer__form` | form wrapper |
243
+ | `.nostr-signer__label` | input label |
244
+ | `.nostr-signer__input` | text input base |
245
+ | `.nostr-signer__input--passphrase` | passphrase field |
246
+ | `.nostr-signer__input--ncryptsec` | ncryptsec textarea |
247
+ | `.nostr-signer__input--bunker-uri` | bunker URI textarea |
248
+ | `.nostr-signer__input--relays` | relays field (nostrconnect) |
249
+ | `.nostr-signer__input--perms` | permissions field (nostrconnect) |
250
+ | `.nostr-signer__ncryptsec-display` | post-creation ncryptsec display |
251
+
252
+ ### Android signer list
253
+
254
+ | Class | Purpose |
255
+ | --- | --- |
256
+ | `.nostr-signer__android-apps` | list of installed signer apps |
257
+ | `.nostr-signer__android-app` | one signer in the list |
258
+
259
+ ### QR
260
+
261
+ | Class | Purpose |
262
+ | --- | --- |
263
+ | `.nostr-signer__qr` | QR display wrapper |
264
+ | `.nostr-signer__qr-uri` | textual nostrconnect URI |
265
+
266
+ ### Buttons / status
267
+
268
+ | Class | Purpose |
269
+ | --- | --- |
270
+ | `.nostr-signer__button` | base button |
271
+ | `.nostr-signer__button--primary` | primary button |
272
+ | `.nostr-signer__button--secondary` | secondary button |
273
+ | `.nostr-signer__error` | error message |
274
+ | `.nostr-signer__status` | status / loading message |
275
+ | `.nostr-signer__hint` | hint text |