diff --git a/.env.example b/.env.example index b3a35e6..524d2ae 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,10 @@ VITE_PROFILE_RELAYS= # the blob lives there under its sha256, the event only holds the URL. # To try locally: node scripts/dev-blossom.mjs VITE_BLOSSOM_SERVER= + +# Public key of the Blossom server's service identity. Attachment reads are +# checked against the group's member list (NIP-29 39002), which a private group +# only serves to a member — so this key has to be a member of every space that +# stores files. scripts/dev-group-seed.sh adds the local one; creating a space +# in the app adds it too when this is set. CON-26 +VITE_BLOSSOM_SERVICE_PUBKEY= diff --git a/NOSTR.md b/NOSTR.md index 9c9fb85..045e4eb 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -72,7 +72,7 @@ backlog, see [docs/10](docs/10-roadmap.md). | [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) groups | ✅ | Spaces, membership, moderation. The relay is the authority | `src/domain/group-state.ts`, `src/nostr/moderation.ts` | | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) `alt` | ✅ | Plain-text description on our own kinds so foreign clients can show something | `src/nostr/publish-page.ts` | | [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) AUTH | ✅ | Authenticating to the relay, automatically on every new connection, retried after `auth-required` | `src/nostr/client.ts` | -| [Blossom](https://github.com/hzrd149/blossom) BUD-01/02 | ✅ | Attachments: the blob lives on the server under its sha256, the event only holds the URL | `src/nostr/blossom.ts` | +| [Blossom](https://github.com/hzrd149/blossom) BUD-01/02/11 | ✅ | Attachments: the blob lives on the server under its sha256, the event only holds the URL. The server files a blob under a group and checks membership before it reads one back (CON-26) | `src/nostr/blossom.ts`, `src/nostr/attachment-access.ts` | | [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion request | ❌ | Deleting happens only through NIP-29 (`9005`), which a relay actually enforces | — | | [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) bunker | ❌ | Planned as a second signer implementation behind the same interface | — | | [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) search | ❌ | Deliberately not: not every relay supports it, and a relay-dependent search would break offline. Search runs locally | `src/domain/search.ts` | @@ -92,7 +92,7 @@ backlog, see [docs/10](docs/10-roadmap.md). | **31818** page placement | ⚠️ our own kind | Where a page hangs in the tree: `page-parent` and `page-order`. Addressable on `(pubkey, 31818, d)`, so moving a page **overwrites** it — a move is not an edit and appends nothing to the page's history. Not `30819`, which is NIP-54's wiki redirect | | **1111** comment | ⚠️ | NIP-22, but anchored to `(h, d)` instead of a root event | | **20817** diagnostic ping | ⚠️ our own kind | Ephemeral (20000–29999), not stored. Only answers "may I write here?" | -| **24242** Blossom upload | ✅ | Authorises a file upload. Not a relay event; it goes to the Blossom server over HTTP | +| **24242** Blossom auth | ✅ | Authorises a file upload (`t=upload`, bound to the content by `x` and to a group by `h`) or a read (`t=get`). Not a relay event; it goes to the Blossom server over HTTP | | **22242** relay AUTH | ✅ | NIP-42, produced by `nostr-tools` | | **9000** / **9001** add/remove member | ✅ | A request to the relay, which verifies admin status | | **9005** delete event | ✅ | Moderation; the relay enforces the deletion | @@ -261,6 +261,7 @@ internal pool does not authenticate, and `info` hangs). Hence the raw `req`. | `VITE_RELAY_URL` | `ws://localhost:8080` | The group relay. It is part of a space's identity (`host'group`) | | `VITE_PROFILE_RELAYS` | empty | Relays for kind 0. Empty means the app shows npubs instead of names — more honest than an invented name | | `VITE_BLOSSOM_SERVER` | empty | Blossom server for attachments. Empty means the attachment button is disabled | +| `VITE_BLOSSOM_SERVICE_PUBKEY` | empty | The Blossom server's service identity, added as a member when a space is created in the app so the server can check attachment rights. `BLOSSOM_SEC` in `scripts/.dev-keys` is the local equivalent | * * * @@ -300,6 +301,10 @@ Named honestly, because they matter when building on top of this: elsewhere remain. - ⚠️ **`created_at` is manipulable**, because the client sets it. Ordering primarily follows the `parent-rev` chain; the clock is for display. +- ⚠️ **Attachment rights are enforced by the file server, not the relay.** + NIP-29 has no notion of a blob. The Blossom server files each upload under a + group and checks a reader's membership against `39002` (CON-26); a picture + embedded from a foreign host is outside that and stays public. * * * @@ -314,7 +319,7 @@ Named honestly, because they matter when building on top of this: | Where a page hangs, and the sibling order | `src/domain/placement.ts`, `src/domain/order.ts`, `src/nostr/publish-placement.ts` | | Three-way merge | `src/domain/merge.ts` | | Group state and moderation | `src/domain/group-state.ts`, `src/nostr/moderation.ts` | -| Attachments | `src/nostr/blossom.ts`, `scripts/dev-blossom.mjs` | +| Attachments | `src/nostr/blossom.ts`, `src/nostr/attachment-access.ts`, `scripts/dev-blossom.mjs`, `scripts/blossom-acl.mjs` | The reasoning behind every decision is in [docs/](docs/README.md), the working rules for this repo in [AGENTS.md](AGENTS.md). diff --git a/docs/02-data-model-events.md b/docs/02-data-model-events.md index 609b0df..8b70f68 100644 --- a/docs/02-data-model-events.md +++ b/docs/02-data-model-events.md @@ -37,6 +37,18 @@ kind `24242` event: the server verifies a signature, not a password. Configured via `VITE_BLOSSOM_SERVER`; without it the `/` menu's attachment entry says so rather than failing silently ([13](13-editing.md)). +**A blob belongs to a group (CON-26).** The upload token carries the space in an +`h` tag and the content hash in an `x` tag, and the server files the blob under +that group. Reading it again needs a `t=get` token from a key that is a member +of the group — so an attachment in a private space is no longer readable by +whoever happens to know its hash. The app fetches those blobs with the token and +draws an object URL; a picture on a foreign host is left alone and stays direct. +The server's own service identity is a member of every space that stores files: +`VITE_BLOSSOM_SERVICE_PUBKEY` when a space is created in the app, or +`BLOSSOM_SEC` in [`scripts/dev-group-seed.sh`](../scripts/dev-group-seed.sh) +locally. Details in [04](04-permissions-nip29.md) and +[09](09-security-privacy.md). + A tiny server for local development ships with the repo: `node scripts/dev-blossom.mjs`. diff --git a/docs/04-permissions-nip29.md b/docs/04-permissions-nip29.md index 24b5751..8fa2dcb 100644 --- a/docs/04-permissions-nip29.md +++ b/docs/04-permissions-nip29.md @@ -114,9 +114,15 @@ Worth knowing, because the flags alone do not prove it — Buzz notes that channels stay readable for non-members at runtime. What a flag means is decided by the relay implementation, so it has to be measured, not assumed. -**What this does not cover:** attachments. They live on a Blossom server outside -the group model, and its read path checks nothing — see -[09](09-security-privacy.md). +**Attachments are inside the boundary too (CON-26).** A Blossom server does +not know NIP-29 by itself, so the development server now carries the rule +itself: an upload token must name its group (`h`), and a read must present a +`t=get` token whose key is a member of one of the groups the blob is filed +under. The server asks the relay for that group's `39002` as a *service +identity*, because a private group's member list is only served to a member — +which is why the service key has to be one. Foreign image hosts in a page are +still outside this and stay unprotected; see [09](09-security-privacy.md) and +[02](02-data-model-events.md). **Superseded:** an earlier version of this document chose `public` + `open` with auto-join ("Open groups auto-join the author when posting"). That path is gone diff --git a/docs/07-tech-stack.md b/docs/07-tech-stack.md index 3dd330a..204ae18 100644 --- a/docs/07-tech-stack.md +++ b/docs/07-tech-stack.md @@ -25,8 +25,10 @@ ## Configuration `.env.example` documents the switches: `VITE_RELAY_URL` for the group relay, -`VITE_PROFILE_RELAYS` for the relays profiles (kind 0) are fetched from, and -`VITE_BLOSSOM_SERVER` for attachments. The profile relays are needed because a +`VITE_PROFILE_RELAYS` for the relays profiles (kind 0) are fetched from, +`VITE_BLOSSOM_SERVER` for attachments and `VITE_BLOSSOM_SERVICE_PUBKEY` for the +Blossom server's own identity, which has to be a group member for attachment +reads to be checked (CON-26). The profile relays are needed because a NIP-29 relay does not accept kind 0 at all — every event there needs an `h` tag. Without configuration the app shows npubs instead of names, which is more honest than an invented name. diff --git a/docs/08-relay-setup.md b/docs/08-relay-setup.md index 26e8b78..c1e3e45 100644 --- a/docs/08-relay-setup.md +++ b/docs/08-relay-setup.md @@ -115,7 +115,9 @@ Two quirks that cost time: - Put the relay behind TLS (`wss://`), because an HTTPS page may not open `ws://` (except for `localhost`). -- The web app is a static bundle on any host. +- Attachments need the same boundary as the group: the file server must check + membership before it hands a blob back (CON-26). The development server does; + which server runs in production is still open, see CON-4. - Backup = event export as JSONL. Because everything is signed, an export is verifiably restorable on another relay. That is also the migration strategy: moving a space means copying events. diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md index 8aeffc8..d85fb43 100644 --- a/docs/09-security-privacy.md +++ b/docs/09-security-privacy.md @@ -36,6 +36,7 @@ |---|---| | XSS through Markdown from arbitrary npubs | `rehype-sanitize` with a strict allowlist, no `dangerouslySetInnerHTML`, no raw HTML, no `javascript:` links | | Images/iframes used as trackers | **Accepted trade:** every image is loaded directly, whatever host it points at — so a host learns the reader's IP, which page is being read and when, and can count reads. No iframes. See "Images are loaded directly" below | +| Attachments in a private space read by a non-member | The Blossom server requires a `t=get` token and checks the reader's key against the group's `39002`, as a service identity; the app fetches with that token and draws an object URL. A picture on a foreign host is outside this. See "Attachments and the group boundary" below | | Forged `h` tags (an event from another group smuggled in) | Checked after loading: `h` must match the open space, otherwise the event is discarded | | Forgetting to verify signatures | Verification is enforced in the data layer, not optional per call | | Impersonation via display names | The npub is the truth and stays one hover or one click away — the author tooltip, a revision's Details view, `/settings/profile`; the member badge only appears for entries in `39002` | @@ -64,6 +65,35 @@ server. Bringing the gate back for foreign origins is a small change: the two places that draw an image are `MarkdownImage` in `src/ui/Markdown.tsx` and `ImageWidget` in `src/ui/markdown-live.ts`. +## Attachments and the group boundary + +An attachment is not in the event — it is a file on a Blossom server, named by +its sha256 in the Markdown. That hash is not a secret: it is derived from the +content, so for a guessable file it is guessable. A blob was therefore readable +by anyone who had the URL, whatever the group's `private` flag said. For a +private space that defeated the point, and CON-26 closed it. + +The rule now sits on the file server, because nothing else can see a blob: +an upload token files the blob under a group (an `h` tag) and is bound to its +content (an `x` tag); a read must present a `t=get` token, and the token's key +has to be a member of one of the groups the blob is filed under. Membership is +asked of the relay as a *service identity*, because a private group's `39002` +is not served to an anonymous reader — so that identity has to be a member of +every space that stores files. The app signs the `t=get` token with the +session's key, fetches the blob with it and hands the renderer an object URL, +because a plain `` cannot send an `Authorization` header. + +**What this does not cover:** a page may embed a picture from any host, and that +host is not ours to protect — it still learns who is reading, exactly as the +section above describes. Only blobs on the configured `VITE_BLOSSOM_SERVER` +are covered. + +**Still open (CON-4):** the shipped server is deliberately a development one. +Under its own domain, the same rule has to live in whatever serves the files in +production — an authorising proxy in front of a stock Blossom server, or the +media endpoints of a relay that already knows the community (as +[block/buzz](https://github.com/block/buzz) does). + ## Privacy note for users An npub is a permanent pseudonym: everything a person posts is linkable across diff --git a/docs/13-editing.md b/docs/13-editing.md index d123042..8e5feaa 100644 --- a/docs/13-editing.md +++ b/docs/13-editing.md @@ -450,6 +450,12 @@ entry cannot open a picker that could only fail, so it shows the reason where the upload note sits — the same promise as before, kept by the menu instead of by a disabled button. +The upload files the blob under the space (an `h` tag) and reading it back needs +a `t=get` token from a member, so an attachment in a private space is not public +(CON-26). In the editor that means a blob on our own Blossom server is fetched +with that token and drawn from an object URL; a picture on a foreign host is +drawn directly, as before. src/nostr/attachment-access.ts + ## Why the write and read views must not drift The sizes in `editorTheme` (`src/ui/MarkdownEditor.tsx`) mirror `PAGE` in diff --git a/scripts/blossom-acl.mjs b/scripts/blossom-acl.mjs new file mode 100644 index 0000000..543d84d --- /dev/null +++ b/scripts/blossom-acl.mjs @@ -0,0 +1,118 @@ +/** + * Blossom authorization checks, kept out of the HTTP server so they can be + * unit-tested without a socket. BUD-01 (retrieval), BUD-02 (upload) and BUD-11 + * (the kind 24242 token) define the rules; docs/09-security-privacy.md and + * CON-26 explain why the *read* path enforces them here. + * + * A token proves one thing: the holder of `pubkey` signed this exact request. + * It says nothing about NIP-29 membership — that is a separate question the + * server answers against the relay's `39002` before it hands out a blob. + */ +import { verifyEvent } from 'nostr-tools' + +export const BLOSSOM_AUTH_KIND = 24242 + +/** A token older than this many seconds is rejected — the clock is the client's. */ +const CLOCK_SKEW_SECONDS = 60 + +/** + * BUD-11 wants Base64url without padding; the app used to send standard + * Base64, so both are accepted on the way in. Decoding is strict either way — + * a token that is not valid JSON after decoding is a bad token, not an empty + * one. + */ +export function decodeToken(encoded) { + const normalised = encoded.replace(/-/g, '+').replace(/_/g, '/') + return Buffer.from(normalised, 'base64').toString('utf8') +} + +/** + * The event out of an `Authorization: Nostr ` header, or a reason. + * Parsing is separate from validation so the tests can build the happy path + * without a header. + */ +export function parseAuthHeader(header) { + if (!header || typeof header !== 'string') return { reason: 'Authorization header is missing' } + if (!header.startsWith('Nostr ')) return { reason: 'Authorization scheme must be Nostr' } + let event + try { + event = JSON.parse(decodeToken(header.slice(6).trim())) + } catch { + return { reason: 'Authorization is not base64url-encoded JSON' } + } + if (event === null || typeof event !== 'object') return { reason: 'Authorization is not an event' } + return { event } +} + +const tagValues = (event, name) => + event.tags.filter((tag) => tag[0] === name).map((tag) => tag[1]) + +/** + * Validate a token against one intended action. Returns `null` when the token + * is good, otherwise a human-readable reason. + * + * `verb` is the BUD-11 `t` value for the endpoint (`get` for retrieval, + * `upload` for upload). `hash` is the blob the endpoint acts on; + * `requireHash` says whether an `x` tag is mandatory for that endpoint (it is + * for upload, optional for GET). `server` is this server's own lowercase host, + * checked only when the token carries `server` tags. + */ +export function verifyToken(event, { verb, hash, server, requireHash = false, now = Math.floor(Date.now() / 1000) }) { + if (event.kind !== BLOSSOM_AUTH_KIND) return `wrong kind, expected ${BLOSSOM_AUTH_KIND}` + if (!verifyEvent(event)) return 'invalid signature' + + const createdAt = Number(event.created_at) + if (!Number.isFinite(createdAt) || createdAt > now + CLOCK_SKEW_SECONDS) { + return 'created_at is in the future' + } + + const expiration = Number(tagValues(event, 'expiration')[0]) + if (!Number.isFinite(expiration) || expiration <= now) return 'expiration is missing or has passed' + + if (!tagValues(event, 't').includes(verb)) return `t tag must be "${verb}"` + + const servers = tagValues(event, 'server') + if (servers.length > 0) { + if (!server) return 'the token is scoped to a server, but this server has no host' + if (!servers.map((value) => String(value).toLowerCase()).includes(server.toLowerCase())) { + return 'the token is scoped to another server' + } + } + + const hashes = tagValues(event, 'x') + if (requireHash && hashes.length === 0) return 'an x tag is required' + if (hashes.length > 0 && (!hash || !hashes.includes(hash))) { + return 'x tag does not match the blob' + } + + return null +} + +/** Convenience: parse then validate. */ +export function checkAuthHeader(header, options) { + const parsed = parseAuthHeader(header) + if (!parsed.event) return { ok: false, reason: parsed.reason } + const problem = verifyToken(parsed.event, options) + if (problem) return { ok: false, reason: problem } + return { ok: true, event: parsed.event } +} + +/** The `h` tag of an upload token — the group the blob is filed under. */ +export function groupOf(event) { + const value = tagValues(event, 'h')[0] + return typeof value === 'string' && value.length > 0 ? value : null +} + +const SHA256 = /^[0-9a-f]{64}$/ + +/** + * The blob hash out of a request path, with an optional file extension, as + * BUD-01 prescribes: `/` or `/.png`. Returns null for any + * other path, so the caller answers 404 rather than guessing. + */ +export function blobHashFromPath(pathname) { + const match = /^\/([0-9a-fA-F]{64})(?:\.[A-Za-z0-9]+)?$/.exec(pathname) + return match ? match[1].toLowerCase() : null +} + +export { SHA256 } diff --git a/scripts/blossom-acl.test.mjs b/scripts/blossom-acl.test.mjs new file mode 100644 index 0000000..fbe7b1a --- /dev/null +++ b/scripts/blossom-acl.test.mjs @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' +import { + blobHashFromPath, + checkAuthHeader, + decodeToken, + groupOf, + parseAuthHeader, + verifyToken, +} from './blossom-acl.mjs' + +const secret = generateSecretKey() +const pubkey = getPublicKey(secret) +const HASH = 'a'.repeat(64) +const NOW = 1_800_000_000 + +function token(overrides = {}) { + const { + kind = 24242, + created_at = NOW - 5, + tags = [ + ['t', 'get'], + ['x', HASH], + ['expiration', String(NOW + 300)], + ], + content = 'read attachment', + } = overrides + return finalizeEvent({ kind, created_at, tags, content }, secret) +} + +function headerFor(event) { + return 'Nostr ' + Buffer.from(JSON.stringify(event)).toString('base64url') +} + +describe('parseAuthHeader', () => { + it('decodes a base64url token', () => { + const event = token() + expect(parseAuthHeader(headerFor(event)).event.id).toBe(event.id) + }) + + it('also accepts the standard base64 the app used to send', () => { + const event = token() + const header = 'Nostr ' + Buffer.from(JSON.stringify(event)).toString('base64') + expect(parseAuthHeader(header).event.id).toBe(event.id) + }) + + it('rejects a missing, wrongly-schemed or undecodable header', () => { + expect(parseAuthHeader(undefined).reason).toMatch(/missing/) + expect(parseAuthHeader('Bearer abc').reason).toMatch(/scheme/) + // valid base64, but not JSON + expect(parseAuthHeader('Nostr ' + Buffer.from('not json').toString('base64url')).reason).toMatch( + /JSON/, + ) + }) +}) + +describe('verifyToken', () => { + const base = { verb: 'get', hash: HASH, server: 'localhost:3355', now: NOW } + + it('accepts a well-formed get token', () => { + expect(verifyToken(token(), base)).toBeNull() + }) + + it('rejects the wrong kind and a tampered signature', () => { + expect(verifyToken(token({ kind: 1 }), base)).toMatch(/kind/) + const event = token() + expect(verifyToken({ ...event, content: 'tampered' }, base)).toMatch(/signature/) + }) + + it('requires an expiration in the future and a created_at not in the future', () => { + const noExpiration = token({ tags: [['t', 'get'], ['x', HASH]] }) + expect(verifyToken(noExpiration, base)).toMatch(/expiration/) + + const expired = token({ tags: [['t', 'get'], ['x', HASH], ['expiration', String(NOW - 1)]] }) + expect(verifyToken(expired, base)).toMatch(/expiration/) + + const future = token({ created_at: NOW + 3600 }) + expect(verifyToken(future, base)).toMatch(/future/) + }) + + it('matches the t verb exactly', () => { + expect(verifyToken(token({ tags: [['t', 'upload'], ['x', HASH], ['expiration', String(NOW + 5)]] }), base)).toMatch( + /"get"/, + ) + }) + + it('requires an x tag for upload but not for get', () => { + const withoutX = token({ tags: [['t', 'upload'], ['expiration', String(NOW + 5)]] }) + expect(verifyToken(withoutX, { verb: 'upload', hash: HASH, now: NOW, requireHash: true })).toMatch( + /x tag is required/, + ) + expect(verifyToken(withoutX, { verb: 'upload', hash: HASH, now: NOW })).toBeNull() + }) + + it('rejects an x tag for another blob', () => { + const other = token({ tags: [['t', 'get'], ['x', 'b'.repeat(64)], ['expiration', String(NOW + 5)]] }) + expect(verifyToken(other, base)).toMatch(/does not match/) + }) + + it('honours server scoping only when the token carries it', () => { + const scopedElsewhere = token({ + tags: [['t', 'get'], ['server', 'cdn.example.com'], ['expiration', String(NOW + 5)]], + }) + expect(verifyToken(scopedElsewhere, base)).toMatch(/another server/) + expect(verifyToken(scopedElsewhere, { ...base, server: 'cdn.example.com' })).toBeNull() + // a token without a server tag works everywhere + expect(verifyToken(token(), { verb: 'get', hash: HASH, now: NOW })).toBeNull() + }) +}) + +describe('checkAuthHeader', () => { + it('reports the parsed event on success and a reason otherwise', () => { + const ok = checkAuthHeader(headerFor(token()), { verb: 'get', hash: HASH, now: NOW }) + expect(ok.ok).toBe(true) + expect(ok.event.pubkey).toBe(pubkey) + + const bad = checkAuthHeader(headerFor(token({ kind: 1 })), { verb: 'get', hash: HASH, now: NOW }) + expect(bad.ok).toBe(false) + expect(bad.reason).toMatch(/kind/) + }) +}) + +describe('groupOf', () => { + it('reads the h tag', () => { + expect(groupOf(token({ tags: [['h', 'engineering']] }))).toBe('engineering') + expect(groupOf(token({ tags: [] }))).toBeNull() + }) +}) + +describe('blobHashFromPath', () => { + it('accepts / with or without an extension', () => { + expect(blobHashFromPath('/' + HASH)).toBe(HASH) + expect(blobHashFromPath('/' + HASH + '.png')).toBe(HASH) + expect(blobHashFromPath('/' + HASH.toUpperCase() + '.PDF')).toBe(HASH) + }) + + it('rejects anything else', () => { + expect(blobHashFromPath('/upload')).toBeNull() + expect(blobHashFromPath('/' + HASH + '/extra')).toBeNull() + expect(blobHashFromPath('/' + 'abc')).toBeNull() + }) +}) diff --git a/scripts/dev-blossom.mjs b/scripts/dev-blossom.mjs index 918544f..35b067c 100755 --- a/scripts/dev-blossom.mjs +++ b/scripts/dev-blossom.mjs @@ -1,31 +1,56 @@ #!/usr/bin/env node /** - * Tiny Blossom server for development (BUD-01/02). + * Tiny Blossom server for development (BUD-01/02/11) with the NIP-29 group + * boundary the relay enforces applied to files too (CON-26). * - * Nostr stores no files — images live on a Blossom or NIP-96 server and are - * embedded into the markdown text by their URL. This server is deliberately - * small and ONLY for local development: it checks the upload authorization - * (kind 24242) and stores files under their sha256. In production a real - * Blossom server belongs here. + * Nostr stores no files — images live on a Blossom server and are embedded in + * the Markdown by their URL. This server is deliberately small and ONLY for + * local development, but it no longer hands a blob to anyone who knows its + * hash: * - * node scripts/dev-blossom.mjs [--port 3355] + * - an upload token (kind 24242) must bind the blob to a group with an `h` + * tag and to the content with an `x` tag; + * - a read (GET/HEAD) must carry a `t=get` token, and the token's key has to + * be a member of one of the groups the blob is filed under. Membership is + * asked of the relay as a *service identity*, because a private group's + * `39002` is not served to anonymous readers at all. + * + * node scripts/dev-blossom.mjs [--port 3355] [--relay ws://localhost:8080] + * [--insecure-reads] + * + * The service identity is `BLOSSOM_SERVICE_NSEC`, or `BLOSSOM_SEC` in + * scripts/.dev-keys (put there and added to the group by + * scripts/dev-group-seed.sh). Without it reads are refused — fail closed. + * `--insecure-reads` is the explicit way to get the old open behaviour back. */ import { createServer } from 'node:http' import { createHash } from 'node:crypto' import { mkdir, readFile, writeFile, access } from 'node:fs/promises' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { verifyEvent } from 'nostr-tools' +import { SimplePool, finalizeEvent, nip19 } from 'nostr-tools' +import { blobHashFromPath, checkAuthHeader, groupOf } from './blossom-acl.mjs' const root = join(dirname(fileURLToPath(import.meta.url)), '..') const store = join(root, '.local', 'blossom') -const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 3355) + +const arg = (name, fallback) => { + const index = process.argv.indexOf(name) + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback +} +const flag = (name) => process.argv.includes(name) + +const port = Number(arg('--port', '3355')) +const relayUrl = arg('--relay', process.env.BLOSSOM_RELAY_URL ?? 'ws://localhost:8080') +const insecureReads = flag('--insecure-reads') || process.env.BLOSSOM_INSECURE_READS === '1' await mkdir(store, { recursive: true }) const CORS = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, PUT, HEAD, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, OPTIONS', + // Authorization is what a protected read sends; without it the browser's + // preflight rejects the request before it is ever made. 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Expose-Headers': 'Content-Type, Content-Length', } @@ -35,25 +60,168 @@ function json(res, status, body) { res.end(JSON.stringify(body)) } -/** Authorization per BUD-01: kind 24242, t=upload, x=, valid signature. */ -function checkAuth(header, hash) { - if (!header?.startsWith('Nostr ')) return 'Authorization header is missing' - let event +/** hex or bech32 (nsec) service key -> the 32 secret bytes nostr-tools wants. */ +function parseSecret(raw) { + const value = String(raw).trim() + if (value.startsWith('nsec')) { + const decoded = nip19.decode(value) + if (decoded.type !== 'nsec') throw new Error('not an nsec key') + return decoded.data + } + if (/^[0-9a-fA-F]{64}$/.test(value)) { + const bytes = new Uint8Array(32) + for (let i = 0; i < 32; i++) bytes[i] = parseInt(value.slice(i * 2, i * 2 + 2), 16) + return bytes + } + throw new Error('BLOSSOM_SERVICE_NSEC is neither hex nor an nsec') +} + +async function loadServiceSecret() { + const fromEnv = process.env.BLOSSOM_SERVICE_NSEC + if (fromEnv) return parseSecret(fromEnv) try { - event = JSON.parse(Buffer.from(header.slice(6), 'base64').toString('utf8')) + const text = await readFile(join(root, 'scripts', '.dev-keys'), 'utf8') + const line = text.split('\n').find((entry) => entry.startsWith('BLOSSOM_SEC=')) + if (line) return parseSecret(line.slice('BLOSSOM_SEC='.length)) } catch { - return 'Authorization is not valid base64 JSON' + /* no key file — reads then fail closed */ } - if (event.kind !== 24242) return 'wrong kind, expected 24242' - if (!verifyEvent(event)) return 'invalid signature' - const tag = (name) => event.tags.find((t) => t[0] === name)?.[1] - if (tag('t') !== 'upload') return 't tag must be upload' - if (tag('x') && tag('x') !== hash) return 'x tag does not match the content' - const expiration = Number(tag('expiration') ?? 0) - if (!expiration || expiration < Math.floor(Date.now() / 1000)) return 'expiration is missing or has passed' return null } +const serviceSecret = await loadServiceSecret() + +// --- blob metadata --------------------------------------------------------- + +const groupsPath = (hash) => join(store, hash + '.groups') + +/** The groups a blob is filed under, from the upload that stored it. */ +async function readGroups(hash) { + try { + const parsed = JSON.parse(await readFile(groupsPath(hash), 'utf8')) + return Array.isArray(parsed) ? parsed.filter((value) => typeof value === 'string') : [] + } catch { + return [] + } +} + +/** Union, not overwrite: the same bytes uploaded to two spaces belong to both. */ +async function addGroup(hash, group) { + const groups = await readGroups(hash) + if (groups.includes(group)) return groups + groups.push(group) + await writeFile(groupsPath(hash), JSON.stringify(groups)) + return groups +} + +async function blobExists(hash) { + try { + await access(join(store, hash)) + return true + } catch { + return false + } +} + +// --- membership oracle ----------------------------------------------------- + +/** + * Asks the relay whether a key is in a group, as the service identity. + * + * NIP-42 first, deliberately: this relay answers an unauthenticated read of a + * private group's state with silence rather than `auth-required`, so a + * rejection-triggered AUTH never fires. That is the same reason `nak` is run + * with `--fpa` here (AGENTS.md). Without a service key the server refuses + * reads outright rather than guess. + */ +class Membership { + constructor({ url, secret }) { + this.url = url + this.secret = secret + this.pool = new SimplePool({ enableReconnect: false }) + this.authed = false + this.cache = new Map() + } + + async ensureAuthed() { + const relay = await this.pool.ensureRelay(this.url, { connectionTimeout: 5000 }) + if (this.authed) return relay + if (!this.secret) throw new Error('no Blossom service identity configured') + await new Promise((resolve) => setTimeout(resolve, 150)) + await relay.auth(async (template) => finalizeEvent(template, this.secret)) + this.authed = true + return relay + } + + query(filter) { + return new Promise((resolve, reject) => { + const events = [] + let closer = null + let settled = false + const finish = (error) => { + if (settled) return + settled = true + clearTimeout(timer) + closer?.close() + error ? reject(error) : resolve(events) + } + const timer = setTimeout(() => finish(null), 3000) + this.ensureAuthed().then(() => { + closer = this.pool.subscribeEose([this.url], filter, { + onauth: async (template) => finalizeEvent(template, this.secret), + onevent: (event) => events.push(event), + onclose: () => finish(null), + maxWait: 2500, + }) + }, finish) + }) + } + + async isMember(group, pubkey) { + const cacheKey = group + '\u0000' + pubkey + const cached = this.cache.get(cacheKey) + if (cached && cached.until > Date.now()) return cached.value + let value = false + try { + // 39002 lists every member as a p tag; the relay only serves it to a + // member, which is exactly why the service identity has to be one. + const events = await this.query({ + kinds: [39002], + '#d': [group], + '#p': [pubkey], + limit: 1, + }) + value = events.length > 0 + } catch (error) { + console.error('membership check failed:', error instanceof Error ? error.message : error) + value = false + } + // A yes is cached for longer than a no: memberships change, but a blob + // read is a hot path and the relay's state does not move that fast. + this.cache.set(cacheKey, { value, until: Date.now() + (value ? 30_000 : 5_000) }) + return value + } + + async isAnyMember(groups, pubkey) { + for (const group of groups) if (await this.isMember(group, pubkey)) return true + return false + } + + close() { + this.pool.close([this.url]) + } +} + +const membership = serviceSecret ? new Membership({ url: relayUrl, secret: serviceSecret }) : null + +function readReason(req, hash) { + const server = (req.headers.host ?? '').split(':')[0] + const auth = checkAuthHeader(req.headers.authorization, { verb: 'get', hash, server }) + return auth +} + +// --- HTTP ------------------------------------------------------------------ + const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${port}`) @@ -70,31 +238,58 @@ const server = createServer(async (req, res) => { if (body.length === 0) return json(res, 400, { message: 'empty upload' }) const hash = createHash('sha256').update(body).digest('hex') - const problem = checkAuth(req.headers.authorization, hash) - if (problem) return json(res, 401, { message: problem }) + const server = (req.headers.host ?? '').split(':')[0] + // requireHash: the upload token must bind to the content it uploads, so a + // token captured from one upload cannot authorise another blob. + const auth = checkAuthHeader(req.headers.authorization, { + verb: 'upload', + hash, + requireHash: true, + server, + }) + if (!auth.ok) return json(res, 401, { message: auth.reason }) + + const group = groupOf(auth.event) + if (!group) return json(res, 400, { message: 'the upload token carries no h tag (group)' }) const type = req.headers['content-type'] || 'application/octet-stream' await writeFile(join(store, hash), body) await writeFile(join(store, `${hash}.type`), String(type)) + const groups = await addGroup(hash, group) - console.log(`upload ${hash.slice(0, 12)} ${body.length} B ${type}`) + console.log(`upload ${hash.slice(0, 12)} ${body.length} B ${type} group=${group}`) return json(res, 200, { url: `http://localhost:${port}/${hash}`, sha256: hash, size: body.length, type, + group, + groups, uploaded: Math.floor(Date.now() / 1000), }) } - const match = /^\/([0-9a-f]{64})/.exec(url.pathname) - if (match && (req.method === 'GET' || req.method === 'HEAD')) { - const hash = match[1] - try { - await access(join(store, hash)) - } catch { - return json(res, 404, { message: 'not found' }) + const hash = blobHashFromPath(url.pathname) + if (hash && (req.method === 'GET' || req.method === 'HEAD')) { + if (!(await blobExists(hash))) return json(res, 404, { message: 'not found' }) + + const groups = await readGroups(hash) + if (!insecureReads) { + if (!membership) { + return json(res, 503, { + message: + 'no Blossom service identity configured (BLOSSOM_SERVICE_NSEC); refusing to serve attachments', + }) + } + const auth = readReason(req, hash) + if (!auth.ok) return json(res, 401, { message: auth.reason }) + if (groups.length === 0) return json(res, 403, { message: 'the blob is not filed under a group' }) + if (!(await membership.isAnyMember(groups, auth.event.pubkey))) { + console.log(`denied ${hash.slice(0, 12)} for ${auth.event.pubkey.slice(0, 12)}`) + return json(res, 403, { message: 'not a member of this attachment\'s group' }) + } } + const body = await readFile(join(store, hash)) let type = 'application/octet-stream' try { @@ -113,5 +308,19 @@ const server = createServer(async (req, res) => { server.listen(port, () => { console.log(`Blossom development server on http://localhost:${port}`) console.log(`files end up in ${store}`) + console.log( + insecureReads + ? 'READS ARE UNPROTECTED (--insecure-reads): anyone with the hash can fetch a blob' + : membership + ? `reads require a group membership, checked against ${relayUrl}` + : 'reads are REFUSED: set BLOSSOM_SERVICE_NSEC (or add BLOSSOM_SEC to scripts/.dev-keys)', + ) console.log('add to .env.local: VITE_BLOSSOM_SERVER=http://localhost:' + port) }) + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + membership?.close() + server.close(() => process.exit(0)) + }) +} diff --git a/scripts/dev-group-seed.sh b/scripts/dev-group-seed.sh index b6a6299..c50213a 100755 --- a/scripts/dev-group-seed.sh +++ b/scripts/dev-group-seed.sh @@ -52,15 +52,25 @@ if [[ ! -f "$KEYFILE" ]]; then { echo "ALICE_SEC=$(nak key generate "$KEYFILE" echo "new throwaway keys created in $KEYFILE" fi # shellcheck source=/dev/null source "$KEYFILE" +# Older key files predate the Blossom service identity (CON-26). Add it rather +# than regenerate: the app's memberships would not survive new keys. +if [[ -z "${BLOSSOM_SEC:-}" ]]; then + echo "BLOSSOM_SEC=$(nak key generate > "$KEYFILE" + # shellcheck source=/dev/null + source "$KEYFILE" + echo "added a Blossom service key to $KEYFILE" +fi # Close stdin explicitly: otherwise nak commands read from stdin and block # when the script runs out of a pipe. ALICE_PK=$(nak key public "$ALICE_SEC" echo "group: $GROUP_ID" echo "admin: $ALICE_PK (alice)" echo "member: $BOB_PK (bob)" +echo "service: $BLOSSOM_PK (blossom, reads 39002 for attachment rights)" echo # create-group without --fpa: the command hangs with --fpa because it reads @@ -106,6 +117,14 @@ OUT=$(run 25 nak group put-user "${NAK_AUTH[@]}" --pubkey "$BOB_PK" "$ADDRESS") grep -q '"kind":9000' <<<"$OUT" || { echo " ERROR: $(tail -1 <<<"$OUT")"; exit 1; } echo " added" +# The Blossom server checks attachment reads against 39002, and a private +# group's member list is only served to a member — so its service identity has +# to be one. scripts/dev-blossom.mjs reads BLOSSOM_SEC from the same key file. +echo "3b) add the Blossom service identity as a member" +OUT=$(run 25 nak group put-user "${NAK_AUTH[@]}" --pubkey "$BLOSSOM_PK" "$ADDRESS") || true +grep -q '"kind":9000' <<<"$OUT" || { echo " ERROR: $(tail -1 <<<"$OUT")"; exit 1; } +echo " blossom service added (BLOSSOM_SEC in $KEYFILE)" + # Fetch the head of a slug's revision chain, so that a second run of the # script continues the chain instead of creating a second root (which the app # would rightly show as a fork). diff --git a/src/nostr/attachment-access.test.ts b/src/nostr/attachment-access.test.ts new file mode 100644 index 0000000..3a682ba --- /dev/null +++ b/src/nostr/attachment-access.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Signer } from './signer' +import { protectedHashFor } from './attachment-access' + +const HASH = 'a'.repeat(64) +const SERVER = 'http://localhost:3355' + +describe('protectedHashFor', () => { + it('recognises a blob on our own server, extension and fragment included', () => { + expect(protectedHashFor(`${SERVER}/${HASH}`, SERVER)).toBe(HASH) + expect(protectedHashFor(`${SERVER}/${HASH}.png#width=480`, SERVER)).toBe(HASH) + expect(protectedHashFor(`http://localhost:3355/${HASH.toUpperCase()}.PDF`, SERVER)).toBe(HASH) + }) + + it('leaves foreign hosts, other ports and non-blob paths alone', () => { + expect(protectedHashFor(`https://example.com/${HASH}`, SERVER)).toBeNull() + expect(protectedHashFor(`http://localhost:9999/${HASH}`, SERVER)).toBeNull() + expect(protectedHashFor(`${SERVER}/upload`, SERVER)).toBeNull() + expect(protectedHashFor(`${SERVER}/${HASH}/extra`, SERVER)).toBeNull() + }) + + it('protects nothing without a configured server', () => { + expect(protectedHashFor(`${SERVER}/${HASH}`, '')).toBeNull() + }) +}) + +/** A fresh module with the build-time Blossom server stubbed in. */ +async function withServer() { + vi.resetModules() + vi.stubEnv('VITE_BLOSSOM_SERVER', SERVER) + return import('./attachment-access') +} + +describe('loadAttachmentUrl', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('returns a foreign URL unchanged, with or without a session', async () => { + const mod = await withServer() + mod.setAttachmentLoader(null) + expect(await mod.loadAttachmentUrl('https://example.com/x.png')).toBe('https://example.com/x.png') + }) + + it('refuses a protected blob without a session', async () => { + const mod = await withServer() + mod.setAttachmentLoader(null) + await expect(mod.loadAttachmentUrl(`${SERVER}/${HASH}`)).rejects.toThrow(/Sign in/) + }) + + it('fetches a protected blob with a t=get token and caches the object URL', async () => { + const mod = await withServer() + const templates: Array> = [] + const signer = { + kind: 'dev', + getPublicKey: async () => 'p', + signEvent: async (template: Record) => { + templates.push(template) + return { ...template, id: '1', pubkey: 'p', sig: 's' } + }, + } as unknown as Signer + + Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:mock', configurable: true }) + Object.defineProperty(URL, 'revokeObjectURL', { value: () => {}, configurable: true }) + // A minimal response object: jsdom's Blob and Node's Response do not agree + // on `.stream`, and the code under test only reads ok/status/blob/text. + const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({ + ok: true, + status: 200, + text: async () => '', + blob: async () => new Blob(['x'], { type: 'image/png' }), + })) + vi.stubGlobal('fetch', fetchMock) + + mod.setAttachmentLoader(mod.attachmentLoaderFor(signer)) + expect(await mod.loadAttachmentUrl(`${SERVER}/${HASH}`)).toBe('blob:mock') + // A second render of the same blob reuses the object URL instead of fetching again. + expect(await mod.loadAttachmentUrl(`${SERVER}/${HASH}`)).toBe('blob:mock') + expect(fetchMock).toHaveBeenCalledTimes(1) + + const tags = templates[0].tags as string[][] + expect(templates[0].kind).toBe(24242) + expect(tags).toContainEqual(['t', 'get']) + expect(tags).toContainEqual(['x', HASH]) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe(`${SERVER}/${HASH}`) + expect(init.headers).toMatchObject({ Authorization: expect.stringMatching(/^Nostr /) }) + }) +}) diff --git a/src/nostr/attachment-access.ts b/src/nostr/attachment-access.ts new file mode 100644 index 0000000..93b437a --- /dev/null +++ b/src/nostr/attachment-access.ts @@ -0,0 +1,180 @@ +import { KINDS } from './kinds' +import type { Signer } from './signer' +import { BLOSSOM_SERVER } from './blossom' + +/** + * Loading attachments that are protected by their group (CON-26). + * + * A blob on our own Blossom server is no longer readable by whoever knows its + * hash: the server wants a `t=get` token and checks that the token's key is a + * member of the group the blob was uploaded to. BUD-11 puts that token in the + * `Authorization` header, and a plain `` cannot send one — so a + * protected image is fetched here and handed to the renderer as an object URL. + * + * Everything on a foreign host is left exactly as it was: it is not ours to + * protect, and it keeps loading directly (docs/09-security-privacy.md). + */ + +/** The credential a caller supplies once signed in, or null when it is not. */ +export type AttachmentLoader = (url: string) => Promise + +let loader: AttachmentLoader | null = null + +/** Who can sign a read token right now. SessionProvider owns this. */ +export function setAttachmentLoader(next: AttachmentLoader | null): void { + loader = next +} + +/** This server's hostname, without the port — BUD-11 scopes by domain. */ +function serverHost(): string { + try { + return new URL(BLOSSOM_SERVER).hostname + } catch { + return '' + } +} + +/** + * The sha256 a URL names on a given Blossom server, or null when it does not + * point there. This is the line between "we can protect it" and "somebody + * else's host" — only the former is fetched with a token. Split out from + * `protectedHash` so it can be tested without the build-time environment. + */ +export function protectedHashFor( + url: string, + server: string, + origin?: string, +): string | null { + if (!server) return null + let target: URL + let base: URL + try { + target = new URL(url, origin) + base = new URL(server) + } catch { + return null + } + if (target.host !== base.host) return null + const match = /^\/([0-9a-fA-F]{64})(?:\.[A-Za-z0-9]+)?$/.exec(target.pathname) + return match ? match[1].toLowerCase() : null +} + +export function protectedHash(url: string): string | null { + return protectedHashFor( + url, + BLOSSOM_SERVER, + typeof window === 'undefined' ? undefined : window.location.origin, + ) +} + +export function isProtectedAttachment(url: string): boolean { + return protectedHash(url) !== null +} + +/** + * The URL to draw immediately, or null when it has to be fetched with a token + * first. Foreign URLs stay synchronous, so nothing that never touched our + * Blossom server changes behaviour. + */ +export function attachmentSrcSync(url: string): string | null { + return isProtectedAttachment(url) ? null : url +} + +/** Object URLs are alive until revoked; keep the newest ones, drop the rest. */ +const objectUrls = new Map() +const inFlight = new Map>() +const MAX_OBJECT_URLS = 64 + +function rememberObjectUrl(hash: string, url: string): string { + const existing = objectUrls.get(hash) + if (existing) { + URL.revokeObjectURL(url) + return existing + } + objectUrls.set(hash, url) + while (objectUrls.size > MAX_OBJECT_URLS) { + const oldest = objectUrls.keys().next().value + if (oldest === undefined) break + const stale = objectUrls.get(oldest) + objectUrls.delete(oldest) + if (stale) URL.revokeObjectURL(stale) + } + return url +} + +function base64Url(value: string): string { + return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** Fetch a protected blob once, as an object URL, and remember it. */ +async function loadProtected(signer: Signer, url: string, hash: string): Promise { + const cached = objectUrls.get(hash) + if (cached) return cached + const running = inFlight.get(hash) + if (running) return running + + const task = (async () => { + const now = Math.floor(Date.now() / 1000) + const auth = await signer.signEvent({ + kind: KINDS.BLOSSOM_AUTH, + created_at: now, + tags: [ + ['t', 'get'], + ['x', hash], + ['server', serverHost()], + ['expiration', String(now + 300)], + ], + content: 'read attachment', + }) + const target = new URL(url, window.location.origin) + target.hash = '' + const response = await fetch(target.toString(), { + headers: { Authorization: `Nostr ${base64Url(JSON.stringify(auth))}` }, + }) + if (!response.ok) { + const message = await response.text().catch(() => '') + throw new Error( + `The Blossom server answered ${response.status}${message ? `: ${message.slice(0, 200)}` : ''}`, + ) + } + const blob = await response.blob() + return rememberObjectUrl(hash, URL.createObjectURL(blob)) + })().finally(() => inFlight.delete(hash)) + + inFlight.set(hash, task) + return task +} + +/** The loader SessionProvider installs: sign a get token, fetch, object URL. */ +export function attachmentLoaderFor(signer: Signer): AttachmentLoader { + return (url) => { + const hash = protectedHash(url) + return hash ? loadProtected(signer, url, hash) : Promise.resolve(url) + } +} + +/** + * The URL an image or link should actually use. Foreign and unsigned cases fall + * through unchanged; only ours needs the loader, and without a session there is + * nothing to fetch it with. + */ +export async function loadAttachmentUrl(url: string): Promise { + if (!isProtectedAttachment(url)) return url + if (!loader) throw new Error('Sign in to load this attachment.') + return loader(url) +} + +/** + * Open a protected, non-image attachment. Downloading through a blob URL keeps + * the token out of the address bar and off the history. + */ +export async function openAttachment(url: string): Promise { + const resolved = await loadAttachmentUrl(url) + const link = document.createElement('a') + link.href = resolved + link.rel = 'noreferrer noopener' + link.download = '' + document.body.appendChild(link) + link.click() + link.remove() +} diff --git a/src/nostr/blossom.ts b/src/nostr/blossom.ts index 4a47376..6a9cd1c 100644 --- a/src/nostr/blossom.ts +++ b/src/nostr/blossom.ts @@ -15,10 +15,29 @@ export type UploadResult = export const BLOSSOM_SERVER: string = (import.meta.env.VITE_BLOSSOM_SERVER ?? '').trim() +/** + * The pubkey of the Blossom server's own service identity. The server needs it + * to read a private group's `39002` and answer "is this reader a member?" — + * so it has to be a member of every space that stores attachments. When set, + * creating a space adds it (CON-26). + */ +export const BLOSSOM_SERVICE_PUBKEY: string = ( + import.meta.env.VITE_BLOSSOM_SERVICE_PUBKEY ?? '' +).trim() + export function attachmentsEnabled(): boolean { return BLOSSOM_SERVER.length > 0 } +/** This server's hostname, without the port — BUD-11 scopes a token by domain. */ +export function blossomHost(): string { + try { + return new URL(BLOSSOM_SERVER).hostname + } catch { + return '' + } +} + /** Why an upload cannot happen without a server — shown, not swallowed. */ export const NO_BLOSSOM_SERVER = 'No Blossom server configured (VITE_BLOSSOM_SERVER).' @@ -36,21 +55,40 @@ async function sha256Hex(data: ArrayBuffer): Promise { * suggest a gate that is no longer there. docs/09-security-privacy.md */ -export async function uploadAttachment(signer: Signer, file: File): Promise { +/** + * `groupId` is not decoration: the upload token files the blob under its + * group (`h`) so the server can later check a reader against that group's + * membership. Without it the blob would be unreachable to everyone. + */ +export async function uploadAttachment( + signer: Signer, + file: File, + { groupId }: { groupId: string }, +): Promise { if (!attachmentsEnabled()) { return { ok: false, reason: NO_BLOSSOM_SERVER } } + if (!groupId) { + return { ok: false, reason: 'No space to file the attachment under.' } + } const data = await file.arrayBuffer() const hash = await sha256Hex(data) + const now = Math.floor(Date.now() / 1000) const auth = await signer.signEvent({ kind: KINDS.BLOSSOM_AUTH, - created_at: Math.floor(Date.now() / 1000), + created_at: now, tags: [ ['t', 'upload'], + // x binds the token to exactly this content; the server requires it. ['x', hash], - ['expiration', String(Math.floor(Date.now() / 1000) + 300)], + // server scopes the token to our host, so a leak cannot be replayed + // against another Blossom server. + ['server', blossomHost()], + // h is the NIP-29 group the blob belongs to — the read check looks here. + ['h', groupId], + ['expiration', String(now + 300)], ], content: `upload attachment ${file.name}`, }) diff --git a/src/session/session.tsx b/src/session/session.tsx index b0ce745..5e7e66c 100644 --- a/src/session/session.tsx +++ b/src/session/session.tsx @@ -8,6 +8,7 @@ import type { Signer } from '../nostr/signer' import { parseProfile, toNpub } from '../nostr/profile' import { cacheProfile } from '../nostr/profile-store' import { clearAllSpaces } from '../nostr/space-store' +import { attachmentLoaderFor, setAttachmentLoader } from '../nostr/attachment-access' import type { Profile } from '../nostr/profile' const STORAGE_KEY = 'nc-pubkey' @@ -154,6 +155,15 @@ export function SessionProvider({ children }: { children: ReactNode }) { } }, [loadProfile]) + // Attachment reads are signed with the session's key (CON-26). This is the + // one place that decides whether a protected blob can be fetched at all; + // signing out takes the credential away with the session. + const activeSigner = session.status === 'signed-in' ? session.signer : null + useEffect(() => { + setAttachmentLoader(activeSigner ? attachmentLoaderFor(activeSigner) : null) + return () => setAttachmentLoader(null) + }, [activeSigner]) + // Follow the other tabs. The storage event fires only in the tabs that did // not make the change, which is exactly the reach this needs. Best effort, // not a guarantee: it needs localStorage to work (a private window may diff --git a/src/ui/CreateSpaceForm.tsx b/src/ui/CreateSpaceForm.tsx index d5e0937..727944c 100644 --- a/src/ui/CreateSpaceForm.tsx +++ b/src/ui/CreateSpaceForm.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { createGroupAndWait, editMetadata } from '../nostr/moderation' +import { addMember, createGroupAndWait, editMetadata } from '../nostr/moderation' +import { BLOSSOM_SERVICE_PUBKEY } from '../nostr/blossom' import { classifyRejection } from '../nostr/client' import { APP_CONTENT_KINDS, normalizeSlug } from '../nostr/kinds' import { DEFAULT_RELAY_URL } from '../nostr/relay-status' @@ -104,6 +105,15 @@ export function CreateSpaceForm() { return } + // The Blossom server reads a private group's member list to answer "may + // this reader have the attachment?", so its service identity has to be a + // member of every space that stores files. Best effort: if it fails, an + // admin can still add the pubkey later, and only attachment reads are + // affected. CON-26 + if (BLOSSOM_SERVICE_PUBKEY) { + await addMember(session.signer, { ...base, pubkey: BLOSSOM_SERVICE_PUBKEY }) + } + navigate(`/s/${encodeURIComponent(address)}`) } catch (err) { setError(err instanceof Error ? err.message : 'signing was cancelled') diff --git a/src/ui/Markdown.tsx b/src/ui/Markdown.tsx index 150060d..be3d513 100644 --- a/src/ui/Markdown.tsx +++ b/src/ui/Markdown.tsx @@ -12,6 +12,12 @@ import { remarkMentions } from './markdown-mentions' import { normaliseInvisibleLines, rehypeBlankLines } from './markdown-blank-lines' import { remarkLineBreaks, remarkNoSetextHeadings } from './markdown-flavour' import { imageWidth } from './image-width' +import { + attachmentSrcSync, + loadAttachmentUrl, + openAttachment, + isProtectedAttachment, +} from '../nostr/attachment-access' /** * Sanitising is mandatory, not optional: content comes from arbitrary keys. @@ -116,6 +122,34 @@ const COMPACT: Scale = { * point. The trade is written down in docs/09-security-privacy.md. */ function MarkdownImage({ src, alt, title }: { src?: string; alt?: string; title?: string }) { + // A blob on our own Blossom server now needs a read token, and a plain + // `` cannot send one — so it is fetched and drawn from an object + // URL. Foreign hosts, and everything before a session exists, stay exactly as + // they were. CON-26, docs/09-security-privacy.md + const [resolved, setResolved] = useState(src ? attachmentSrcSync(src) : null) + useEffect(() => { + if (!src) { + setResolved(null) + return + } + const direct = attachmentSrcSync(src) + if (direct !== null) { + setResolved(direct) + return + } + let cancelled = false + void loadAttachmentUrl(src) + .then((url) => { + if (!cancelled) setResolved(url) + }) + .catch(() => { + if (!cancelled) setResolved(null) + }) + return () => { + cancelled = true + } + }, [src]) + if (!src) return null // The width a resize chose lives in the URL's fragment. Drawing it here is // the other half of that contract: what is made smaller in the editor is @@ -124,7 +158,7 @@ function MarkdownImage({ src, alt, title }: { src?: string; alt?: string; title? const width = imageWidth(src) return ( {alt + // A non-image attachment on our Blossom server needs the same read + // token as an image. Fetching it on click and downloading through a + // blob URL keeps the token out of the tab and the history. CON-26 + const protectedHref = + typeof href === 'string' && isProtectedAttachment(href) ? href : null return ( { + event.preventDefault() + void openAttachment(protectedHref) + } + : undefined + } className={cx('text-accent-fg underline underline-offset-2', className)} rel="noreferrer noopener" {...props} diff --git a/src/ui/PageEditor.tsx b/src/ui/PageEditor.tsx index 92b6c98..04a8725 100644 --- a/src/ui/PageEditor.tsx +++ b/src/ui/PageEditor.tsx @@ -171,7 +171,7 @@ export function PageEditor({ setUploading(true) try { for (const file of files) { - const result = await uploadAttachment(session.signer, file) + const result = await uploadAttachment(session.signer, file, { groupId }) if (!result.ok) { setUploadNote(`${file.name}: ${result.reason}`) return diff --git a/src/ui/editor-image.ts b/src/ui/editor-image.ts index e0ad7b3..326d22d 100644 --- a/src/ui/editor-image.ts +++ b/src/ui/editor-image.ts @@ -1,6 +1,7 @@ import { WidgetType } from '@codemirror/view' import type { EditorView } from '@codemirror/view' import { clampImageWidth, imageWidth, withImageWidth } from './image-width' +import { attachmentSrcSync, loadAttachmentUrl } from '../nostr/attachment-access' /** * An image in the editor: the picture, always. @@ -95,7 +96,21 @@ export class ImageWidget extends WidgetType { fitCleanup.set(wrapper, () => window.removeEventListener('resize', onResize)) } // Set last, so a cached picture cannot finish before the listener is on. - img.src = this.src + // A blob on our own Blossom server is fetched with a read token and drawn + // from an object URL; a foreign picture is drawn directly, as before. + // CON-26, src/nostr/attachment-access.ts + const direct = attachmentSrcSync(this.src) + if (direct !== null) { + img.src = direct + } else { + void loadAttachmentUrl(this.src) + .then((url) => { + if (wrapper.isConnected) img.src = url + }) + .catch(() => { + /* signed out or not a member: the picture simply stays empty */ + }) + } if (width === null) queueMicrotask(fit) const handle = document.createElement('span')