@estiva-app/protocol 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/src/nip19.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * NIP-19 `naddr` — bech32-encoded pointers to addressable events.
3
+ *
4
+ * This is how an object gets *out of* the app that owns it and *into* another:
5
+ * Ship encodes an issue as `nostr:naddr1…`, somebody pastes it into a Peek
6
+ * message (NIP-21 for the URI scheme, NIP-27 for the convention of embedding one
7
+ * in a text body), and Peek resolves it back to an address.
8
+ *
9
+ * ## Two implementations, and they already agreed
10
+ *
11
+ * Before SHA-3 this existed twice: Ship wrote an encoder, Peek wrote a decoder,
12
+ * and each grew the other half later. Their outputs were checked byte for byte
13
+ * during the extraction and were **identical** for the same pointer — so this
14
+ * merge is a deduplication and not a reconciliation. What each copy had that the
15
+ * other lacked is all kept: Ship's generic bech32 primitives and
16
+ * `addrToNaddr`/`naddrToAddr`, Peek's `pointerToAddress`/`addressToPointer`,
17
+ * `referenceToPointer` and `stripNaddrs`.
18
+ *
19
+ * ## Three things about the format that are easy to get wrong
20
+ *
21
+ * **Not bech32m.** NIP-19 predates bech32m and uses the original constant
22
+ * (`^ 1`). Encoding with bech32m produces a string that looks right, passes a
23
+ * casual eyeball, and fails every other implementation's checksum. A round-trip
24
+ * test cannot catch it — it would be wrong in both directions — which is why
25
+ * `bech32Encode`/`bech32Decode` are exported and pinned against the canonical
26
+ * NIP-19 `npub` vector from the specification itself.
27
+ *
28
+ * **The 90-character limit does not apply.** BIP-173 caps a bech32 string at 90
29
+ * characters for QR-code reasons; an `naddr` carrying a UUID `d` tag, a 32-byte
30
+ * pubkey and a relay hint runs to ~180. NIP-19 explicitly lifts the cap, so no
31
+ * length check appears here.
32
+ *
33
+ * **TLV order is not normative, and other implementations differ.** This encoder
34
+ * emits identifier, relays, author, kind. `nostr-tools` emits a different order,
35
+ * so the same pointer encodes to a *different string* — measured. Every decoder
36
+ * involved (this one, Ship's old one, Peek's old one, `nostr-tools`) reads TLVs
37
+ * by type and is order-tolerant, so the strings interoperate; but they are not
38
+ * comparable as strings, and nothing here should ever compare two naddrs for
39
+ * equality. Compare the addresses they decode to.
40
+ */
41
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'
42
+
43
+ /**
44
+ * `TextDecoder` is not in `lib.es2022`, and this package compiles with
45
+ * `types: []` and no `lib: dom` (ADR 0002 §4a). Declared inside this module so
46
+ * nothing lands in a consumer's global scope, and read inside a function body so
47
+ * importing this module touches no global.
48
+ */
49
+ declare const TextDecoder: { new (): { decode(input: Uint8Array): string } }
50
+
51
+ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
52
+ const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
53
+
54
+ function polymod(values: number[]): number {
55
+ let chk = 1
56
+ for (const value of values) {
57
+ const top = chk >>> 25
58
+ chk = ((chk & 0x1ffffff) << 5) ^ value
59
+ for (let i = 0; i < 5; i++) {
60
+ if ((top >>> i) & 1) chk ^= GENERATOR[i]
61
+ }
62
+ }
63
+ return chk >>> 0
64
+ }
65
+
66
+ function hrpExpand(hrp: string): number[] {
67
+ const high: number[] = []
68
+ const low: number[] = []
69
+ for (const char of hrp) {
70
+ high.push(char.charCodeAt(0) >>> 5)
71
+ low.push(char.charCodeAt(0) & 31)
72
+ }
73
+ return [...high, 0, ...low]
74
+ }
75
+
76
+ function checksum(hrp: string, data: number[]): number[] {
77
+ const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]
78
+ // `^ 1` is bech32. bech32m would use `^ 0x2bc830a3` — see the note above.
79
+ const mod = polymod(values) ^ 1
80
+ return [0, 1, 2, 3, 4, 5].map((i) => (mod >>> (5 * (5 - i))) & 31)
81
+ }
82
+
83
+ /** Regroup bytes between bit widths, e.g. 8-bit bytes → 5-bit bech32 symbols. */
84
+ export function convertBits(data: ArrayLike<number>, from: number, to: number, pad: boolean): number[] {
85
+ let acc = 0
86
+ let bits = 0
87
+ const out: number[] = []
88
+ const maxv = (1 << to) - 1
89
+ for (let i = 0; i < data.length; i++) {
90
+ const value = data[i]
91
+ if (value < 0 || value >> from !== 0) throw new Error(`value out of range: ${value}`)
92
+ acc = (acc << from) | value
93
+ bits += from
94
+ while (bits >= to) {
95
+ bits -= to
96
+ out.push((acc >>> bits) & maxv)
97
+ }
98
+ }
99
+ if (pad) {
100
+ if (bits > 0) out.push((acc << (to - bits)) & maxv)
101
+ } else if (bits >= from || ((acc << (to - bits)) & maxv) !== 0) {
102
+ throw new Error('invalid padding')
103
+ }
104
+ return out
105
+ }
106
+
107
+ /**
108
+ * Exported for the bare NIP-19 types (`npub`, `note`) and for tests.
109
+ *
110
+ * The checksum layer is the part worth testing directly: a wrong constant still
111
+ * round-trips against itself, so only an external vector catches it.
112
+ *
113
+ * Strict about case, deliberately — BIP-173 forbids mixing. `decodeNaddr` is the
114
+ * forgiving entry point, because what reaches it was typed or pasted by a person.
115
+ */
116
+ export function bech32Encode(hrp: string, data: number[]): string {
117
+ const combined = [...data, ...checksum(hrp, data)]
118
+ return `${hrp}1${combined.map((d) => CHARSET[d]).join('')}`
119
+ }
120
+
121
+ export function bech32Decode(encoded: string): { hrp: string; data: number[] } {
122
+ const lower = encoded.toLowerCase()
123
+ if (lower !== encoded && encoded.toUpperCase() !== encoded) {
124
+ throw new Error('mixed case bech32 string')
125
+ }
126
+ const split = lower.lastIndexOf('1')
127
+ if (split < 1 || split + 7 > lower.length) throw new Error('malformed bech32 string')
128
+ const hrp = lower.slice(0, split)
129
+ const data: number[] = []
130
+ for (const char of lower.slice(split + 1)) {
131
+ const index = CHARSET.indexOf(char)
132
+ if (index === -1) throw new Error(`invalid bech32 character: ${char}`)
133
+ data.push(index)
134
+ }
135
+ if (polymod([...hrpExpand(hrp), ...data]) !== 1) throw new Error('bad bech32 checksum')
136
+ return { hrp, data: data.slice(0, -6) }
137
+ }
138
+
139
+ /** A decoded `naddr` — everything needed to fetch the event it points at. */
140
+ export interface AddressPointer {
141
+ /** The `d` tag of the addressable event. */
142
+ identifier: string
143
+ pubkey: string
144
+ kind: number
145
+ /** Relay hints, in the order they appeared. May be empty. */
146
+ relays: string[]
147
+ }
148
+
149
+ /**
150
+ * TLV types for `naddr` (NIP-19 §"Shareable identifiers with extra metadata").
151
+ * The numbers are the protocol; the names are ours.
152
+ */
153
+ const TLV_IDENTIFIER = 0
154
+ const TLV_RELAY = 1
155
+ const TLV_AUTHOR = 2
156
+ const TLV_KIND = 3
157
+
158
+ /**
159
+ * Encode an address as `naddr1…`.
160
+ *
161
+ * TLV order is identifier, relays, author, kind — see the header on why that is
162
+ * a choice rather than a rule.
163
+ */
164
+ export function encodeNaddr(pointer: AddressPointer): string {
165
+ const bytes: number[] = []
166
+ const push = (type: number, value: Uint8Array) => {
167
+ if (value.length > 255) throw new Error(`TLV value too long for type ${type}`)
168
+ bytes.push(type, value.length, ...value)
169
+ }
170
+
171
+ push(TLV_IDENTIFIER, utf8ToBytes(pointer.identifier))
172
+ for (const relay of pointer.relays) push(TLV_RELAY, utf8ToBytes(relay))
173
+ // Peek's encoder checked the author's length and Ship's did not, and the
174
+ // difference is not cosmetic: `hexToBytes` is happy with any even-length hex,
175
+ // so a 20-byte author encoded into a *valid* naddr that every decoder then
176
+ // refused as "author must be 32 bytes". A pointer that cannot be read back is
177
+ // worse than an error, because it is produced silently and only fails at
178
+ // whoever pastes it. Peek's guard is the one that ships.
179
+ const author = hexToBytes(pointer.pubkey)
180
+ if (author.length !== 32) throw new Error(`author must be 32 bytes, got ${author.length}`)
181
+ push(TLV_AUTHOR, author)
182
+ // Kind is a 4-byte big-endian integer, not a decimal string.
183
+ push(
184
+ TLV_KIND,
185
+ new Uint8Array([
186
+ (pointer.kind >>> 24) & 0xff,
187
+ (pointer.kind >>> 16) & 0xff,
188
+ (pointer.kind >>> 8) & 0xff,
189
+ pointer.kind & 0xff,
190
+ ]),
191
+ )
192
+
193
+ return bech32Encode('naddr', convertBits(bytes, 8, 5, true))
194
+ }
195
+
196
+ /**
197
+ * Decode `naddr1…`, with or without a `nostr:` prefix, in any case.
198
+ *
199
+ * Forgiving on purpose: what arrives here was pasted by a person, sometimes out
200
+ * of an email client that capitalised the first letter. `bech32Decode` is the
201
+ * strict primitive underneath — this lowercases first, so a mixed-case string
202
+ * that would be refused there is accepted here.
203
+ */
204
+ export function decodeNaddr(encoded: string): AddressPointer {
205
+ const { hrp, data } = bech32Decode(encoded.replace(/^nostr:/i, '').toLowerCase())
206
+ if (hrp !== 'naddr') throw new Error(`expected an naddr, got ${hrp}`)
207
+ const bytes = convertBits(data, 5, 8, false)
208
+
209
+ let identifier: string | undefined
210
+ let pubkey: string | undefined
211
+ let kind: number | undefined
212
+ const relays: string[] = []
213
+
214
+ for (let i = 0; i < bytes.length; ) {
215
+ const type = bytes[i]
216
+ const length = bytes[i + 1]
217
+ const value = bytes.slice(i + 2, i + 2 + length)
218
+ if (value.length !== length) throw new Error('truncated TLV')
219
+ i += 2 + length
220
+
221
+ switch (type) {
222
+ case TLV_IDENTIFIER:
223
+ identifier = new TextDecoder().decode(new Uint8Array(value))
224
+ break
225
+ case TLV_RELAY:
226
+ relays.push(new TextDecoder().decode(new Uint8Array(value)))
227
+ break
228
+ case TLV_AUTHOR:
229
+ if (length !== 32) throw new Error(`author must be 32 bytes, got ${length}`)
230
+ pubkey = bytesToHex(new Uint8Array(value))
231
+ break
232
+ case TLV_KIND:
233
+ if (length !== 4) throw new Error(`kind must be 4 bytes, got ${length}`)
234
+ kind = ((value[0] << 24) | (value[1] << 16) | (value[2] << 8) | value[3]) >>> 0
235
+ break
236
+ // Unknown TLV types are skipped, not rejected — that is what lets the
237
+ // format gain fields without breaking existing decoders.
238
+ }
239
+ }
240
+
241
+ if (identifier === undefined || pubkey === undefined || kind === undefined) {
242
+ throw new Error('naddr is missing identifier, author or kind')
243
+ }
244
+ return { identifier, pubkey, kind, relays }
245
+ }
246
+
247
+ /** `<kind>:<pubkey>:<d>` — the form used in `a` tags and relay filters. */
248
+ export function pointerToAddress(pointer: AddressPointer): string {
249
+ return `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
250
+ }
251
+
252
+ /**
253
+ * The inverse: `<kind>:<pubkey>:<d>` back to a pointer.
254
+ *
255
+ * An address carries no relay hints — `naddr` has a TLV for them and an `a` tag
256
+ * does not — so the relays come back empty. That is a real loss of information,
257
+ * not an oversight: it is why the two forms are not interchangeable and why the
258
+ * encoder is not simply run in reverse.
259
+ *
260
+ * The `d` identifier may itself contain colons, so only the first two are
261
+ * separators.
262
+ */
263
+ export function addressToPointer(address: string): AddressPointer {
264
+ const first = address.indexOf(':')
265
+ const second = address.indexOf(':', first + 1)
266
+ if (first < 1 || second < 0) throw new Error(`not an address: ${address.slice(0, 40)}`)
267
+ const kind = Number(address.slice(0, first))
268
+ const pubkey = address.slice(first + 1, second)
269
+ const identifier = address.slice(second + 1)
270
+ if (!Number.isInteger(kind) || !/^[0-9a-f]{64}$/i.test(pubkey)) {
271
+ throw new Error(`not an address: ${address.slice(0, 40)}`)
272
+ }
273
+ return { kind, pubkey: pubkey.toLowerCase(), identifier, relays: [] }
274
+ }
275
+
276
+ /** `<kind>:<pubkey>:<d>` → `naddr1…`. */
277
+ export function addrToNaddr(address: string, relays: string[] = []): string {
278
+ const [kind, pubkey, ...rest] = address.split(':')
279
+ return encodeNaddr({ kind: Number(kind), pubkey, identifier: rest.join(':'), relays })
280
+ }
281
+
282
+ /** `naddr1…` → `<kind>:<pubkey>:<d>`. */
283
+ export function naddrToAddr(encoded: string): string {
284
+ return pointerToAddress(decodeNaddr(encoded))
285
+ }
286
+
287
+ /**
288
+ * A pointer from *either* form a reference arrives in.
289
+ *
290
+ * A message composed in an app carries `nostr:naddr1…` in its body. The same
291
+ * message read back off the relay carries the same reference as an `a` tag,
292
+ * which is a plain `<kind>:<pubkey>:<d>` address — that is what the NIP says a
293
+ * tag holds.
294
+ *
295
+ * Both name one object. Accepting only the first is what made a reference stop
296
+ * rendering the moment its own message came back from the relay (FEE-2).
297
+ */
298
+ export function referenceToPointer(input: string): AddressPointer {
299
+ const trimmed = input.replace(/^nostr:/i, '')
300
+ return trimmed.toLowerCase().startsWith('naddr1')
301
+ ? decodeNaddr(trimmed)
302
+ : addressToPointer(trimmed)
303
+ }
304
+
305
+ /**
306
+ * Every `nostr:naddr1…` in a body of text (NIP-27).
307
+ *
308
+ * The character class is bech32's own alphabet, which excludes `1`, `b`, `i`
309
+ * and `o`, so a match ends cleanly at punctuation without a lookahead.
310
+ */
311
+ export const NADDR_RE = /nostr:(naddr1[023456789acdefghjklmnpqrstuvwxyz]+)/gi
312
+
313
+ export function findNaddrs(text: string): string[] {
314
+ return [...text.matchAll(NADDR_RE)].map((m) => m[1])
315
+ }
316
+
317
+ /**
318
+ * The same text with every `nostr:naddr1…` taken out (PEEK-18).
319
+ *
320
+ * A reference that resolves into a widget should not also sit in the prose as
321
+ * sixty characters of bech32: the widget *is* the reference, rendered. Ship
322
+ * appends one to every thread it starts about an issue, so leaving it in means
323
+ * most cross-app messages open with a wall of noise nobody reads.
324
+ *
325
+ * Whitespace is repaired rather than merely removed. A pointer is usually
326
+ * trailing or on a line of its own, and deleting it in place otherwise leaves a
327
+ * double space mid-sentence or a hole between paragraphs — both of which look
328
+ * like the message itself is broken. Runs of blanks collapse *within* a line
329
+ * only, so indentation and paragraph breaks survive.
330
+ *
331
+ * Display-only. The stored body keeps the pointer, which is what lets the
332
+ * reference still be found, resolved and followed.
333
+ */
334
+ export function stripNaddrs(text: string): string {
335
+ // Text with no pointer comes back byte-identical. The repairs below are only
336
+ // ever justified by a removal, and a display helper that reflows somebody's
337
+ // indentation for free would be a bug wearing a tidy-up's clothes.
338
+ if (findNaddrs(text).length === 0) return text
339
+
340
+ return (
341
+ text
342
+ // Take the blanks on either side along with the pointer, then put a single
343
+ // space back only when it stood between words on one line. Doing it in one
344
+ // pass is what keeps the repair local to the hole.
345
+ .replace(
346
+ new RegExp(`([^\\S\\n]*)${NADDR_RE.source}([^\\S\\n]*)`, 'gi'),
347
+ (_match, before: string, _addr: string, after: string) => (before && after ? ' ' : ''),
348
+ )
349
+ // A pointer alone on its line leaves the line's newlines behind on both
350
+ // sides, which reads as an unexplained gap.
351
+ .replace(/\n{3,}/g, '\n\n')
352
+ .trim()
353
+ )
354
+ }
package/src/nip98.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * NIP-98 HTTP auth for Buzz's REST bridge.
3
+ *
4
+ * Buzz's bridge (`POST /events`, `POST /query`) authenticates with NIP-98, **not**
5
+ * NIP-42 — no challenge/response and no persistent connection, which is what
6
+ * makes it usable from a request-scoped server runtime that cannot hold a socket,
7
+ * and from a plain `fetch` in a browser.
8
+ *
9
+ * Written against the relay's actual verifier, `verify_nip98_event`
10
+ * (crates/buzz-auth/src/nip98.rs:55). Its rules, in order:
11
+ *
12
+ * 1. kind must be 27235
13
+ * 2. valid Schnorr signature + id hash
14
+ * 3. `created_at` within ±60s of the relay's clock
15
+ * 4. `u` tag must equal the expected URL after normalization
16
+ * 5. `method` tag must match (case-insensitive)
17
+ * 6. if a `payload` tag is present AND a body was sent, sha256(body) must match
18
+ *
19
+ * Two things that bite in practice:
20
+ *
21
+ * - **No loopback aliasing.** `localhost`, `127.0.0.1` and `::1` are distinct
22
+ * hosts to the verifier (deliberately — it is the row-zero community binding).
23
+ * The `u` tag must use the same host string as the request's Host header.
24
+ * - **Single use, and "fresh" is not enough.** Each auth event id is recorded in
25
+ * a Redis seen-set (`check_nip98_replay`, bridge.rs:135). Rebuilding the header
26
+ * per request does *not* guarantee a new id: `created_at` has one-second
27
+ * resolution, so two requests with the same URL, method and body inside the
28
+ * same second produce a byte-identical event and the second is rejected with
29
+ * `NIP-98: replay detected`. Verified against the live relay. Hence the
30
+ * `nonce` tag below — it is what makes each auth event unique.
31
+ *
32
+ * ## Why the builder is unsigned, and the signer is somebody else's problem
33
+ *
34
+ * Before SHA-3 this existed twice with a real divergence: Peek had split the
35
+ * builder into `buildUnsignedAuthEvent` plus a signing step, because Peek holds
36
+ * no keys and must sign through Estiva ID; Ship still had the combined
37
+ * build-and-sign form. **Peek's shape is what ships**, because it is the one that
38
+ * works for a keyless app — and a keyed app composes it with `signEvent` in one
39
+ * line. The tag layout then has exactly one definition, which is the point: it is
40
+ * part of the event id preimage, and two copies drifting would produce ids the
41
+ * relay computes differently.
42
+ *
43
+ * `nostr-tools/nip98` was evaluated and does **not** fit: its `getToken` emits
44
+ * `u`, `method` and `payload` and no nonce, so identical requests in one second
45
+ * collide on Buzz's replay set. Measured, not assumed — see the README.
46
+ */
47
+ import { sha256 } from '@noble/hashes/sha256'
48
+ import { bytesToHex, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
49
+ import { KIND, type NostrTag, type SignedEvent, type UnsignedEvent } from './events.js'
50
+ import type { Signer } from './sign.js'
51
+
52
+ /** The relay rejects auth events outside ±60s (`TIMESTAMP_TOLERANCE_SECS`). */
53
+ export const TIMESTAMP_TOLERANCE_SECS = 60
54
+
55
+ /**
56
+ * `URL` and `btoa` are not in `lib.es2022`, and this package compiles with
57
+ * `types: []` and no `lib: dom` (ADR 0002 §4a). Declared inside this module so
58
+ * nothing lands in a consumer's global scope, and read inside function bodies so
59
+ * importing this module touches no global.
60
+ */
61
+ declare const URL: { new (raw: string): { pathname: string; toString(): string } }
62
+ declare const btoa: (binary: string) => string
63
+
64
+ /**
65
+ * Buzz's `normalize_url` (nip98.rs:145): parse, strip trailing slashes from the
66
+ * path, re-serialize. We only need it to keep our own `u` tag canonical.
67
+ */
68
+ export function normalizeUrl(raw: string): string {
69
+ try {
70
+ const parsed = new URL(raw)
71
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '')
72
+ return parsed.toString()
73
+ } catch {
74
+ return raw.toLowerCase()
75
+ }
76
+ }
77
+
78
+ /** What `buildUnsignedAuthEvent` needs. Named so callers can pass it around. */
79
+ export interface AuthEventArgs {
80
+ /** Left empty when a remote signer will overwrite it with the token's subject. */
81
+ pubkey: string
82
+ url: string
83
+ method: string
84
+ /** Request body, when there is one — adds the `payload` tag. */
85
+ body?: string
86
+ /** Override for tests; defaults to now. */
87
+ nowMs?: number
88
+ /** Override for tests; defaults to random. Must differ per request. */
89
+ nonce?: string
90
+ }
91
+
92
+ /**
93
+ * Build the unsigned kind:27235 event backing an `Authorization: Nostr …` header.
94
+ *
95
+ * `url` must be the full request URL with the same host the relay will see —
96
+ * `nip98_expected_url` (bridge.rs:195) reconstructs it as
97
+ * `{http|https}://{host}{path}` from the request's own Host header.
98
+ */
99
+ export function buildUnsignedAuthEvent(args: AuthEventArgs): UnsignedEvent {
100
+ const tags: NostrTag[] = [
101
+ ['u', normalizeUrl(args.url)],
102
+ ['method', args.method.toUpperCase()],
103
+ // Uniqueness, not security: without it, two identical requests in the same
104
+ // second collide on the event id and the relay rejects the second as a
105
+ // replay. The verifier ignores tags it does not know (it looks up `u`,
106
+ // `method` and `payload` by name), so this is safe to add.
107
+ ['nonce', args.nonce ?? bytesToHex(randomBytes(16))],
108
+ ]
109
+ if (args.body !== undefined) {
110
+ tags.push(['payload', bytesToHex(sha256(utf8ToBytes(args.body)))])
111
+ }
112
+ return {
113
+ pubkey: args.pubkey,
114
+ created_at: Math.floor((args.nowMs ?? Date.now()) / 1000),
115
+ kind: KIND.HTTP_AUTH,
116
+ tags,
117
+ content: '',
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Base64 without Node's `Buffer`, so this works in a browser, in Convex's
123
+ * default runtime and under Node from one build.
124
+ */
125
+ export function base64(input: string): string {
126
+ const bytes = utf8ToBytes(input)
127
+ let binary = ''
128
+ for (const b of bytes) binary += String.fromCharCode(b)
129
+ return btoa(binary)
130
+ }
131
+
132
+ /**
133
+ * The full header value: `Nostr <base64(signed kind-27235 event JSON)>`
134
+ * (bridge.rs:81-93 strips the `Nostr ` prefix and base64-decodes the rest).
135
+ *
136
+ * Uses the utf8-aware `base64` above rather than a bare `btoa`, which throws on
137
+ * any code point above U+00FF.
138
+ */
139
+ export function authorizationHeaderFor(signed: SignedEvent): string {
140
+ return `Nostr ${base64(JSON.stringify(signed))}`
141
+ }
142
+
143
+ /**
144
+ * Build, sign and encode the header in one step.
145
+ *
146
+ * **The auth event is signed by the same signer as the content**, which is what
147
+ * makes a keyless signer work at all: Buzz's bridge authenticates with NIP-98,
148
+ * so an app holding a perfectly signed issue and no way to sign a `27235` can
149
+ * still publish nothing. Signing content but not auth leaves the seam
150
+ * half-built, and it looks finished (PEEK-44).
151
+ *
152
+ * That rule has exactly one definition, here, and {@link Relay} uses it rather
153
+ * than inlining it — because an app with its own transport needs the same rule
154
+ * and would otherwise write it out again.
155
+ *
156
+ * Async for the same reason `Signer.sign` is: a remote signer or a browser
157
+ * extension cannot answer synchronously.
158
+ */
159
+ export async function authorizationHeader(
160
+ signer: Signer,
161
+ args: Omit<AuthEventArgs, 'pubkey'>,
162
+ ): Promise<string> {
163
+ return authorizationHeaderFor(await signer.sign(buildUnsignedAuthEvent({ ...args, pubkey: signer.pubkey })))
164
+ }
package/src/sign.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * BIP-340 Schnorr signing, and the signer seam every publish path goes through.
3
+ *
4
+ * Signing needs entropy (`@noble/curves` draws auxiliary randomness per
5
+ * signature), so `signEvent` only works where a CSPRNG is available — a browser,
6
+ * Node, Convex's Node runtime. That is a *runtime* requirement rather than a type
7
+ * one: nothing here reads a global, and importing this module is safe anywhere.
8
+ *
9
+ * ## What is deliberately not here
10
+ *
11
+ * `estivaIdSigner` — the signer that posts to Estiva ID's `/sign` and holds no
12
+ * key at all — is **identity**, and belongs to `@estiva-app/identity` (SHA-4).
13
+ * This package defines the {@link Signer} interface it will implement, because
14
+ * the relay clients need something to sign with and the interface is the seam
15
+ * between "the bytes" and "who is allowed to sign them". Keeping the interface
16
+ * here and the implementations there is what stops `protocol` growing a
17
+ * dependency on an identity service.
18
+ */
19
+ import { schnorr } from '@noble/curves/secp256k1'
20
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
21
+ import { computeEventId, type SignedEvent, type UnsignedEvent } from './events.js'
22
+
23
+ /** Derive the 64-char hex x-only public key for a secret key. */
24
+ export function publicKeyFromSecret(secretKeyHex: string): string {
25
+ return bytesToHex(schnorr.getPublicKey(hexToBytes(secretKeyHex)))
26
+ }
27
+
28
+ /**
29
+ * Compute the event id and sign it, producing a relay-submittable event.
30
+ *
31
+ * Throws if `unsigned.pubkey` does not match the secret key — a mismatch would
32
+ * produce an event the relay silently rejects as an invalid signature, which is
33
+ * painful to debug from the other side.
34
+ */
35
+ export function signEvent(unsigned: UnsignedEvent, secretKeyHex: string): SignedEvent {
36
+ const derived = publicKeyFromSecret(secretKeyHex)
37
+ if (derived !== unsigned.pubkey) {
38
+ throw new Error(
39
+ `pubkey mismatch: event declares ${unsigned.pubkey.slice(0, 16)}… but the secret key derives ${derived.slice(0, 16)}…`,
40
+ )
41
+ }
42
+ const id = computeEventId(unsigned)
43
+ const sig = bytesToHex(schnorr.sign(id, hexToBytes(secretKeyHex)))
44
+ return { ...unsigned, id, sig }
45
+ }
46
+
47
+ /**
48
+ * Who signs, decided once and injected everywhere (PEEK-44).
49
+ *
50
+ * ## Why this exists at all
51
+ *
52
+ * The suite claims apps built by different people, sharing no code and no
53
+ * database, can work on the same data. Wiring an app directly to one login
54
+ * service would quietly undercut that — it would only work for people with an
55
+ * Estiva account.
56
+ *
57
+ * Depending on a *signer* does not. Separating the signer from the client is
58
+ * ordinary Nostr architecture (NIP-07, NIP-46), so an app stays honest for
59
+ * anyone who wants to point their own signer at it, and "sign in with Estiva ID"
60
+ * becomes one implementation among several rather than an assumption baked into
61
+ * the data layer. It is also the seam that lets this package stay ignorant of
62
+ * identity: `@estiva-app/identity` implements this interface, and nothing here
63
+ * knows that it exists.
64
+ *
65
+ * ## The two decisions in the interface
66
+ *
67
+ * **`pubkey` is synchronous.** Every event builder needs it before there is
68
+ * anything to sign — `buildMessage(pubkey, …)` — so making it a promise would
69
+ * put an `await` in front of every construction site for no benefit. You know
70
+ * who you are before you sign; implementations that must ask (NIP-07) resolve it
71
+ * once, at construction.
72
+ *
73
+ * **`sign` is asynchronous**, because two of the three implementations are: a
74
+ * remote call to `/sign` and a round trip to a browser extension. The local key
75
+ * is the odd one out, and it is cheaper for it to return a resolved promise than
76
+ * for the interface to pretend signing is always instant.
77
+ *
78
+ * ## What a signer deliberately cannot do
79
+ *
80
+ * Sign as somebody else. Every implementation attributes the event to its own
81
+ * `pubkey` and ignores whatever the caller put there, which is the same
82
+ * guarantee `/sign` makes server-side. Code that genuinely needs to produce a
83
+ * mismatched event — Ship's `scripts/verify.ts` forges one to prove the relay
84
+ * rejects it — uses {@link signEvent} directly and holds a raw key to do it.
85
+ */
86
+
87
+ /** How the current signer authenticates, for anything that needs to say so. */
88
+ export type SignerKind = 'local' | 'estiva-id' | 'nip07'
89
+
90
+ export interface Signer {
91
+ /** The pubkey every event from this signer is attributed to. */
92
+ readonly pubkey: string
93
+ readonly kind: SignerKind
94
+ /** Attributes the event to {@link pubkey}, whatever the caller supplied. */
95
+ sign(unsigned: UnsignedEvent): Promise<SignedEvent>
96
+ }
97
+
98
+ /**
99
+ * A signer holding a raw secret key.
100
+ *
101
+ * The honest name for what a script has always done. Used directly by scripts,
102
+ * which have no `localStorage`, and underneath an app's per-browser identity.
103
+ */
104
+ export function secretKeySigner(secretKeyHex: string, kind: SignerKind = 'local'): Signer {
105
+ const pubkey = publicKeyFromSecret(secretKeyHex)
106
+ return {
107
+ pubkey,
108
+ kind,
109
+ async sign(unsigned) {
110
+ return signEvent({ ...unsigned, pubkey }, secretKeyHex)
111
+ },
112
+ }
113
+ }