Skip to content

CON-26: attachment reads obey the group boundary - #75

Open
molgerz wants to merge 1 commit into
mainfrom
con-26-attachment-group-rights
Open

molgerz wants to merge 1 commit into
mainfrom
con-26-attachment-group-rights

Conversation

@molgerz

@molgerz molgerz commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes CON-26.

What this does

A Blossom blob was readable by anyone who knew its hash — no npub, no membership, no AUTH. Since the hash is derived from the content, "the URL is secret" is not a guarantee for any guessable file, and for a private space the text was protected while every attachment in it was not.

The rule now lives where the bytes are: the file server files each upload under a group and checks membership before it reads a blob back.

  • Upload — the kind 24242 token must name its group (h) and is bound to the content (x), scoped to this server (server) and to five minutes (expiration). The server records hash → group(s) (union, so the same bytes in two spaces belong to both).
  • Read — GET/HEAD /<sha256> requires a t=get token whose key is a member of one of the blob's groups. Membership is asked of the relay's 39002 as a service identity, because a private group's member list is not served to an anonymous reader.
  • Client — the app signs the t=get token, fetches protected blobs with it and hands the renderer an object URL (a plain <img src> cannot send an Authorization header). Non-image attachments download the same way. Pictures on foreign hosts are left exactly as they were.
  • Setup — scripts/dev-group-seed.sh generates a BLOSSOM_SEC service key and adds it to the group; creating a space in the app adds VITE_BLOSSOM_SERVICE_PUBKEY when configured. Without a service key the server fails closed; --insecure-reads is the explicit way back to the old behaviour.

Reviewed against the ticket's open decision

The refinement comment recommended option 1a (per-space ACL in the Blossom layer) over Buzz's community-scoped media, and over "accept and document". This implements 1a. Two decisions remain open and are called out in docs/09: whether production keeps per-space scope (a stock Blossom server needs an authorising proxy) and how that interacts with CON-4/CON-24.

Files

  • scripts/blossom-acl.mjs + scripts/blossom-acl.test.mjs — token validation (BUD-11), pure and unit-tested.
  • scripts/dev-blossom.mjs — group filing on upload, membership-checked reads, relay membership oracle, fail-closed config.
  • scripts/dev-group-seed.sh — the Blossom service identity and its membership.
  • src/nostr/attachment-access.ts + test — URL classification, t=get signing, object-URL cache.
  • src/nostr/blossom.ts, src/session/session.tsx, src/ui/Markdown.tsx, src/ui/editor-image.ts, src/ui/PageEditor.tsx, src/ui/CreateSpaceForm.tsx.
  • Docs: docs/02, docs/04, docs/07, docs/08, docs/09, docs/13, NOSTR.md, .env.example.

Test plan

Automated (run locally — all green)

  • npm run typecheck passes
  • npm run lint passes
  • npm test passes — 239 files / 2226 tests, including the new scripts/blossom-acl.test.mjs (14) and src/nostr/attachment-access.test.ts (6)
  • npm run build passes

Server (unit + smoke, already exercised locally)

  • upload token without x → 401; without h → 400; no token → 401; valid → 200
  • read with no service key configured → 503 (fails closed, even with a valid t=get token)
  • --insecure-reads serves the blob again (escape hatch works)
  • token checks: wrong kind, tampered signature, future created_at, expired/missing expiration, wrong t verb, wrong/missing x when required, server scoping
  • blobHashFromPath accepts /<sha256> and /<sha256>.png, rejects everything else

Manual, end to end against the real relay (needs the relay + seed)

  • ./scripts/dev-relay-up.sh && ./scripts/dev-group-seed.sh → the new Blossom service key is created and added to engineering
  • node scripts/dev-blossom.mjs + VITE_BLOSSOM_SERVER=http://localhost:3355 + VITE_BLOSSOM_SERVICE_PUBKEY=<BLOSSOM_PK>
  • as a member (alice): upload an image, it renders in the editor and in the read view; upload a PDF, the link downloads
  • as a non-member (signed in with a key that is not in the group): the page still loads but the attachment does not (fetch 403)
  • signed out: a protected attachment does not load
  • a picture on a foreign host still renders with no token
  • resize an uploaded image in the editor — the width fragment still round-trips into the Markdown
  • after a reload, protected images load again (object-URL cache is per session, not persisted)

Regression / honesty

  • an empty VITE_BLOSSOM_SERVER behaves exactly as before (no critical path touches the new code)
  • docs/09-security-privacy.md, docs/04 and NOSTR.md no longer claim attachments sit outside the group boundary
  • with no service key the server log says reads are refused, not silently open

Known limits (deliberate)

  • A page may embed a picture from any host; those are not ours to protect and still leak who is reading. Stated plainly in docs/09.
  • The shipped server stays a development one. Production needs the same rule in whatever serves files — an authorising proxy in front of a stock Blossom server, or relay-integrated media. Tracked by CON-4/CON-24.
  • The membership cache holds a "yes" for 30s, so removing a member can take that long to bite.

🤖 Generated with DeepSeek Harness

A Blossom blob was readable by anyone who knew its hash, whatever the group's
private flag said — and the hash is derived from the content, so for a guessable
file it is guessable. The file server now carries the rule the relay cannot: an
upload files the blob under a group (`h`) and binds the token to its content
(`x`); a read needs a `t=get` token whose key is a member of one of those
groups, checked against the relay's `39002` as a service identity, because a
private group's member list is not served to an anonymous reader.

The app signs the read token, fetches protected blobs and draws object URLs — a
plain `<img src>` cannot send an Authorization header. Pictures on foreign
hosts are left exactly as they were. The dev seed adds a Blossom service key and
a space created in the app adds `VITE_BLOSSOM_SERVICE_PUBKEY` when configured.

Server checks are unit-tested (`scripts/blossom-acl.test.mjs`) and the
fail-closed read path is smoke-tested; the client's URL classification, token
signing, fetch and object-URL cache in `src/nostr/attachment-access.test.ts`.
@molgerz

molgerz commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Correction to the manual test plan (found while setting up the live environment): the bullet "as a non-member: the page still loads but the attachment does not" describes a state that cannot occur in this configuration. The space is private, and the relay filters 1818 out for non-members entirely (docs/04, measured), so a non-member never reaches a page with an image in it.

The non-member case is covered where it can actually be observed — server-side, and already verified against the live relay:

request result
upload as a member 200
GET with no token 401
GET as a member (alice/bob) 200
GET as a non-member, valid t=get token 403

What remains for the client is therefore: a member sees the attachment, a signed-out session does not, and a fetch failure leaves the picture empty rather than drawing a broken one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant