Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
11 changes: 8 additions & 3 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 |
Expand Down Expand Up @@ -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 |

* * *

Expand Down Expand Up @@ -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.

* * *

Expand All @@ -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).
12 changes: 12 additions & 0 deletions docs/02-data-model-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
12 changes: 9 additions & 3 deletions docs/04-permissions-nip29.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/07-tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/08-relay-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions docs/09-security-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 `<img src>` 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
Expand Down
6 changes: 6 additions & 0 deletions docs/13-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions scripts/blossom-acl.mjs
Original file line number Diff line number Diff line change
@@ -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 <base64url>` 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: `/<sha256>` or `/<sha256>.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 }
Loading
Loading