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 (
+ // 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')