| 1 | diff --git a/README.md b/README.md |
| 2 | index f0a4420..8bfbaa6 100644 |
| 3 | --- a/README.md |
| 4 | +++ b/README.md |
| 5 | @@ -31,7 +31,7 @@ The repository contains three executables in one Go module: |
| 6 | |
| 7 | `make build` writes all three binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, and tinyclient needs a backend and preserves its public origin and tenant path. |
| 8 | |
| 9 | -See [Architecture](docs/architecture.md) and [Module boundaries and protocol contracts](docs/module-contracts.md) for the current responsibilities and proposed follow-up boundaries. |
| 10 | +Reusable `protocol/nostr` and `protocol/auth` packages supply wire values and proof verification without importing host services. Feature semantics and access policy remain separate. See [Architecture](docs/architecture.md), [Module boundaries and protocol contracts](docs/module-contracts.md) and the [extension catalog](docs/extensions/README.md) for ownership, supported profiles and remaining seams. |
| 11 | |
| 12 | ## Run locally |
| 13 | |
| 14 | diff --git a/cmd/tiny/gittoken.go b/cmd/tiny/gittoken.go |
| 15 | index 8833728..9e00c3f 100644 |
| 16 | --- a/cmd/tiny/gittoken.go |
| 17 | +++ b/cmd/tiny/gittoken.go |
| 18 | @@ -11,8 +11,8 @@ import ( |
| 19 | "strings" |
| 20 | "time" |
| 21 | |
| 22 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 23 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 24 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 25 | ) |
| 26 | |
| 27 | // gitToken mints the GRASP-08 repository-root proof that private Git hosting |
| 28 | diff --git a/cmd/tiny/gittoken_test.go b/cmd/tiny/gittoken_test.go |
| 29 | index 7d4e632..5ba579e 100644 |
| 30 | --- a/cmd/tiny/gittoken_test.go |
| 31 | +++ b/cmd/tiny/gittoken_test.go |
| 32 | @@ -6,7 +6,7 @@ import ( |
| 33 | "testing" |
| 34 | "time" |
| 35 | |
| 36 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 37 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 38 | ) |
| 39 | |
| 40 | func TestGitTokenMintsAReusableRepositoryRootProof(t *testing.T) { |
| 41 | diff --git a/docs/agents.md b/docs/agents.md |
| 42 | index 0342d70..ecd5a1c 100644 |
| 43 | --- a/docs/agents.md |
| 44 | +++ b/docs/agents.md |
| 45 | @@ -4,6 +4,8 @@ An agent is a member of your relay that acts under a grant you sign. It has its |
| 46 | |
| 47 | Agents suit assistants, bots and automations that should post as themselves rather than with your key. Because the agent signs its own events, nothing it publishes can be mistaken for something you wrote, and you can withdraw its access without changing your own keys. |
| 48 | |
| 49 | +The [agent-grant contract](extensions/agent-grants.md) and [reviewed grant-request contract](extensions/agent-grant-requests.md) specify these project extensions, their authority rules and compatibility limits. They build on Nostr events but are not themselves upstream NIPs. |
| 50 | + |
| 51 | ## Grant an agent |
| 52 | |
| 53 | The owner or a moderator grants an agent by publishing a kind 30392 event addressed to the agent's public key. Publishing the grant makes the agent a member with the role `agent`. Publishing a new grant for the same agent replaces the earlier one. |
| 54 | @@ -352,7 +354,9 @@ export function verify(body, header, secret) { |
| 55 | } |
| 56 | ``` |
| 57 | |
| 58 | -Right before each POST the relay checks that the callback still exists and is not paused and that the key may still read the event, so hiding an event or revoking an agent stops deliveries at once. Events go to a callback one at a time, in the order they arrived as far as possible. A repository state is delivered once its objects have arrived, so a push wakes the agent when the objects can be fetched. |
| 59 | +Before each POST, the relay checks that the callback exists and is not paused, that its owner still has membership and that the read gate permits the queued event. Delivery does not separately recheck whether an agent grant is active or reload the source event. Pause the callback itself when delivery must stop; pausing a grant or deleting an event is not a guaranteed cancellation of already queued delivery. An in-flight request cannot be recalled. |
| 60 | + |
| 61 | +Each callback has at most one in-process delivery at a time. Retries can repeat delivery, so receivers must handle events idempotently. A repository state is delivered once its objects have arrived. See the [callback contract](extensions/callbacks.md) for exact payloads, management responses, retries and audited limits. |
| 62 | |
| 63 | ### Retries and pauses |
| 64 | |
| 65 | diff --git a/docs/architecture.md b/docs/architecture.md |
| 66 | index 78a202f..64f089b 100644 |
| 67 | --- a/docs/architecture.md |
| 68 | +++ b/docs/architecture.md |
| 69 | @@ -12,8 +12,10 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae |
| 70 | |
| 71 | | Packages | Responsibility | |
| 72 | | --- | --- | |
| 73 | -| [`event`](../internal/event/doc.go), [`policy`](../internal/policy/doc.go) | Event and filter values, signing, validation and shared policy decisions. | |
| 74 | -| [`auth`](../internal/auth/doc.go), [`gates`](../internal/gates/doc.go) | Signed request proofs, event admission and visibility checks. | |
| 75 | +| [`protocol/nostr`](../protocol/nostr/doc.go) | Public event and filter values, canonical encoding, signatures and generic wire checks. No host access policy. | |
| 76 | +| [`protocol/auth`](../protocol/auth/doc.go) | Public request-proof verification and replay state. No membership or permission decisions. | |
| 77 | +| [`event`](../internal/event/doc.go), [`auth`](../internal/auth/doc.go) | Internal compatibility aliases and feature helpers; private-kind search filtering remains host behavior. | |
| 78 | +| [`policy`](../internal/policy/doc.go), [`gates`](../internal/gates/doc.go) | Host policy, event admission and visibility checks. | |
| 79 | | [`storage`](../internal/storage/doc.go), [`work`](../internal/work/doc.go) | Event persistence, queries, transaction hooks and durable queue claims. | |
| 80 | | [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. | |
| 81 | | [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. | |
| 82 | @@ -38,6 +40,8 @@ Consumers declare interfaces around the operations they need. `records.Community |
| 83 | |
| 84 | ## Event acceptance |
| 85 | |
| 86 | +`protocol/nostr.Validate` checks wire shape, event ID and signature. `tinygit.ParseMetadata` separately parses repository claims without establishing signer validity, repository authority or object availability. Git admission entry points perform signature and metadata checks before host admission callbacks. Generic `nostr.Matches` is not a visibility gate; callers must apply host access policy. |
| 87 | + |
| 88 | Client publication and imported events use the shared event persistence and projection path. Each entry point applies its admission rules and protocol actions. Signed repository state stays pending until its referenced Git objects are available; promotion makes it visible and plans its optional follow-up work. |
| 89 | |
| 90 | Callbacks and custom views are optional. A planning intent commits with an accepted event and captures its eligible registrations or failed local lookups. Recovery rechecks the source and current registrations, with at most three worker attempts and a 15-minute age limit. Stable child intent IDs preserve completed work. Remote delivery and transforms have their own retry policies. See [callbacks](agents.md#retries-and-pauses) and [custom views](views.md#failures). |
| 91 | diff --git a/docs/extensions/README.md b/docs/extensions/README.md |
| 92 | index d37806d..2203c4e 100644 |
| 93 | --- a/docs/extensions/README.md |
| 94 | +++ b/docs/extensions/README.md |
| 95 | @@ -2,8 +2,33 @@ |
| 96 | |
| 97 | This catalog indexes project-defined interoperability contracts. An extension is not a claim of upstream NIP, GRASP or Blossom standardization. See [Module boundaries and protocol contracts](../module-contracts.md) for the distinction between upstream rules, supported profiles, project extensions and implementation details. |
| 98 | |
| 99 | -| Identifier | Owner | Status | Specification | |
| 100 | +| Identifier | Contract owner | Status | Specification | |
| 101 | | --- | --- | --- | --- | |
| 102 | -| `tinyclient-read-adapter` | `tinyclient` contract and tinyrelay authorization | Experimental | [Read-only frontend/backend HTTP adapter](tinyclient-read-adapter.md) | |
| 103 | +| `tinyclient-read-adapter` | `tinyclient` presentation contract; tinyrelay data and authorization | Experimental, revision 1 | [Read-only frontend/backend HTTP adapter](tinyclient-read-adapter.md) | |
| 104 | +| `agent-grants` | `internal/community` grant semantics; `internal/gates` admission; `internal/daemon` integration | Experimental, version 1 | [Agent grants](agent-grants.md) | |
| 105 | +| `agent-grant-requests` | `internal/community` review and replacement; `internal/gates` admission; `internal/daemon` MCP and approvals | Experimental, version 1 | [Reviewed grant requests](agent-grant-requests.md) | |
| 106 | +| `callbacks` | `internal/daemon` callback service and management | Experimental, version 1 | [Event callbacks](callbacks.md) | |
| 107 | +| `custom-view-transforms` | `internal/daemon` transform service; `internal/views` block identity; `internal/records` relay signing | Experimental, version 1 | [Custom-view block transforms](custom-view-transforms.md) | |
| 108 | |
| 109 | -The catalog is not yet a complete inventory of existing project-specific behavior. Agent grants and requests, callbacks, custom-view transforms and other custom bindings remain documented in their feature guides pending a wire-contract audit. Add entries as those contracts are checked against their implementation and tests; do not infer compliance or compatibility from a feature's name. |
| 110 | +## Status and compatibility |
| 111 | + |
| 112 | +The four version 1 specifications document existing implementation behavior audited at repository baseline [`9d68e8778c3448fdc08c95e7b44bb30732692f83`](https://012.run/repo?owner=c01503e8035f5a98eb2004721dc4eeddda498c7871927ae7edb4525ab6ccf2d7&repo=tinyrelay&ref=9d68e8778c3448fdc08c95e7b44bb30732692f83&view=home). Relative source and test links identify the owners in this stack. Their identifiers and versions are documentation identifiers, not new wire fields. There is no negotiated extension version, extension handshake or independent conformance certification. Use compatible client and relay revisions; changing existing field meanings, authority or persisted identifiers requires a compatibility review, not a naming-only refactor. |
| 113 | + |
| 114 | +“Must” and “rejects” in these specifications describe the audited contract, not new runtime requirements. An explicit audit limit takes precedence over broader wording in a feature guide. The catalog remains incomplete: unlisted project bindings and proposed reusable view definitions are not implicitly covered. The upstream standards audit is also incomplete. References below identify foundations, but this audit did not verify an upstream commit or establish full conformance to those documents. A NIP number advertised by the relay is discovery, not proof that every provision is implemented. |
| 115 | + |
| 116 | +## Shared transport boundary |
| 117 | + |
| 118 | +Signed grant and request events use the normal Nostr event envelope and publication path: WebSocket `EVENT`, `POST /events` with one event JSON object, or an MCP publishing tool. Event ID/signature validation proves authorship; the feature's current authority checks decide whether the event may take effect. The relay does not sign as an agent or convert a request into an operator grant. |
| 119 | + |
| 120 | +Management methods in these specifications are project methods carried through the [NIP-86](https://github.com/nostr-protocol/nips/blob/master/86.md) management envelope. Send `POST /` at the tenant root with `Content-Type: application/nostr+json+rpc` and `{"method":"<method>","params":[...]}`. Except for `supportedmethods`, the HTTP handler resolves the actor from authentication. [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) proofs are bound to the actual URL, method and body; a supplied public key is not authority. HTTP routing recognizes the management media type after dedicated routes, so clients should use the root, not invent a feature-specific management endpoint. |
| 121 | + |
| 122 | +Management HTTP success is `{"result":...}` with status 200; errors are `{"error":"<reason>"}`. Authentication failures use 401; `restricted:` and `blocked:` method errors use 403; other method errors use 400. Event publication instead returns `{"event_id":"...","accepted":true|false,"message":"..."}` with status 200 or 400 after parsing/authentication. Clients must inspect `accepted`, not assume all error prefixes map to the management HTTP status rules. MCP tools use `POST /mcp`; inspect `isError` and the publishing result, not HTTP success alone. The MCP transport revision is separate from these extension versions; see [MCP](../mcp.md). |
| 123 | + |
| 124 | +Sources: [`http.go`](../../internal/daemon/http.go) (`ServeHTTP`, `bridge`, `httpPublish`, `manageHTTP`, `Execute`), [`mcp_tools.go`](../../internal/daemon/mcp_tools.go), [`mcp_http.go`](../../internal/daemon/mcp_http.go), [`relay.go`](../../internal/relay/relay.go) and [`event.go`](../../internal/event/event.go). |
| 125 | + |
| 126 | +## Ownership rules |
| 127 | + |
| 128 | +- Feature owners define field parsing and semantic validation. Shared event and storage code does not confer operator, repository or tenant authority. |
| 129 | +- The daemon binds authentication, host policy and feature services. An internal parser or unsigned-event builder alone is not an admission boundary. |
| 130 | +- The caller that stores an accepted event owns its transaction. Grant effects participate in that transaction; optional callback and transform planning must not turn a valid source event into a rejected publish. Remote delivery is subsequent work, not part of the source event's atomic commit. |
| 131 | +- Test links are evidence for the stated behavior, not a declaration of an exhaustive test suite. Gaps and differences between guides, schemas and implementation are recorded in each specification. |
| 132 | diff --git a/docs/extensions/agent-grant-requests.md b/docs/extensions/agent-grant-requests.md |
| 133 | new file mode 100644 |
| 134 | index 0000000..1cfdf7a |
| 135 | --- /dev/null |
| 136 | +++ b/docs/extensions/agent-grant-requests.md |
| 137 | @@ -0,0 +1,93 @@ |
| 138 | +# Reviewed grant requests |
| 139 | + |
| 140 | +| Field | Value | |
| 141 | +| --- | --- | |
| 142 | +| Identifier | `agent-grant-requests` | |
| 143 | +| Status | Experimental | |
| 144 | +| Contract version | 1; no negotiated wire version | |
| 145 | +| Owner | `internal/community` request parsing, patching and replacement validation; `internal/gates` admission/privacy; `internal/daemon` MCP preparation, approvals and history. | |
| 146 | +| Wire | Agent-signed kind `1111` with `request=grant`; operator-signed kind `30392` replacement. | |
| 147 | + |
| 148 | +The [catalog's audit baseline and transport rules](README.md) apply. [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md) supplies the kind 1111 comment/reference foundation; [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md) supplies kind 7 reactions; NIP-01 and NIP-40 supply signed event and expiration foundations. The `grant` request type, JSON patch, provenance and authority rules are project additions to those foundations. No upstream revision or full NIP-22 conformance audit is asserted. This is not the general-purpose approval convention or upstream delegation. See [Agent identities](../agents.md#requesting-additional-access) for the workflow. |
| 149 | + |
| 150 | +## Request envelope |
| 151 | + |
| 152 | +The request is signed by the agent named by the active current [grant](agent-grants.md). Required tags are: |
| 153 | + |
| 154 | +```json |
| 155 | +[ |
| 156 | + ["E", "<current grant event ID>", "", "<operator pubkey>"], |
| 157 | + ["K", "30392"], |
| 158 | + ["P", "<operator pubkey>"], |
| 159 | + ["e", "<current grant event ID>", "", "<operator pubkey>"], |
| 160 | + ["k", "30392"], |
| 161 | + ["request", "grant"], |
| 162 | + ["grant", "{\"base\":\"<current grant event ID>\",\"changes\":{\"kinds\":[9]}}"], |
| 163 | + ["p", "<operator pubkey>"] |
| 164 | +] |
| 165 | +``` |
| 166 | + |
| 167 | +This is the builder's tag shape, with placeholders, not a signed event. The content is the reason: valid UTF-8, nonblank after trimming, at most 500 Unicode code points. Direct event validation counts the original content; the MCP builder trims it before constructing the event. |
| 168 | + |
| 169 | +[`decodeGrantRequest` and `ReviewAgentGrantRequest`](../../internal/community/agent_grant_requests.go) enforce: |
| 170 | + |
| 171 | +- Kind `1111` and first `request` value exactly `grant`. |
| 172 | +- Only `request`, `grant`, `E`, `K`, `P`, `e`, `k`, `p`, `subject` and `expiration` tag names. Any repeated tag name or unsupported/empty tag is rejected. `subject` and `expiration` are optional; no extension-specific subject length check is made. |
| 173 | +- `grant` contains one JSON value with fields `base` and `changes`. Unknown struct fields or trailing JSON values are rejected. Duplicate JSON object keys are not separately rejected by the Go decoder; clients should emit unique keys. |
| 174 | +- `base` equals both `E` and `e`. The reference is 64 characters and must resolve to the current grant; `K` and `k` are `30392`; `P` and the one `p` name the current grant signer. Reference relay hints and additional positions are not fully NIP-22-validated here; clients should use the builder shape above. |
| 175 | +- The stored current grant belongs to the requesting agent and is active. A human role with a grant cannot use the narrow agent-request admission path. Requests without grants are rejected even when general comment publishing is allowed. |
| 176 | +- At least one nonnull change field is present, and the resulting parsed scope differs from the original under ordered `reflect.DeepEqual` comparison. Empty arrays alone or adding only existing kinds/rooms cannot satisfy the no-op check. Repository/site replacements remove matching tags and append requested entries, so reordering alone can pass even when permissions are unchanged. |
| 177 | + |
| 178 | +The optional positive `expiration` ends review eligibility. Malformed/nonpositive expiration values map to no expiry through [`event.Expiration`](../../internal/event/event.go); this parser does not add a stricter expiration syntax check. Ordinary event admission still rejects already expired requests. |
| 179 | + |
| 180 | +## Changes and replacement construction |
| 181 | + |
| 182 | +| JSON field | Accepted value and operation | |
| 183 | +| --- | --- | |
| 184 | +| `kinds` | Integer array; add entries, retaining existing kinds. Resulting grant kinds must be from 0 through 65535. | |
| 185 | +| `rooms` | String array; add entries. Each requested room must match `^[a-z0-9_-]{1,64}$`, stricter than a direct grant's room parser. | |
| 186 | +| `repos` | Objects with `owner`, `identifier`, `level`; replace entries matched by raw owner/identifier, retaining other repositories (see compatibility limits below). Repeated identities in a request are rejected. Resulting entries must pass the grant parser. | |
| 187 | +| `sites` | Objects with `label`, optional integer `ttl` and boolean `encrypted`; replace entries matched by raw label, retaining others (see compatibility limits below). Duplicate labels and negative TTLs are rejected. Omitted/zero TTL means no TTL in the signed-event path; positive TTL is 1 through 365. The label must belong to the agent or be `*`. | |
| 188 | +| `wiki` | String replacing the field: empty, `propose` or `edit`. | |
| 189 | +| `jobs` | String replacing the field: empty, `request`, `serve` or `both`. | |
| 190 | +| `rate` | Integer replacing the field, 1 through 600. | |
| 191 | + |
| 192 | +All resulting lists and fields pass [`ParseAgentGrant`](../../internal/community/agents.go), including its 256-entry caps. Null pointer fields mean no replacement. There is no request patch for the agent, operator, name, expiration or grant content. Kinds/rooms cannot remove entries; repository/site entries can change an existing permission, and empty wiki/jobs can clear those fields. “Additional access” is a use case, not a rule that every patch must increase permissions. |
| 193 | + |
| 194 | +[`grantRequestTags`](../../internal/community/agent_grant_requests.go) copies retained tags in order, removes previous request provenance, adds requested kinds then rooms then repositories then sites, and appends supplied wiki/jobs/rate values. It then appends: |
| 195 | + |
| 196 | +```json |
| 197 | +[ |
| 198 | + ["grant-request", "<request event ID>"], |
| 199 | + ["grant-base", "<current grant event ID>"], |
| 200 | + ["e", "<request event ID>", "", "grant-request"] |
| 201 | +] |
| 202 | +``` |
| 203 | + |
| 204 | +The unsigned replacement retains the original operator, kind, name, expiration and content. Its ID/signature are cleared and its timestamp is advanced past the base grant if necessary. Clients should sign the reviewed template without reordering or normalizing tags: validation compares the complete ordered tag array as well as parsed scope, identity, name, expiration and original content. |
| 205 | + |
| 206 | +## Authority and lifecycle |
| 207 | + |
| 208 | +1. `request_grant` over `POST /mcp` with `reason` and `changes` derives the current base/operator and returns `unsigned` plus `next`. This prepares but does not publish. Its schema is in [`mcp_grant_request.go`](../../internal/daemon/mcp_grant_request.go). |
| 209 | +2. The agent signs the event. Calling `request_grant` with `event` requires its `pubkey` to match the authenticated actor, reviews it again and uses normal publication. WebSocket `EVENT` and `POST /events` enforce the same feature admission through the gate. This narrow path does not require `k=1111`; the agent rate limit and host restrictions still apply. |
| 210 | +3. The request can be read only by its author or the `p` operator, subject to other read checks. Relay-owner/moderator standing alone does not bypass this participant check in [`Gate.CanSee`](../../internal/gates/gates.go). Notifications direct the operator to review, not one-tap grant approval. |
| 211 | +4. Approval is a separately signed replacement grant from that operator, who must still have grant authority. A `+` reaction never changes access and does not settle the request as approved. Agents cannot approve themselves. |
| 212 | +5. Any stored, unexpired kind 7 reaction from the operator with an `e` reference to the request and trimmed content `-` blocks replacement. This is not a newest-reaction-wins rule. A later `+` does not undo a still-stored denial. |
| 213 | +6. Replacement requires the request to be stored, not expired or denied; its base to remain current; the agent grant to remain unpaused, unrevoked and unexpired; and the exact reviewed replacement fields/tags. Validation runs at admission and again in [`ApplyAgentEventTx`](../../internal/community/agents.go), inside the event/grant transaction. A prepared template is not a reservation on current authority. |
| 214 | +7. Approvals report `open`, `answered` with decision `approved`/`denied`, or `expired`. A superseded base produces a conflict and is rendered expired; inactive requests can carry `grant_error` with no actionable review. [`grant_approvals.go`](../../internal/daemon/grant_approvals.go) consults archived grant revisions so a later grant edit does not erase the historical approval receipt. A historical approved receipt does not prove the permission is still effective. |
| 215 | + |
| 216 | +Private visibility is relay access control, not encryption. The operator and relay can read the reason and requested scope; signed copies may exist outside this relay. Do not put secrets in a rationale. |
| 217 | + |
| 218 | +## Errors and discovery |
| 219 | + |
| 220 | +Error families include `invalid:` for malformed/no-op patches or replacement differences; `restricted:` for a missing/inactive grant, wrong requesting role or replacement operator; and `conflict:` for stale, expired or denied requests. Examples are `invalid: grant request changes nothing`, `conflict: agent grant changed since the request` and `invalid: grant replacement tags differ from the reviewed request`. MCP preparation/submission failures set `isError` and include an `expected` shape; an unsigned preview is not a successful write. Apply the [transport error rules](README.md#shared-transport-boundary). |
| 221 | + |
| 222 | +Discover `request_grant` with MCP `tools/list`. `browseapprovals` and `browseapproval` are daemon read queries used by the approvals UI; the latter supplies the `grant` review (`request_id`, `base`, `agent`, `operator`, `before`, `after`, `unsigned`) or `grant_error`. There is no dedicated grant-request version capability; the relay's `agents` information entry describes grants generally, not caller eligibility. |
| 223 | + |
| 224 | +## Compatibility limits and evidence |
| 225 | + |
| 226 | +- MCP's advertised plain-field schema requires nonempty wiki/jobs strings and a site TTL of at least 1, while signed-event review permits empty wiki/jobs and omitted/zero TTL. Do not assume schema acceptance equals event admission or that both paths expose every clearing operation. |
| 227 | +- Direct grants trim repository strings and site labels, and repository identifiers may contain colons. The request patch remover matches raw tags without trimming and splits repository strings differently (`SplitN(..., 3)`). A normalized repository/site identity may therefore fail to match the old tag; the retained entry takes precedence over the appended entry when parsed. This can make the request a rejected no-op or leave that replacement unapplied within an otherwise accepted patch. For example, an existing `["sites", " * ", "ttl=7"]` plus a request adding a new kind `9` and setting site `*` to TTL 3 is accepted but retains effective TTL 7. Inspect the reviewed effective scope, not just the requested changes. This audit documents the mismatches; it does not redefine identities or fix runtime behavior. |
| 228 | +- Request JSON and NIP-22 references have the decoder/positional validation limits noted above. No independently versioned JSON schema or exhaustive upstream conformance suite is claimed. |
| 229 | + |
| 230 | +Evidence: [`community/agent_grant_requests_test.go`](../../internal/community/agent_grant_requests_test.go) tests retained entries and patch ordering; [`grant_admission_test.go`](../../internal/daemon/grant_admission_test.go) tests malformed requests, role/state restrictions, exact replacement and stale review; [`grant_approvals_test.go`](../../internal/daemon/grant_approvals_test.go) tests no-kind-1111 admission, participant reads, ineffective `+`, successful replacement/history, denial and supersession; [`mcp_grant_request_test.go`](../../internal/daemon/mcp_grant_request_test.go) tests operator-bound preparation, invalid changes and schema behavior. |
| 231 | diff --git a/docs/extensions/agent-grants.md b/docs/extensions/agent-grants.md |
| 232 | new file mode 100644 |
| 233 | index 0000000..49cca12 |
| 234 | --- /dev/null |
| 235 | +++ b/docs/extensions/agent-grants.md |
| 236 | @@ -0,0 +1,84 @@ |
| 237 | +# Agent grants |
| 238 | + |
| 239 | +| Field | Value | |
| 240 | +| --- | --- | |
| 241 | +| Identifier | `agent-grants` | |
| 242 | +| Status | Experimental | |
| 243 | +| Contract version | 1; no negotiated wire version | |
| 244 | +| Owner | `internal/community` parses grants and owns their effective state; `internal/gates` enforces admission; `internal/daemon` binds management, storage and feature authority. | |
| 245 | +| Wire | Kind `30392`; address `30392:<operator pubkey>:<agent pubkey>`. | |
| 246 | + |
| 247 | +The [catalog's audit baseline and transport rules](README.md) apply. Foundations are [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) signed/addressable events, [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion, [NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md) expiration, and NIP-86/NIP-98 management. Kind 30392, its grant tags, the agent role and the management methods below are project conventions, not an upstream delegation standard. No upstream revision or complete conformance audit is asserted. The user guide is [Agent identities](../agents.md). |
| 248 | + |
| 249 | +## Event fields |
| 250 | + |
| 251 | +The agent remains the author and signer of its own events. The grant's `pubkey` is the operator, not the agent. [`ParseAgentGrant`](../../internal/community/agents.go) validates the following fields; signer authority is a separate check. |
| 252 | + |
| 253 | +| Field or tag | Version 1 validation and interpretation | |
| 254 | +| --- | --- | |
| 255 | +| `kind` | Exactly `30392`. | |
| 256 | +| `d` | Required agent public key: 64 lowercase hexadecimal characters. Must not equal the event's signer. | |
| 257 | +| `p` | Required; its first value must equal `d`. | |
| 258 | +| `expiration` | Required positive, signed 64-bit Unix timestamp; no later than the admission time plus 365 days. A timestamp at or before admission time is allowed as revocation, unlike ordinary expired events. | |
| 259 | +| `content` | Empty or any valid JSON value, not necessarily an object. No permission is derived from it. | |
| 260 | +| `name` | Optional; trimmed and truncated to 64 **bytes**, not rejected for excess length. | |
| 261 | +| `room` | Repeated, trimmed strings of 1 to 128 bytes; deduplicated after trimming. The grant parser does not require the narrower room ID syntax. | |
| 262 | +| `repo` | Repeated `<owner>:<identifier>:<level>`. Owner is a lowercase 64-character hex key; identifier is 1 to 256 bytes; level is exactly `propose`, `read` or `maintain`. The final colon separates the level; the identifier may contain colons. Outer whitespace is trimmed. First entry for an owner/identifier wins. | |
| 263 | +| `k` | Repeated decimal integers from 0 through 65535, parsed after trimming; deduplicated numerically. | |
| 264 | +| `wiki` | Absent/empty, `propose` or `edit`. | |
| 265 | +| `jobs` | Absent/empty, `request`, `serve` or `both`. | |
| 266 | +| `sites` | Repeated `["sites","<label>",...]`; trimmed label is `*` or a site under the agent's key accepted by [`sites.ParseSite`](../../internal/sites). Snapshot labels are rejected. Subsequent trimmed values may only be `encrypted` or `ttl=<days>`, with days from 1 through 365. Repeated TTL flags use the last value; first entry for a label wins. | |
| 267 | +| `rate` | Absent/empty defaults to 60 events per minute; otherwise a trimmed decimal integer from 1 through 600. | |
| 268 | + |
| 269 | +Each deduplicated list of rooms, repositories, kinds or sites is limited to 256 entries. Scalar tags use the first tag with a value, as defined by [`event.Tag`](../../internal/event/event.go). Grant parsing does not reject unknown tags or duplicate scalar tags. Clients should emit one scalar tag per field and unambiguous list entries; the stricter [grant-request](agent-grant-requests.md) rules are not interchangeable with this parser. Original signed tags and content are preserved, even when the effective projection trims or deduplicates them. |
| 270 | + |
| 271 | +## Signature, authority and state |
| 272 | + |
| 273 | +[`agentGrantShape`](../../internal/gates/gates.go) requires the signer's current role to be `owner` or `moderator`. A valid signature or successful `ParseAgentGrant` call alone does not confer this authority. Host bans, allowed kinds, room membership and feature rules still apply. The same grant gate is used for client writes and imports. |
| 274 | + |
| 275 | +[`ApplyAgentEventTx`](../../internal/community/agents.go), bound by [`event_commit.go`](../../internal/daemon/event_commit.go), mirrors a stored grant and the membership change inside the event transaction. A fresh active grant resets pause/revocation and adds role `agent` only if the key has no existing human role. A human member, moderator or owner retains that role and is not restricted by the grant. A revoked grant's record remains available for audit; its `agent` membership row is removed. Grant admission still checks retained grant state for a key with no role, preventing revoked keys from escaping the grant gate merely by losing membership. |
| 276 | + |
| 277 | +A grant is active only when not paused, not revoked and `expires > now`. A new accepted grant replaces the effective grant for that agent. Nostr addressable-event replacement is scoped by kind, signer and `d`, but the effective grant table is keyed only by agent. Do not infer a cross-operator “newest timestamp wins” rule from NIP-01: different authorized operators can publish different addresses for the same agent, and the accepted grant application updates that one effective row. |
| 278 | + |
| 279 | +Revocation can use: |
| 280 | + |
| 281 | +- A kind 5 deletion from the current grant's signer naming its event ID in `e`, or its address in `a`. |
| 282 | +- A newly accepted kind 30392 grant with an expired positive `expiration`. |
| 283 | +- The privileged `revokeagent` management method. |
| 284 | + |
| 285 | +Pause/resume are local management state, not signed grant replacements or portable Nostr events. A fresh active grant restores access; resuming a revoked grant is rejected. Request-linked replacements must also pass the provenance and stale-base rules in [Reviewed grant requests](agent-grant-requests.md). |
| 286 | + |
| 287 | +## Scope enforcement |
| 288 | + |
| 289 | +[`AgentGrant.Check` and `AllowsKind`](../../internal/community/agents.go), [`agentAdmission`](../../internal/gates/gates.go) and [`jobReply`](../../internal/gates/jobs.go) establish these rules: |
| 290 | + |
| 291 | +- Profiles (`0`), relay lists (`10002`) and authentication (`22242`) pass the grant's kind test. An inactive grant still fails the state test. Other kinds require a `k` entry or the derived permission below. |
| 292 | +- `wiki=propose` admits `30818` and `818`; `wiki=edit` adds `30819`. Proposal visibility and decisions belong to the [wiki approval integration](../../internal/daemon/wiki_approvals.go), not event signature validation. |
| 293 | +- `jobs=request` admits requests `5000` through `5999`, excluding reserved kind `5128`; `serve` admits results `6000` through `6999` and feedback `7000`; `both` combines them. Agent answers require a locally held, readable request and valid request references. See [`jobs_test.go`](../../internal/gates/jobs_test.go). |
| 294 | +- `sites` admits `15128` and `35128`, not snapshot kind `5128`. A manifest must match a covering label even if its kind was explicitly granted. When every covering entry has a TTL, its `expiration` must fit the longest covering TTL measured from admission time; an entry with no TTL removes that bound. |
| 295 | +- A nonempty first `h` value must be in the grant's rooms. This does not bypass room existence, membership or administration rules. |
| 296 | +- Repository collaboration kinds `1617`, `1618`, `1619`, `1621`, `1111` and `1630` through `1633` are checked against valid `30617:<owner>:<identifier>` coordinates in `a` and `A` tags. Every recognized coordinate needs a grant entry. All except a comment (`1111`) require such a coordinate. Status kinds require `maintain`; all still require kind permission. |
| 297 | +- Repository announcements/state (`30617`, `30618`) have their own Git admission and maintainer rules. An active `maintain` grant contributes repository authority through [`repository_maintainers.go`](../../internal/daemon/repository_maintainers.go); it does not implicitly add kind 30618. Private Git membership checks remain separate. |
| 298 | +- The rate limiter is a sliding 60-second admission counter, shared by writes/imports through the gate and reset by process restart. It is not a persistent count of successfully stored events. |
| 299 | + |
| 300 | +Site upload TTL/encryption effects use [`agentUploadGrant` and `blobUploadTerms`](../../internal/daemon/services.go) and the blob service, not grant event signatures. `encrypted` on any site entry applies to all agent uploads; it is a MIME-based acceptance rule, not cryptographic proof of encryption. Grant scope is not a general read-access control list. |
| 301 | + |
| 302 | +## Management and discovery |
| 303 | + |
| 304 | +Owner and moderator methods are `listagents`, `pauseagent`, `resumeagent`, `revokeagent`, `pauseallagents` and `resumeallagents`. Single-agent methods take the public key as their first positional string. Corresponding MCP names are `list_agents`, `pause_agent`, `resume_agent`, `revoke_agent`, `pause_all_agents` and `resume_all_agents`. See [`agentExecute`](../../internal/daemon/management.go) and [`mcp_tools.go`](../../internal/daemon/mcp_tools.go). |
| 305 | + |
| 306 | +`listagents` returns entries with `pubkey`, `owner`, `event_id`, `name`, `expires`, `paused`, `revoked`, `scope` and `lastEvent`. Scope uses `kinds`, `rooms`, `repos`, `rate` and optional `wiki`, `jobs`, `sites`; repository objects use `owner`, `identifier`, `level`, and site objects use `label`, optional `ttl` and `encrypted`. These are effective projections, not replacement event templates. |
| 307 | + |
| 308 | +While an active grant exists, [`Capabilities`](../../internal/daemon/capabilities.go) adds `{"id":"agents","status":"enabled","reason":"agent identities under owner-signed grants"}` and [`information`](../../internal/daemon/http.go) includes the `agents` entry in relay information. This does not enumerate grants, encode version 1 or establish caller permission. Absence can mean no active grants, not that the implementation lacks this extension. |
| 309 | + |
| 310 | +## Errors, compatibility and evidence |
| 311 | + |
| 312 | +Malformed grants return `invalid: agent grant ...`; unauthorized signers return `restricted: only the owner or a moderator can grant an agent`. Inactive or out-of-scope agent writes return `restricted: agent grant ...`; site TTL violations return `invalid: agent grant allows site ...`. Preserve the message and apply the [transport-specific error rules](README.md#shared-transport-boundary). |
| 313 | + |
| 314 | +Evidence: |
| 315 | + |
| 316 | +- [`gates/agents_test.go`](../../internal/gates/agents_test.go): `TestAgentGrantShapeAndSigner`, `TestAgentGrantScopesKindsRoomsAndRepositories`, `TestAgentGrantStateRejectsWrites`, `TestAgentGrantRateLimitSlidesOverOneMinute`, `TestHumanMemberWithGrantKeepsMemberPermissions`, `TestImportEnforcesAgentGrant`, and site shape/expiry cases. |
| 317 | +- [`daemon/agents_test.go`](../../internal/daemon/agents_test.go): `TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion` and operator management/read cases. |
| 318 | +- [`git_agent_maintainer_test.go`](../../internal/daemon/git_agent_maintainer_test.go) and [`blob_authorization_test.go`](../../internal/daemon/blob_authorization_test.go): host-specific Git and upload authority. |
| 319 | + |
| 320 | +Audit limits: The name's byte truncation can split UTF-8 and differs from the guide's character wording. Direct grant room syntax is broader than request room syntax. Cross-operator replacement ordering is not a portable conflict-resolution protocol. These are documented behavior/ambiguities, not runtime changes or new conformance claims. |
| 321 | diff --git a/docs/extensions/callbacks.md b/docs/extensions/callbacks.md |
| 322 | new file mode 100644 |
| 323 | index 0000000..394a6f7 |
| 324 | --- /dev/null |
| 325 | +++ b/docs/extensions/callbacks.md |
| 326 | @@ -0,0 +1,93 @@ |
| 327 | +# Event callbacks |
| 328 | + |
| 329 | +| Field | Value | |
| 330 | +| --- | --- | |
| 331 | +| Identifier | `callbacks` | |
| 332 | +| Status | Experimental | |
| 333 | +| Contract version | 1; no negotiated wire version | |
| 334 | +| Owner | `internal/daemon` callback service, registration, matching and delivery; host visibility is supplied by `internal/gates`. | |
| 335 | +| Wire | Project management methods plus HTTPS POST of one signed Nostr event. No registration event kind. | |
| 336 | + |
| 337 | +The [catalog's audit baseline and transport rules](README.md) apply. [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) supplies events and filter matching; NIP-86/NIP-98 supply the management/authentication foundations. Registration, HMAC headers, retries and quotas are project extensions, not NIP-01 subscriptions or an upstream webhook standard. No upstream revision or full conformance audit is asserted. See the [callback guide](../agents.md#callbacks) for receiver examples. |
| 338 | + |
| 339 | +This contract is **not** the signed push-registration/replication callback implemented by [`internal/replication/push.go`](../../internal/replication/push.go). That service has a different registration, payload and authority model. Sharing the pinned HTTP client does not make the protocols interchangeable. |
| 340 | + |
| 341 | +## Registration and management |
| 342 | + |
| 343 | +Use the tenant-root management endpoint or the corresponding MCP tool at `/mcp`: |
| 344 | + |
| 345 | +| Management method | MCP tool | Parameters/effect | |
| 346 | +| --- | --- | --- | |
| 347 | +| `listcallbacks` | `list_callbacks` | No parameters; list accessible registrations. | |
| 348 | +| `addcallback` | `add_callback` | One object with `url`, `filter`, optional `secret`; creates a registration owned by the authenticated caller. | |
| 349 | +| `removecallback` | `remove_callback` | First parameter is an ID string or `{"id":"..."}`; deletes the registration. MCP takes `id`. | |
| 350 | +| `pausecallback` | `pause_callback` | Same ID forms; stops further delivery attempts. | |
| 351 | +| `resumecallback` | `resume_callback` | Same ID forms; clears pause, failure count and last status. | |
| 352 | + |
| 353 | +Members and agents manage their own registrations. Owner/moderator roles may list and control all registrations; registration always belongs to the caller, not an arbitrary key in the options. Guests are rejected. The per-key policy allowance is `callbacks` (default 4); all stored registrations count, including paused ones. Zero prevents nonowner registration. Only the owner is exempt from the cap. Management mutations are audited. |
| 354 | + |
| 355 | +[`addCallback`](../../internal/daemon/callbacks.go) trims `url` and `secret`. The URL is at most 2,048 bytes after trimming, uses `https`, has a hostname, and has no credentials or fragment. Path, query and explicit port are allowed. Unknown top-level registration object fields are ignored by this decoder; filter fields are stricter. |
| 356 | + |
| 357 | +A supplied nonempty secret must be 16 to 128 bytes of ASCII `!` through `~`: embedded spaces are rejected despite the error's “printable ASCII” wording. An omitted/blank secret generates 32 random bytes encoded as 64 lowercase hex characters. Registration IDs are 16 random bytes encoded as 32 lowercase hex characters. Treat an ID as an opaque registration identifier, not authentication. |
| 358 | + |
| 359 | +Registration returns a summary plus `secret` once. Summary fields are `id`, `owner`, `url`, `host`, `filter`, `created`, `lastDelivery`, `lastStatus`, `failures` and `paused`. Times are Unix seconds. `host` includes an explicit port. List responses omit `url` for registrations owned by someone other than the caller, and never include secrets. Pause/resume return a summary; removal returns `{"id":"...","removed":true}`. **Audit gap:** pause/resume currently return `record.summary()` without the listing's cross-owner URL redaction. Do not interpret the redacted list as a guarantee that an operator cannot retrieve a path/query through a mutation response. |
| 360 | + |
| 361 | +## Filter contract |
| 362 | + |
| 363 | +The filter is a JSON object, or a JSON-encoded object string accepted for form compatibility. Matching uses [`event.Matches`](../../internal/event) through the callback's NIP-01 filter projection: OR within one field's values, AND across fields. Only these keys are accepted: |
| 364 | + |
| 365 | +| Key | Validation | |
| 366 | +| --- | --- | |
| 367 | +| `kinds` | Required array of 1 to 32 integers, each 0 through 65535. Sorted on storage; duplicates are not removed. | |
| 368 | +| `authors` | Optional array of 1 to 8 lowercase 64-character hex public keys. Prefixes are not accepted. | |
| 369 | +| `#e`, `#p` | Optional arrays of 1 to 8 lowercase 64-character hex IDs/keys. | |
| 370 | +| `#a` | Optional array of 1 to 8 `<kind>:<pubkey>:<identifier>` strings. Kind is a nonempty digit string; pubkey is lowercase 64-character hex. This validator does not bound the kind numerically or require a nonempty identifier. | |
| 371 | +| `#h` | Optional array of 1 to 8 room IDs matching `^[a-z0-9_-]{1,64}$`. | |
| 372 | +| `since` | Accepted and ignored, without value validation. There is no callback history/backfill cursor. | |
| 373 | + |
| 374 | +Other keys, empty arrays and malformed values are rejected. At registration, an agent must have an active grant; every named `#h` must be a granted room, and recognized repository coordinates in `#a` must have a grant entry. Kinds are deliberately not constrained to the agent's publishing kinds. Omitting a room/repository filter does not grant read access; each event is separately checked by the host gate. |
| 375 | + |
| 376 | +## Delivery and authentication |
| 377 | + |
| 378 | +One delivery is `POST <registered url>` with the signed event object itself as JSON, not a batch, `EVENT` frame or retry envelope. The sender emits: |
| 379 | + |
| 380 | +| Header | Value | |
| 381 | +| --- | --- | |
| 382 | +| `Content-Type` | `application/json` | |
| 383 | +| `X-Tiny-Callback` | Registration ID. | |
| 384 | +| `X-Tiny-Signature` | `sha256=` plus lowercase hex HMAC-SHA256 of the exact request-body bytes, keyed by the secret's bytes. | |
| 385 | +| `X-Tiny-Relay` | Configured public relay URL. | |
| 386 | +| `User-Agent` | `tinyrelay` | |
| 387 | + |
| 388 | +A receiver should verify the MAC over the raw body with a constant-time comparison before acting, then validate the Nostr event if it needs proof of event authorship. The MAC proves possession of the registration secret, not the event author's identity. Header values other than the body MAC are not separately authenticated by that MAC. Neither `X-Tiny-Relay` nor the event's signature authorizes a local action at the receiver. |
| 389 | + |
| 390 | +The receiver acknowledges with any 2xx status within 10 seconds. Response content is ignored (the sender drains at most 4,096 bytes). Non-2xx responses, timeout and connection failure count as failed attempts. There is no challenge handshake, delivery timestamp, nonce or delivery-attempt header. Receivers must make side effects idempotent, normally using registration ID plus event ID; retries and uncertain acknowledgments can cause duplicates. No exactly-once or strict ordering guarantee is made. |
| 391 | + |
| 392 | +## Lifecycle and trust boundaries |
| 393 | + |
| 394 | +[`callbacks.go`](../../internal/daemon/callbacks.go), [`callbacks_delivery.go`](../../internal/daemon/callbacks_delivery.go) and [`event_followups.go`](../../internal/daemon/event_followups.go) define the lifecycle: |
| 395 | + |
| 396 | +- Stored event acceptance captures eligible registrations for later work. Optional local planning failure does not reject a valid source event. Recovery is bounded by a 15-minute age limit and three background worker planning attempts, separately from delivery retries. Successfully planned child work retains its identity. Failed discovery recovery excludes registrations created at or after the acceptance second, so uncertain same-second registrations can be skipped. |
| 397 | +- Repository-state callbacks are planned after object promotion, rather than waking a receiver while objects are missing. Ephemeral events use a separate immediate planning path whose intent carries the event; they do not gain durable source history or the stored-event planning recovery guarantee. |
| 398 | +- Before each POST the service rereads the registration, skips missing/paused registrations, checks current membership and applies `Gate.CanSee` to the queued event. A key with no remaining membership has its callback paused. Gate checks include bans, held events, host read policy, rooms and recipients. |
| 399 | +- Each callback has at most one in-process POST at a time. A failed event gets two further scheduled attempts, after one minute and then five minutes. The third is final. Each failed HTTP attempt increments consecutive failures; success resets them. At 20 failures the callback pauses. Resumption does not promise replay of skipped or exhausted events. |
| 400 | + |
| 401 | +Outbound URLs use the shared [`NewPinnedClient`](../../internal/replication/callback.go), which dials resolved public addresses directly and refuses redirects. The lexical registration check rejects private/loopback IPs and local-style hostnames. A relay configured with a private/loopback public URL deliberately relaxes private-target restrictions for local use; HTTPS and redirect refusal remain. This is a deployment trust switch, not an exemption clients can request. DNS filtering is the implementation's explicit address-deny list, not a claim of a complete SSRF audit. |
| 402 | + |
| 403 | +Callbacks intentionally disclose the entire readable signed event to an external service. Secrets and URL tokens require protection in storage and at the receiver. Revocation cannot recall an already sent request. |
| 404 | + |
| 405 | +**Read-state limits:** Delivery does not rerun `checkCallbackScope` or `AgentGrant.Active`; `Gate.CanSee` is a read gate, not a write-grant check. Paused/expired agents can retain membership/read access, so pausing a grant alone is not a guaranteed webhook kill switch. Pause the callback to stop it. Delivery checks the queued event payload, not a fresh source lookup; deletion/expiration alone is not a guaranteed cancellation of already planned deliveries. Do not broaden the guide's read-recheck wording into guarantees the implementation does not enforce. |
| 406 | + |
| 407 | +## Errors, discovery and evidence |
| 408 | + |
| 409 | +Registration/shape/quota errors use `invalid:`; role/scope errors use `restricted:`; missing IDs return `not found: callback`. Delivery records bounded status reasons such as `ok`, `HTTP 500`, `timeout`, `connection failed`, or `paused after 20 failures: ...`. Apply the [transport error rules](README.md#shared-transport-boundary). |
| 410 | + |
| 411 | +Discover method names with `supportedmethods`, and MCP names/schemas with `tools/list`; `/llms.txt` also describes the tools. These surfaces do not negotiate callback version 1 or promise permission to register. |
| 412 | + |
| 413 | +Evidence: |
| 414 | + |
| 415 | +- [`callbacks_test.go`](../../internal/daemon/callbacks_test.go): `TestCallbackRegistrationValidatesURLFilterScopeAndCap`, `TestCallbackManagementPermissionMatrix`, `TestCallbackMatchesFilterAndKeepsPrivateEventsBehindTheGate`, `TestCallbackDeliverySignsBodyRetriesAndPauses`, `TestCallbackDeliveryRechecksTheGateAndMembership`, `TestMCPCallbackTools`. |
| 416 | +- [`callback_followups_test.go`](../../internal/daemon/callback_followups_test.go): planning failure, preserved completed work, paused/removed/later registrations, exhaustion and independent view failure. |
| 417 | +- [`replication/callback.go`](../../internal/replication/callback.go) supplies the shared pinned HTTP client; this audit found no direct pinned-HTTP-client test in the replication test files. The callback restart test in [`callback_restart_acceptance_test.go`](../../internal/daemon/callback_restart_acceptance_test.go) exercises **replication** callback work, not proof of this HMAC webhook's end-to-end restart delivery. |
| 418 | + |
| 419 | +The URL-redaction, grant-state and queued-source limits above are unresolved implementation boundaries. This specification neither fixes them nor claims tests for stronger guarantees. |
| 420 | diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md |
| 421 | new file mode 100644 |
| 422 | index 0000000..e546019 |
| 423 | --- /dev/null |
| 424 | +++ b/docs/extensions/custom-view-transforms.md |
| 425 | @@ -0,0 +1,122 @@ |
| 426 | +# Custom-view block transforms |
| 427 | + |
| 428 | +| Field | Value | |
| 429 | +| --- | --- | |
| 430 | +| Identifier | `custom-view-transforms` | |
| 431 | +| Status | Experimental | |
| 432 | +| Contract version | 1; no negotiated wire version | |
| 433 | +| Owner | `internal/daemon` definitions, scheduling, transform HTTP, artifacts and access; `internal/views` fenced blocks/hash/path contract; `internal/records` relay signing identity. | |
| 434 | +| Wire | Project management methods, HTTPS JSON transform exchange, relay-signed kind `30078` artifacts and `/views/` media routes. | |
| 435 | + |
| 436 | +The [catalog's audit baseline and transport rules](README.md) apply. [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) supplies kind 30078 application data, with NIP-01 addressable events, NIP-40 expiration and NIP-86/NIP-98 management foundations. The `bind.ws/view/` namespace, source/block tags, transform exchange and audience rules are project-defined. Neither the namespace nor the `X-Transform-*` name claims upstream standardization. No upstream revision or complete conformance audit is asserted. |
| 437 | + |
| 438 | +This contract renders fenced code blocks. It is not the [reusable view proposal](../view-definitions.md), a generic whole-event transform or the built-in `listviews` summaries. The [Custom views guide](../views.md) covers operation and presentation. |
| 439 | + |
| 440 | +## Definition and authority |
| 441 | + |
| 442 | +All methods, including listing, require the current `owner` role; moderators, members and agents cannot configure a transform. A permitted author may still trigger an owner-configured write view by publishing matching source content. Defining a view authorizes disclosure of those blocks to that external transform. |
| 443 | + |
| 444 | +| Management method | MCP tool | Effect | |
| 445 | +| --- | --- | --- | |
| 446 | +| `listcustomviews` | `list_custom_views` | Lists definitions without secrets. | |
| 447 | +| `addcustomview` | `add_custom_view` | Takes one definition object; creates a new named view and returns its secret once. Existing names are rejected, not updated. | |
| 448 | +| `removecustomview` | `remove_custom_view` | Deletes the view and its artifacts. | |
| 449 | +| `pausecustomview` | `pause_custom_view` | Disables transform requests; retains artifacts. | |
| 450 | +| `resumecustomview` | `resume_custom_view` | Enables the view and clears failure count/status. | |
| 451 | +| `runcustomview` | `run_custom_view` | Queues a bounded backfill; `refresh=true` rebuilds existing artifacts. A paused view must be resumed first. | |
| 452 | + |
| 453 | +Use the [shared management transport](README.md#shared-transport-boundary). Named methods accept a first positional name string or `{"name":"..."}`; run also accepts `{"name":"...","refresh":true}`. MCP uses named `name` and optional `refresh` fields. Source: [`custom_views.go`](../../internal/daemon/custom_views.go) and [`mcp_tools.go`](../../internal/daemon/mcp_tools.go). |
| 454 | + |
| 455 | +| Definition field | Version 1 validation | |
| 456 | +| --- | --- | |
| 457 | +| `name` | Trimmed, matches `^[a-z0-9-]{1,32}$`. | |
| 458 | +| `kinds` | 1 to 16 integers, each 0 through 65535; sorted/deduplicated after the count check. | |
| 459 | +| `transform` | Trimmed HTTPS URL using the [callback URL restrictions](callbacks.md#registration-and-management), including 2,048-byte limit and no credentials/fragment. | |
| 460 | +| `languages` | 1 to 32 entries, then trimmed/lowercased/deduplicated; each is 1 to 32 bytes using `a-z`, `0-9`, `+`, `-`, `_`, `.` or `#`. | |
| 461 | +| `trigger` | Trimmed `write` or `hourly`; absent/empty defaults to `write`. | |
| 462 | +| `audience` | Trimmed `public` or `members`; absent/empty defaults to `public`. | |
| 463 | +| `max_bytes` | Artifact-byte cap, 1 through 4 MiB; omitted, null or empty string defaults to 1 MiB. Integer or numeric string is accepted by management. | |
| 464 | +| `secret` | Same trimmed 16-to-128-byte nonspace ASCII rule as callbacks; generated as 64 lowercase hex characters if omitted/blank. | |
| 465 | + |
| 466 | +Management additionally accepts kinds as numeric strings or a delimited string and languages as a delimited string (comma, space or newline separators). The MCP schema is narrower; clients should use arrays and integers. Unknown definition object fields are ignored by the management decoder. |
| 467 | + |
| 468 | +Summaries contain `name`, `kinds`, `transform`, `host`, `trigger`, `audience`, `languages`, `maxBytes`, `enabled`, `state` (`active`/`paused`), `created`, `lastRun`, `lastStatus` and `failures`. `max_bytes` is input spelling; `maxBytes` is output spelling. Run adds `queued`; removal returns `name` and `removed:true`. Neither summaries nor an enabled view imply a completed render. |
| 469 | + |
| 470 | +## Block selection and identity |
| 471 | + |
| 472 | +[`internal/views/views.go`](../../internal/views/views.go) defines the shared wire/cache identity: |
| 473 | + |
| 474 | +- A fence opens with at least three backticks or tildes after trimming the line. The first word of the remaining info string, lowercased, is the language. A closing fence contains only the same marker character with at least the opening run's length. An unterminated fence consumes the remaining text. |
| 475 | +- Input CRLF becomes LF. Block source is the lines between fences joined with LF, without an added trailing newline. Index is zero-based across **all** fenced blocks, including unnamed and nonmatching languages. |
| 476 | +- Only nonempty languages in the view's configured list are sent. Ordinary source is event content. Kind `30618` instead reads the repository README at the commit referenced by its `HEAD` tag, trying `README.md`, `readme.md`, then `README`; missing/binary/empty files are skipped. |
| 477 | +- Hash is lowercase hexadecimal SHA-256 of UTF-8 bytes `lang + "\n" + source`. It is not an artifact-body hash. Cache identity is view name plus hash; equal blocks across events share one artifact. Normal runs reuse existing artifacts; refresh bypasses reuse. |
| 478 | + |
| 479 | +## Transform request and answer |
| 480 | + |
| 481 | +One POST per source carries only pending matching blocks: |
| 482 | + |
| 483 | +```json |
| 484 | +{"relay":"https://relay.example","view":"diagrams","source":{"id":"<source key>","kind":30818},"blocks":[{"index":2,"lang":"mermaid","source":"graph TD;\n A-->B;"}]} |
| 485 | +``` |
| 486 | + |
| 487 | +`source.id` is the event ID except for kind 30618, where it is the address `30618:<state author pubkey>:<d>`. That special form exposes the repository state author/identifier and is not a 64-character event ID. No full event, separate tags, author or timestamp fields are transmitted; block content and source identity still can disclose private information. |
| 488 | + |
| 489 | +Headers are `Content-Type: application/json`, `Accept: application/json`, `User-Agent: tinyrelay`, `X-Transform-View`, `X-Transform-Relay` and `X-Transform-Signature`. Their values are the view name, configured public relay URL, and `sha256=` plus lowercase hex HMAC-SHA256 over the exact body bytes with the view secret. Legacy `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Signature` carry identical values. New integrations should use `X-Transform-*`; existing aliases remain part of this version. |
| 490 | + |
| 491 | +Receivers should verify the body MAC in constant time before rendering. This proves shared-secret possession, not source author approval or semantic correctness. No timestamp/nonce or response signature is negotiated. TLS authenticates the response endpoint. The relay waits up to 30 seconds, refuses redirects and uses the shared pinned-client address restrictions, with the configured local/private-host exception described in [Callbacks](callbacks.md#lifecycle-and-trust-boundaries). |
| 492 | + |
| 493 | +A successful endpoint returns a 2xx status and JSON such as: |
| 494 | + |
| 495 | +```json |
| 496 | +{"artifacts":[{"block":2,"type":"image/svg+xml","body":"<svg ...></svg>","engine":"mermaid"}],"errors":[{"block":5,"error":"unknown shape"}]} |
| 497 | +``` |
| 498 | + |
| 499 | +`artifacts` and `errors` are arrays of the shown objects. No extra response fields are required; unknown object fields are ignored. The body read is limited to `4 * (4 MiB) + 1 MiB`; truncated/malformed JSON is a run failure. `block` must identify a block actually sent. Unsent-block responses and invalid artifacts are refused individually, not treated as transport failures. Missing artifacts leave code (or a prior artifact on refresh) available. `errors` is counted for diagnostics, not a signed decision or automatic retry directive. |
| 500 | + |
| 501 | +### Artifact validation |
| 502 | + |
| 503 | +[`checkArtifact`, `checkSVG` and `storeArtifact`](../../internal/daemon/custom_views_transform.go) enforce: |
| 504 | + |
| 505 | +- Exact type `image/svg+xml` or `image/png`. |
| 506 | +- SVG body length at most `max_bytes`. It must contain `<svg` case-insensitively. Regex checks reject script elements, `on...=` handlers, foreign objects and `href`/`xlink:href` targets other than empty strings or internal `#` fragments. The original body is rejected or retained, not sanitized. |
| 507 | +- PNG body is trimmed Base64 (standard alphabet, padded or unpadded). Decoded length must fit `max_bytes` and begin with the PNG signature. Storage normalizes it to padded Base64. This is a signature-prefix check, not complete PNG decoding or validation. |
| 508 | +- `engine` is optional, trimmed and truncated to 64 bytes before recording. |
| 509 | + |
| 510 | +The SVG checks are not an XML/CSS parser or proof that every external-load syntax is absent. Consumers must retain the sandbox boundary below. No guarantee of a valid render, safe arbitrary SVG execution or faithful interpretation of source code follows from acceptance. A valid JSON/2xx run resets failures and records `ok` even if every returned artifact is refused or `errors` is nonempty; check actual artifact availability rather than status alone. |
| 511 | + |
| 512 | +## Signed records and media routes |
| 513 | + |
| 514 | +[`signArtifact`](../../internal/daemon/custom_views_transform.go) asks [`internal/records`](../../internal/records/identity.go) to sign kind 30078 with the durable relay identity, not the tenant operator or source author. The record content is SVG text or padded Base64 PNG. Tags are: |
| 515 | + |
| 516 | +- `["-"]`, `["d","bind.ws/view/<name>/<hash>"]`, `["view","<name>"]`. |
| 517 | +- One `e` source tag for an ordinary event ID, or `a` source tag for a repository-state address, for each recorded source. |
| 518 | +- One `["block","<decimal index>"]` for each source, in the same source order. These block tags follow the source tags; they are not adjacent source/block pairs. |
| 519 | +- `["type","image/svg+xml"]` or `["type","image/png"]`, optional `engine`, and optional `expiration`. |
| 520 | + |
| 521 | +If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Expiry is therefore not an unconditional immediate HTTP-route check. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive. |
| 522 | + |
| 523 | +Public artifacts use `records.Generate` and enter normal event storage. Members-only artifacts use `records.Sign` and remain signed in the artifact store without being published to normal event queries. The signature attests to the relay's generated output, not endorsement by any source author. |
| 524 | + |
| 525 | +`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts use the relay's general browse-read rule, not a per-source visibility check. |
| 526 | + |
| 527 | +Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` for members and `public, no-cache` for public artifacts. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go). |
| 528 | + |
| 529 | +**Disclosure boundary:** Audience is attached to the view/artifact, not inherited from a source's room, proposal, wiki approval or repository ACL. A public view can expose an artifact of a nonpublic or pending source; a members view can expose it to members who cannot read that source. Transformation itself uses current source presence, not author-specific read authorization. Only configure a transform/audience appropriate for everything its kinds/languages can select. Hash-based paths and relay signatures are not access control or encryption. |
| 530 | + |
| 531 | +## Scheduling, errors and discovery |
| 532 | + |
| 533 | +Write views capture registrations and queue optional work after source acceptance; repository-state work waits for available objects. [`event_followups.go`](../../internal/daemon/event_followups.go) bounds failed planning recovery separately from HTTP retries and excludes later/uncertain same-second registrations during failed-discovery recovery. Registration generation/fingerprint checks prevent old work from targeting a newly created view with the same name. |
| 534 | + |
| 535 | +Hourly/manual runs consider at most the newest 500 events of the selected kinds; an hourly pass applies a last-run time window with overlap. This is not an unbounded backfill or guarantee to process every event. Transforms serialize per view. Before a POST, the worker checks enabled state, registration generation and whether the source is still current. Those checks do not recall an in-flight request. |
| 536 | + |
| 537 | +Non-2xx, timeout, connection/read failure or invalid JSON produces a failed run. Two retries follow, after one minute and five minutes, for three attempts total. Twenty consecutive failures pause the view; success/resume clears the counter. Reasons include `HTTP <status>`, `timeout`, `connection failed`, `read failed` and `invalid response`. Management validation uses `invalid:`, authority uses `restricted: only the owner may ...`, and missing definitions use `not found: view`. See the [transport rules](README.md#shared-transport-boundary). |
| 538 | + |
| 539 | +Discover management names with `supportedmethods`, MCP schemas with `tools/list`, and definitions with the owner-only list method/tool. The frontend receives only enabled view names/languages through `CustomViews`; that is presentation data, not endpoint/secret disclosure or authority to configure the view. There is no transform version capability or handshake. |
| 540 | + |
| 541 | +## Evidence and audit limits |
| 542 | + |
| 543 | +- [`views_test.go`](../../internal/views/views_test.go) exercises block parsing, identity, path and presentation behavior. |
| 544 | +- [`custom_views_test.go`](../../internal/daemon/custom_views_test.go): `TestCustomViewManagementIsOwnerOnlyAndValidated`, `TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts`, replacement/reuse/rebuild, retries, artifact validation, members-only storage, hourly runs, repository README and MCP tools. |
| 545 | +- [`custom_views_targets_test.go`](../../internal/daemon/custom_views_targets_test.go) tests recreated registration rejection and stable generation backfill; [`custom_views_media_test.go`](../../internal/daemon/custom_views_media_test.go) tests extensionless media routes. |
| 546 | + |
| 547 | +Repeated identical blocks in one source share a `(view,hash,source)` record with one block index, not one entry per occurrence. There is no explicit duplicate-artifact response rejection, complete PNG/SVG parser, per-source audience inheritance or full remote-renderer conformance suite. These limits, the kind 30618 source-address disclosure and the `ok`/artifact-success distinction are intentional audit disclosures, not newly introduced wire behavior. |
| 548 | diff --git a/docs/module-contracts.md b/docs/module-contracts.md |
| 549 | index 3c8f790..fc95c02 100644 |
| 550 | --- a/docs/module-contracts.md |
| 551 | +++ b/docs/module-contracts.md |
| 552 | @@ -2,7 +2,7 @@ |
| 553 | |
| 554 | The project is a monorepo. `tinyrelay` is the relay host, `tinygit` is the Git engine and standalone Git service, and `tinyclient` is the browser interface and standalone frontend. Separate executables do not imply separate Go modules, databases or network services for every package. |
| 555 | |
| 556 | -The Git and frontend extraction establishes the first boundaries. The additional boundaries below are proposed follow-up work, not claims that those packages have already been extracted. |
| 557 | +The Git and frontend components, public `protocol/nostr` and `protocol/auth` packages, and `tinygit.ParseMetadata` establish the current boundaries. Blob, room and narrower community/records services below remain proposed follow-up work. |
| 558 | |
| 559 | ## Keep protocol rules separate from access policy |
| 560 | |
| 561 | @@ -22,8 +22,8 @@ The client can validate a draft for feedback and verify a signer's result, but s |
| 562 | |
| 563 | | Boundary | Owns | Must not own | |
| 564 | | --- | --- | --- | |
| 565 | -| Shared Nostr primitives, a proposed public `protocol/nostr` package | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. | |
| 566 | -| Request-proof adapters | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. | |
| 567 | +| Public `protocol/nostr` | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. | |
| 568 | +| Public `protocol/auth` | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. | |
| 569 | | Host access policy | Membership, agent grants, visibility, quotas and admission decisions. | Reinterpreting signed fields or weakening feature protocol validation. | |
| 570 | | `tinygit` | Git repository metadata, signed refs, object storage, smart HTTP, repair and Git-specific protocol rules. | Rooms, browser sessions or general relay management. | |
| 571 | | `tinyclient` | Server-rendered pages, browser interactions, draft construction and signer-result verification. | Authoritative permissions, repository mutation or event persistence. | |
| 572 | @@ -51,7 +51,9 @@ Each public feature boundary should document: |
| 573 | |
| 574 | Use small, consumer-owned interfaces for access decisions, persistence and optional transports. Prefer feature-specific read models to a universal backend interface. Moving packages must not break the atomicity of event persistence, projections and durable work. |
| 575 | |
| 576 | -During the extraction, `tinygit` still shares internal event, policy and SQLite implementations with the host. Public aliases can preserve source compatibility, but they are transitional plumbing, not a substitute for a small public protocol and persistence contract. Independent builds are a first milestone; a dependency boundary check is the next one. |
| 577 | +`tinygit.Event` and the frontend room event type now originate in public `protocol/nostr`. Public `protocol/auth` depends on those primitives, not host services. Internal aliases preserve one implementation, type identity, verifier replay state and error sentinels. Feature kind constants, room-reply helpers and private-kind search exclusions remain internal rather than becoming generic protocol rules. |
| 578 | + |
| 579 | +`tinygit` still shares internal policy and SQLite storage with the host. A narrow persistence contract is a later seam, not completed by moving event types. External-consumer and transitive dependency tests guard the public protocol packages. `tinygit.ParseMetadata` validates metadata shape without asserting signature validity or authority; its admission entry points combine these checks before consulting host policy. |
| 580 | |
| 581 | ## Describe standards and project extensions separately |
| 582 | |
| 583 | @@ -64,7 +66,7 @@ Protocol documentation should classify each behavior as one of: |
| 584 | |
| 585 | For each project extension, create a specification under `docs/extensions/` when its behavior has been audited. Each specification should include a stable identifier, status, version, owning module, upstream references, exact event kinds and tag shapes or endpoint schemas, validation rules, authorization requirements, errors, discovery, security considerations and compatibility rules. Include valid and invalid fixtures and link the implementation and tests. |
| 586 | |
| 587 | -The extension catalog should index those specifications rather than repeat their rules. Start by auditing agent grants and grant requests, callbacks, custom-view transforms and custom HTTP/MCP bindings. Existing behavior is not automatically standardized, and an implementation detail should not be promoted into a wire extension merely because it is unusual. |
| 588 | +The [extension catalog](extensions/README.md) indexes specifications for agent grants and grant requests, callbacks, custom-view transforms and the tinyclient HTTP read adapter. These describe implemented behavior and its audited limits; they are not full upstream conformance certificates. Continue auditing remaining custom bindings without repeating each specification in the catalog. Existing behavior is not automatically standardized, and an implementation detail should not be promoted into a wire extension merely because it is unusual. |
| 589 | |
| 590 | Keep transport-specific method names in their adapters. A feature operation may have HTTP, MCP and browser bindings, but its domain contract and validation must have one owner. Generate discovery and schema checks from the same definitions where practical; do not create a parallel hand-maintained registry with different defaults. |
| 591 | |
| 592 | diff --git a/docs/testing.md b/docs/testing.md |
| 593 | index f185889..0fa1054 100644 |
| 594 | --- a/docs/testing.md |
| 595 | +++ b/docs/testing.md |
| 596 | @@ -16,7 +16,7 @@ The repository also contains a multistage Docker build. `make docker-test` runs |
| 597 | Build all executables with `make build`. Run the focused component suites with: |
| 598 | |
| 599 | ```sh |
| 600 | -go test -race ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient |
| 601 | +go test -race ./protocol/... ./internal/auth ./internal/event ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient |
| 602 | go test ./internal/daemon -run TestStandaloneTinyclient |
| 603 | npm run test:tinyclient |
| 604 | ``` |
| 605 | diff --git a/docs/views.md b/docs/views.md |
| 606 | index 9b7c0bc..36b7d67 100644 |
| 607 | --- a/docs/views.md |
| 608 | +++ b/docs/views.md |
| 609 | @@ -6,6 +6,8 @@ Block transforms can support diagrams, charts or other code-based images. They d |
| 610 | |
| 611 | A relay with no block transforms keeps showing code. The source remains available beneath each image. If an image fails to load, the page opens its source when JavaScript is available; without JavaScript, open **Source** to read it. |
| 612 | |
| 613 | +The [custom-view transform contract](extensions/custom-view-transforms.md) defines this project extension, its wire exchange, artifact records and audited limits. It is not a general event-transform standard. |
| 614 | + |
| 615 | ## Define a view |
| 616 | |
| 617 | Call the `addcustomview` management method, or the `add_custom_view` MCP tool, as the owner: |
| 618 | @@ -35,7 +37,7 @@ A view posts one request for each source event, carrying only the blocks: |
| 619 | {"relay": "https://relay.example", "view": "diagrams", "source": {"id": "<source>", "kind": 30818}, "blocks": [{"index": 2, "lang": "mermaid", "source": "graph TD;\n a-->b;"}]} |
| 620 | ``` |
| 621 | |
| 622 | -`index` counts every fenced block in the text from zero, whether or not it carries a language, so an answer can name the block it rendered. No event content outside the blocks, and no tags, author or timestamp, leaves the relay. Blocks in other languages are dropped before the request is built, and a block whose artifact the relay already holds is reused rather than sent again. |
| 623 | +`index` counts every fenced block in the text from zero, whether or not it carries a language, so an answer can name the block it rendered. The request does not include a full event, separate tags or timestamp. For repository state, `source.id` is an address containing the state author and repository identifier; for other kinds it is the event ID. Blocks and source identities can disclose private information. Blocks in other languages are dropped before the request is built, and a block whose artifact the relay already holds is reused rather than sent again. |
| 624 | |
| 625 | | Header | Value | |
| 626 | | --- | --- | |
| 627 | @@ -53,7 +55,7 @@ The relay also sends the equivalent `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Si |
| 628 | {"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]} |
| 629 | ``` |
| 630 | |
| 631 | -Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers, foreign objects and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code. |
| 632 | +Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are screened with regular expressions for script elements, event handlers, foreign objects and certain external-reference attributes. Matching artifacts are rejected, not sanitized. These checks are not a complete XML/CSS security validator; PNG validation checks its signature prefix rather than decoding the complete image. A block listed under `errors`, or left out of the answer entirely, keeps showing its code. |
| 633 | |
| 634 | SVG artifacts must include their own colors and background rules. Page styles do not reach into the SVG document. Diagramzip's `/transform/blocks` endpoint defaults to `auto-transparent`, which omits the canvas and includes light and dark palettes. The diagram follows the page's color scheme. After changing a transform's styling, rebuild the stored artifacts to apply the change. |
| 635 | |
| 636 | @@ -63,13 +65,13 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/< |
| 637 | |
| 638 | Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced. |
| 639 | |
| 640 | -Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them. |
| 641 | +Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Neither audience inherits the source event's access controls. A public artifact can expose material from a private or pending source, and a members-only artifact can expose it to members who cannot read that source. Configure transforms and audiences only for content you intend to disclose. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them. |
| 642 | |
| 643 | ## Failures |
| 644 | |
| 645 | A valid event is accepted even when its optional transform work cannot be planned immediately. The relay makes up to three background planning attempts within 15 minutes, rechecks the source and view, and skips work that is no longer relevant. Recovery fills missing work without restarting completed transforms or backfilling views registered after the event. |
| 646 | |
| 647 | -A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A successful run resets the failure count. |
| 648 | +A response outside 2xx, a timeout or a connection failure counts as a failure. The relay tries the event again after one minute and once more after five, three attempts in all, then drops it. After 20 failures in a row the view is paused and its status records the reason. A valid JSON response with a 2xx status resets the failure count and can record `ok` even if every artifact is rejected. Check the actual artifact before treating a run as a successful render. |
| 649 | |
| 650 | ## Manage views |
| 651 | |
| 652 | diff --git a/internal/auth/auth.go b/internal/auth/auth.go |
| 653 | index 32bd14c..f417347 100644 |
| 654 | --- a/internal/auth/auth.go |
| 655 | +++ b/internal/auth/auth.go |
| 656 | @@ -1,369 +1,22 @@ |
| 657 | package auth |
| 658 | |
| 659 | import ( |
| 660 | - "crypto/rand" |
| 661 | - "crypto/sha256" |
| 662 | - "encoding/base64" |
| 663 | - "encoding/hex" |
| 664 | - "errors" |
| 665 | - "fmt" |
| 666 | - "net/url" |
| 667 | - "strings" |
| 668 | - "sync" |
| 669 | "time" |
| 670 | |
| 671 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 672 | + proof "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 673 | ) |
| 674 | |
| 675 | -const tokenSkew = time.Minute |
| 676 | +// Validator and ChallengeManager preserve public verifier and replay-store |
| 677 | +// identity for callers that have not migrated to protocol/auth. |
| 678 | +type Validator = proof.Validator |
| 679 | +type ChallengeManager = proof.ChallengeManager |
| 680 | |
| 681 | -var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header") |
| 682 | +var ErrMissingAuthorization = proof.ErrMissingAuthorization |
| 683 | +var ErrGRASP08Unauthorized = proof.ErrGRASP08Unauthorized |
| 684 | |
| 685 | -type Validator struct { |
| 686 | - now func() time.Time |
| 687 | - mu sync.Mutex |
| 688 | - seen map[string]time.Time |
| 689 | -} |
| 690 | - |
| 691 | -// NewValidator uses now to check proof freshness and track replay windows. |
| 692 | -// A nil clock selects the system clock. |
| 693 | -func NewValidator(now func() time.Time) *Validator { |
| 694 | - if now == nil { |
| 695 | - now = time.Now |
| 696 | - } |
| 697 | - return &Validator{now: now, seen: make(map[string]time.Time)} |
| 698 | -} |
| 699 | - |
| 700 | -// VerifyNIP98 validates a kind 27235 request proof against the exact URL, |
| 701 | -// method and body supplied by the caller. Successfully verified event IDs are |
| 702 | -// retained for the one-minute replay window. |
| 703 | -func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (event.Event, error) { |
| 704 | - e, err := decodeToken(header, "NIP-98") |
| 705 | - if err != nil { |
| 706 | - return event.Event{}, err |
| 707 | - } |
| 708 | - if err := event.Validate(e); err != nil { |
| 709 | - return event.Event{}, authError(err.Error()) |
| 710 | - } |
| 711 | - if e.Kind != 27235 { |
| 712 | - return event.Event{}, authError("token must be kind 27235") |
| 713 | - } |
| 714 | - now := v.now() |
| 715 | - if outsideSkew(now, e.CreatedAt) { |
| 716 | - return event.Event{}, authError("token expired") |
| 717 | - } |
| 718 | - if err := sameRequestURL(event.Tag(e, "u"), rawURL); err != nil { |
| 719 | - return event.Event{}, err |
| 720 | - } |
| 721 | - if strings.ToUpper(event.Tag(e, "method")) != strings.ToUpper(method) { |
| 722 | - return event.Event{}, authError("token was signed for another method") |
| 723 | - } |
| 724 | - if body != "" && event.Tag(e, "payload") != hashBytes([]byte(body)) { |
| 725 | - return event.Event{}, authError("token payload hash does not match the body") |
| 726 | - } |
| 727 | - if err := v.mark(e.ID, now); err != nil { |
| 728 | - return event.Event{}, err |
| 729 | - } |
| 730 | - return e, nil |
| 731 | -} |
| 732 | - |
| 733 | -// VerifyBlossom validates a BUD-11 authorization for action. It accepts the |
| 734 | -// protocol's reusable authorization form and checks its action and expiry. |
| 735 | -func (v *Validator) VerifyBlossom(header, action string) (event.Event, error) { |
| 736 | - return v.verifyBlossom(header, action, "", "", false) |
| 737 | -} |
| 738 | - |
| 739 | -// VerifyBlossomRequest validates a BUD-11 authorization with optional server |
| 740 | -// and blob-hash scope. Upload, delete, mirror and media actions require an x |
| 741 | -// tag when this scoped form is used. |
| 742 | -func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (event.Event, error) { |
| 743 | - return v.verifyBlossom(header, action, server, blobHash, true) |
| 744 | -} |
| 745 | - |
| 746 | -// IsBlossomAuthorization reports whether an Authorization header contains a |
| 747 | -// signed BUD-11 event. Malformed tokens return false so callers can produce |
| 748 | -// their normal authentication error through the protocol-specific verifier. |
| 749 | -func IsBlossomAuthorization(header string) bool { |
| 750 | - e, err := decodeToken(header, "Blossom") |
| 751 | - return err == nil && e.Kind == 24242 |
| 752 | -} |
| 753 | - |
| 754 | -func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (event.Event, error) { |
| 755 | - e, err := decodeToken(header, "Blossom") |
| 756 | - if err != nil { |
| 757 | - return event.Event{}, err |
| 758 | - } |
| 759 | - if err := event.Validate(e); err != nil { |
| 760 | - return event.Event{}, authError(err.Error()) |
| 761 | - } |
| 762 | - if e.Kind != 24242 { |
| 763 | - return event.Event{}, authError("token must be kind 24242") |
| 764 | - } |
| 765 | - if event.Tag(e, "t") != action { |
| 766 | - return event.Event{}, authError(fmt.Sprintf("token is not for %s", action)) |
| 767 | - } |
| 768 | - if strict && server != "" && !blossomServerScope(e, server) { |
| 769 | - return event.Event{}, authError("token is not scoped to this server") |
| 770 | - } |
| 771 | - now := v.now() |
| 772 | - expiration := event.Expiration(e) |
| 773 | - if expiration == 0 || expiration <= now.Unix() { |
| 774 | - return event.Event{}, authError("token expired") |
| 775 | - } |
| 776 | - // BUD-11 authorization timestamps may not be from the future. The |
| 777 | - // expiration tag, rather than an arbitrary maximum age, controls how long |
| 778 | - // an otherwise valid token remains usable. |
| 779 | - if e.CreatedAt > now.Unix() { |
| 780 | - return event.Event{}, authError("token is from the future") |
| 781 | - } |
| 782 | - if strict && requiresBlobScope(action) && len(event.TagValues(e, "x")) == 0 { |
| 783 | - return event.Event{}, authError("token must include an x tag") |
| 784 | - } |
| 785 | - if strict && blobHash != "" && len(event.TagValues(e, "x")) > 0 && !contains(event.TagValues(e, "x"), blobHash) { |
| 786 | - return event.Event{}, authError("token x tag does not name this blob") |
| 787 | - } |
| 788 | - return e, nil |
| 789 | -} |
| 790 | - |
| 791 | -func requiresBlobScope(action string) bool { |
| 792 | - switch action { |
| 793 | - case "upload", "delete", "mirror", "media": |
| 794 | - return true |
| 795 | - default: |
| 796 | - return false |
| 797 | - } |
| 798 | -} |
| 799 | - |
| 800 | -func blossomServerScope(e event.Event, server string) bool { |
| 801 | - for _, value := range event.TagValues(e, "server") { |
| 802 | - if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) { |
| 803 | - return true |
| 804 | - } |
| 805 | - } |
| 806 | - return len(event.TagValues(e, "server")) == 0 |
| 807 | -} |
| 808 | - |
| 809 | -func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) { |
| 810 | - if strings.TrimSpace(header) == "" { |
| 811 | - return nil, nil |
| 812 | - } |
| 813 | - e, err := decodeToken(header, "authorization") |
| 814 | - if err != nil { |
| 815 | - return nil, err |
| 816 | - } |
| 817 | - if e.Kind == 24242 { |
| 818 | - if action == "" { |
| 819 | - return nil, authError("this door takes a NIP-98 signature") |
| 820 | - } |
| 821 | - e, err = v.VerifyBlossom(header, action) |
| 822 | - if err != nil { |
| 823 | - return nil, err |
| 824 | - } |
| 825 | - xs := event.TagValues(e, "x") |
| 826 | - if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) { |
| 827 | - return nil, authError("token x tag does not name this blob") |
| 828 | - } |
| 829 | - return []string{e.PubKey}, nil |
| 830 | - } |
| 831 | - e, err = v.VerifyNIP98(header, rawURL, method, body) |
| 832 | - if err != nil { |
| 833 | - return nil, err |
| 834 | - } |
| 835 | - return []string{e.PubKey}, nil |
| 836 | -} |
| 837 | - |
| 838 | -// ValidateBlobPayload checks the hash binding on a token that was already |
| 839 | -// authenticated by WhoAsks. It deliberately does not mark the event as seen a |
| 840 | -// second time: upload authorization and the streamed body validator inspect |
| 841 | -// the same proof at two different points in the request. |
| 842 | -func (v *Validator) ValidateBlobPayload(header, actualSHA string) error { |
| 843 | - e, err := decodeToken(header, "authorization") |
| 844 | - if err != nil { |
| 845 | - return err |
| 846 | - } |
| 847 | - if err := event.Validate(e); err != nil { |
| 848 | - return authError(err.Error()) |
| 849 | - } |
| 850 | - switch e.Kind { |
| 851 | - case 24242: |
| 852 | - if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 853 | - return authError("token x tag does not name this blob") |
| 854 | - } |
| 855 | - case 27235: |
| 856 | - if event.Tag(e, "payload") != actualSHA { |
| 857 | - return authError("token payload hash does not match the blob hash") |
| 858 | - } |
| 859 | - default: |
| 860 | - return authError("token must be kind 24242 or 27235") |
| 861 | - } |
| 862 | - return nil |
| 863 | -} |
| 864 | - |
| 865 | -// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated |
| 866 | -// operation after a streaming body has been materialized. It does not mark the |
| 867 | -// authorization event as seen a second time. |
| 868 | -func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error { |
| 869 | - e, err := v.VerifyBlossom(header, action) |
| 870 | - if err != nil { |
| 871 | - return err |
| 872 | - } |
| 873 | - if values := event.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 874 | - return authError("token x tag does not name this blob") |
| 875 | - } |
| 876 | - return nil |
| 877 | -} |
| 878 | - |
| 879 | -type ChallengeManager struct { |
| 880 | - relayURL string |
| 881 | - now func() time.Time |
| 882 | - mu sync.Mutex |
| 883 | - active map[string]time.Time |
| 884 | -} |
| 885 | - |
| 886 | -// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued |
| 887 | -// challenges are single-use and expire with the validator replay window. |
| 888 | +func NewValidator(now func() time.Time) *Validator { return proof.NewValidator(now) } |
| 889 | func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager { |
| 890 | - if now == nil { |
| 891 | - now = time.Now |
| 892 | - } |
| 893 | - return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)} |
| 894 | -} |
| 895 | - |
| 896 | -// Issue creates and stores a cryptographically random NIP-42 challenge. |
| 897 | -func (m *ChallengeManager) Issue() (string, error) { |
| 898 | - raw := make([]byte, 32) |
| 899 | - if _, err := rand.Read(raw); err != nil { |
| 900 | - return "", fmt.Errorf("create NIP-42 challenge: %w", err) |
| 901 | - } |
| 902 | - challenge := hex.EncodeToString(raw) |
| 903 | - now := m.now() |
| 904 | - m.mu.Lock() |
| 905 | - m.prune(now) |
| 906 | - m.active[challenge] = now.Add(tokenSkew) |
| 907 | - m.mu.Unlock() |
| 908 | - return challenge, nil |
| 909 | -} |
| 910 | - |
| 911 | -// Verify validates and consumes a NIP-42 AUTH event for the configured relay. |
| 912 | -func (m *ChallengeManager) Verify(e event.Event) error { |
| 913 | - if err := event.Validate(e); err != nil { |
| 914 | - return authError(err.Error()) |
| 915 | - } |
| 916 | - if e.Kind != 22242 { |
| 917 | - return authError("AUTH event must be kind 22242") |
| 918 | - } |
| 919 | - now := m.now() |
| 920 | - if outsideSkew(now, e.CreatedAt) { |
| 921 | - return authError("AUTH event timestamp expired") |
| 922 | - } |
| 923 | - if event.Tag(e, "relay") != m.relayURL { |
| 924 | - return authError("AUTH event names another relay") |
| 925 | - } |
| 926 | - challenge := event.Tag(e, "challenge") |
| 927 | - m.mu.Lock() |
| 928 | - defer m.mu.Unlock() |
| 929 | - m.prune(now) |
| 930 | - if _, ok := m.active[challenge]; !ok { |
| 931 | - return authError("AUTH challenge is unknown or already used") |
| 932 | - } |
| 933 | - delete(m.active, challenge) |
| 934 | - return nil |
| 935 | -} |
| 936 | - |
| 937 | -func (m *ChallengeManager) prune(now time.Time) { |
| 938 | - for challenge, expires := range m.active { |
| 939 | - if !expires.After(now) { |
| 940 | - delete(m.active, challenge) |
| 941 | - } |
| 942 | - } |
| 943 | -} |
| 944 | - |
| 945 | -func (v *Validator) mark(id string, now time.Time) error { |
| 946 | - v.mu.Lock() |
| 947 | - defer v.mu.Unlock() |
| 948 | - for seenID, at := range v.seen { |
| 949 | - if now.Sub(at) > tokenSkew { |
| 950 | - delete(v.seen, seenID) |
| 951 | - } |
| 952 | - } |
| 953 | - if _, ok := v.seen[id]; ok { |
| 954 | - return authError("token replay detected") |
| 955 | - } |
| 956 | - v.seen[id] = now |
| 957 | - return nil |
| 958 | -} |
| 959 | - |
| 960 | -func decodeToken(header, tokenName string) (event.Event, error) { |
| 961 | - parts := strings.Fields(header) |
| 962 | - if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") { |
| 963 | - return event.Event{}, ErrMissingAuthorization |
| 964 | - } |
| 965 | - raw, err := decodeBase64(parts[1]) |
| 966 | - if err != nil { |
| 967 | - return event.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName)) |
| 968 | - } |
| 969 | - e, err := event.Parse(raw) |
| 970 | - if err != nil { |
| 971 | - return event.Event{}, authError(err.Error()) |
| 972 | - } |
| 973 | - return e, nil |
| 974 | -} |
| 975 | - |
| 976 | -func decodeBase64(value string) ([]byte, error) { |
| 977 | - for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { |
| 978 | - if raw, err := encoding.DecodeString(value); err == nil { |
| 979 | - return raw, nil |
| 980 | - } |
| 981 | - } |
| 982 | - return nil, errors.New("invalid base64") |
| 983 | -} |
| 984 | - |
| 985 | -func sameRequestURL(signed, requested string) error { |
| 986 | - want, ok := normalizedRequestURL(requested) |
| 987 | - if !ok { |
| 988 | - return authError("token was signed for another URL") |
| 989 | - } |
| 990 | - got, ok := normalizedRequestURL(signed) |
| 991 | - if !ok || got != want { |
| 992 | - return authError("token was signed for another URL") |
| 993 | - } |
| 994 | - return nil |
| 995 | -} |
| 996 | - |
| 997 | -func normalizedRequestURL(raw string) (string, bool) { |
| 998 | - u, err := url.Parse(strings.TrimSpace(raw)) |
| 999 | - if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { |
| 1000 | - return "", false |
| 1001 | - } |
| 1002 | - path := u.EscapedPath() |
| 1003 | - if path == "" { |
| 1004 | - path = "/" |
| 1005 | - } |
| 1006 | - // URL.Host preserves a possibly meaningful port while hostname matching is |
| 1007 | - // case insensitive. Query strings remain part of the NIP-98 binding. |
| 1008 | - host := strings.ToLower(u.Hostname()) |
| 1009 | - if port := u.Port(); port != "" { |
| 1010 | - host += ":" + port |
| 1011 | - } |
| 1012 | - return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true |
| 1013 | -} |
| 1014 | - |
| 1015 | -func outsideSkew(now time.Time, createdAt int64) bool { |
| 1016 | - delta := now.Unix() - createdAt |
| 1017 | - return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second) |
| 1018 | -} |
| 1019 | - |
| 1020 | -func hashBytes(value []byte) string { |
| 1021 | - sum := sha256.Sum256(value) |
| 1022 | - return hex.EncodeToString(sum[:]) |
| 1023 | -} |
| 1024 | - |
| 1025 | -func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) } |
| 1026 | -func contains(values []string, value string) bool { |
| 1027 | - for _, candidate := range values { |
| 1028 | - if candidate == value { |
| 1029 | - return true |
| 1030 | - } |
| 1031 | - } |
| 1032 | - return false |
| 1033 | + return proof.NewChallengeManager(relayURL, now) |
| 1034 | } |
| 1035 | +func IsBlossomAuthorization(header string) bool { return proof.IsBlossomAuthorization(header) } |
| 1036 | +func GRASP08RepositoryRoot(raw string) (string, bool) { return proof.GRASP08RepositoryRoot(raw) } |
| 1037 | diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go |
| 1038 | deleted file mode 100644 |
| 1039 | index 0bfe60a..0000000 |
| 1040 | --- a/internal/auth/auth_test.go |
| 1041 | +++ /dev/null |
| 1042 | @@ -1,248 +0,0 @@ |
| 1043 | -package auth |
| 1044 | - |
| 1045 | -import ( |
| 1046 | - "crypto/sha256" |
| 1047 | - "encoding/base64" |
| 1048 | - "encoding/hex" |
| 1049 | - "strings" |
| 1050 | - "testing" |
| 1051 | - "time" |
| 1052 | - |
| 1053 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1054 | -) |
| 1055 | - |
| 1056 | -func TestNIP98ValidatesRequestAndRejectsReplay(t *testing.T) { |
| 1057 | - now := time.Unix(1_700_000_000, 0) |
| 1058 | - validator := NewValidator(func() time.Time { return now }) |
| 1059 | - e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", ""}}) |
| 1060 | - // A non-empty request body must carry its exact SHA-256 tag. |
| 1061 | - e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 1062 | - header := token(t, e) |
| 1063 | - got, err := validator.VerifyNIP98(header, "https://RELAY.example/api?ignored=1", "post", "hello") |
| 1064 | - if err != nil || got.PubKey == "" { |
| 1065 | - t.Fatalf("verify valid token: %v", err) |
| 1066 | - } |
| 1067 | - if _, err := validator.VerifyNIP98(header, "https://relay.example/api?ignored=1", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "replay") { |
| 1068 | - t.Fatalf("expected replay rejection, got %v", err) |
| 1069 | - } |
| 1070 | -} |
| 1071 | - |
| 1072 | -func TestNIP98ExpiryAndPayload(t *testing.T) { |
| 1073 | - now := time.Unix(1_700_000_000, 0) |
| 1074 | - validator := NewValidator(func() time.Time { return now }) |
| 1075 | - e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 1076 | - if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") { |
| 1077 | - t.Fatalf("expected expiry, got %v", err) |
| 1078 | - } |
| 1079 | - e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}}) |
| 1080 | - if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") { |
| 1081 | - t.Fatalf("expected payload rejection, got %v", err) |
| 1082 | - } |
| 1083 | -} |
| 1084 | - |
| 1085 | -func TestNIP98BindsSchemeQueryAndPath(t *testing.T) { |
| 1086 | - now := time.Unix(1_700_000_000, 0) |
| 1087 | - validator := NewValidator(func() time.Time { return now }) |
| 1088 | - for _, requested := range []string{ |
| 1089 | - "http://relay.example/api?a=1", |
| 1090 | - "https://relay.example/other?a=1", |
| 1091 | - "https://relay.example/api?a=2", |
| 1092 | - "https://relay.example/api?a=1#fragment", |
| 1093 | - "https://user:pass@relay.example/api?a=1", |
| 1094 | - } { |
| 1095 | - e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}}) |
| 1096 | - if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil { |
| 1097 | - t.Fatalf("accepted request URL %q", requested) |
| 1098 | - } |
| 1099 | - } |
| 1100 | -} |
| 1101 | - |
| 1102 | -func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) { |
| 1103 | - now := time.Unix(1_700_000_000, 0) |
| 1104 | - validator := NewValidator(func() time.Time { return now }) |
| 1105 | - root := "https://relay.example/npub1owner/repo.git" |
| 1106 | - e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}}) |
| 1107 | - header := token(t, e) |
| 1108 | - for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} { |
| 1109 | - if _, err := validator.VerifyGRASP08(header, request); err != nil { |
| 1110 | - t.Fatalf("verify reusable proof for %s: %v", request, err) |
| 1111 | - } |
| 1112 | - } |
| 1113 | -} |
| 1114 | - |
| 1115 | -func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) { |
| 1116 | - got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack") |
| 1117 | - if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" { |
| 1118 | - t.Fatalf("root=%q ok=%v", got, ok) |
| 1119 | - } |
| 1120 | -} |
| 1121 | - |
| 1122 | -func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) { |
| 1123 | - now := time.Unix(1_700_000_000, 0) |
| 1124 | - validator := NewValidator(func() time.Time { return now }) |
| 1125 | - for _, tc := range []struct { |
| 1126 | - name string |
| 1127 | - tags [][]string |
| 1128 | - at int64 |
| 1129 | - url string |
| 1130 | - }{ |
| 1131 | - {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 1132 | - {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 1133 | - {"expired", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}, now.Add(-61 * time.Second).Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 1134 | - } { |
| 1135 | - t.Run(tc.name, func(t *testing.T) { |
| 1136 | - _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url) |
| 1137 | - if err != ErrGRASP08Unauthorized { |
| 1138 | - t.Fatalf("error=%v, want uniform error", err) |
| 1139 | - } |
| 1140 | - }) |
| 1141 | - } |
| 1142 | -} |
| 1143 | - |
| 1144 | -func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) { |
| 1145 | - now := time.Unix(1_700_000_000, 0) |
| 1146 | - validator := NewValidator(func() time.Time { return now }) |
| 1147 | - e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}) |
| 1148 | - for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} { |
| 1149 | - if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized { |
| 1150 | - t.Fatalf("endpoint %s accepted with error %v", path, err) |
| 1151 | - } |
| 1152 | - } |
| 1153 | -} |
| 1154 | - |
| 1155 | -func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) { |
| 1156 | - now := time.Unix(1_700_000_000, 0) |
| 1157 | - validator := NewValidator(func() time.Time { return now }) |
| 1158 | - e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}}) |
| 1159 | - header := token(t, e) |
| 1160 | - if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 1161 | - t.Fatalf("verify first Blossom request: %v", err) |
| 1162 | - } |
| 1163 | - if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 1164 | - t.Fatalf("verify reused Blossom request: %v", err) |
| 1165 | - } |
| 1166 | -} |
| 1167 | - |
| 1168 | -func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) { |
| 1169 | - now := time.Unix(1_700_000_000, 0) |
| 1170 | - validator := NewValidator(func() time.Time { return now }) |
| 1171 | - hash := sha256Hex("blob") |
| 1172 | - blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 1173 | - if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil { |
| 1174 | - t.Fatalf("valid Blossom hash: %v", err) |
| 1175 | - } |
| 1176 | - if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") { |
| 1177 | - t.Fatalf("expected Blossom hash rejection, got %v", err) |
| 1178 | - } |
| 1179 | - nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}}) |
| 1180 | - if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil { |
| 1181 | - t.Fatalf("valid NIP98 hash: %v", err) |
| 1182 | - } |
| 1183 | - if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") { |
| 1184 | - t.Fatalf("expected NIP98 hash rejection, got %v", err) |
| 1185 | - } |
| 1186 | -} |
| 1187 | - |
| 1188 | -func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) { |
| 1189 | - now := time.Unix(1_700_000_000, 0) |
| 1190 | - validator := NewValidator(func() time.Time { return now }) |
| 1191 | - hash := sha256Hex("blob") |
| 1192 | - tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 1193 | - header := token(t, tokenEvent) |
| 1194 | - if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil { |
| 1195 | - t.Fatalf("valid mirror hash: %v", err) |
| 1196 | - } |
| 1197 | - if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil { |
| 1198 | - t.Fatal("accepted mismatched mirror hash") |
| 1199 | - } |
| 1200 | -} |
| 1201 | - |
| 1202 | -func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) { |
| 1203 | - now := time.Unix(1_700_000_000, 0) |
| 1204 | - validator := NewValidator(func() time.Time { return now }) |
| 1205 | - hash := sha256Hex("blob") |
| 1206 | - valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}}) |
| 1207 | - if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil { |
| 1208 | - t.Fatalf("valid scoped Blossom token: %v", err) |
| 1209 | - } |
| 1210 | - wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}}) |
| 1211 | - if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil { |
| 1212 | - t.Fatal("accepted token scoped to another server") |
| 1213 | - } |
| 1214 | - missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}}) |
| 1215 | - if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil { |
| 1216 | - t.Fatal("accepted upload token without x scope") |
| 1217 | - } |
| 1218 | -} |
| 1219 | - |
| 1220 | -func TestVerifyBlossomRequestAllowsUnscopedGet(t *testing.T) { |
| 1221 | - now := time.Unix(1_700_000_000, 0) |
| 1222 | - validator := NewValidator(func() time.Time { return now }) |
| 1223 | - hash := sha256Hex("blob") |
| 1224 | - e := signedEvent(t, 24242, now.Add(-24*time.Hour).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700086400"}}) |
| 1225 | - if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", hash); err != nil { |
| 1226 | - t.Fatalf("unscoped GET token rejected: %v", err) |
| 1227 | - } |
| 1228 | -} |
| 1229 | - |
| 1230 | -func TestVerifyBlossomRequestRejectsFutureCreatedAt(t *testing.T) { |
| 1231 | - now := time.Unix(1_700_000_000, 0) |
| 1232 | - validator := NewValidator(func() time.Time { return now }) |
| 1233 | - e := signedEvent(t, 24242, now.Add(time.Second).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700000300"}}) |
| 1234 | - if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", ""); err == nil || !strings.Contains(err.Error(), "future") { |
| 1235 | - t.Fatalf("future token accepted: %v", err) |
| 1236 | - } |
| 1237 | -} |
| 1238 | - |
| 1239 | -func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) { |
| 1240 | - now := time.Unix(1_700_000_000, 0) |
| 1241 | - m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now }) |
| 1242 | - challenge, err := m.Issue() |
| 1243 | - if err != nil { |
| 1244 | - t.Fatalf("issue challenge: %v", err) |
| 1245 | - } |
| 1246 | - e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}}) |
| 1247 | - if err := m.Verify(e); err != nil { |
| 1248 | - t.Fatalf("verify challenge: %v", err) |
| 1249 | - } |
| 1250 | - if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") { |
| 1251 | - t.Fatalf("expected one-time challenge rejection, got %v", err) |
| 1252 | - } |
| 1253 | -} |
| 1254 | - |
| 1255 | -func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) event.Event { |
| 1256 | - t.Helper() |
| 1257 | - e := event.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags} |
| 1258 | - if err := event.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 1259 | - t.Fatalf("sign event: %v", err) |
| 1260 | - } |
| 1261 | - return e |
| 1262 | -} |
| 1263 | - |
| 1264 | -func token(t *testing.T, e event.Event) string { |
| 1265 | - t.Helper() |
| 1266 | - raw, err := event.Canonical(e) |
| 1267 | - if err != nil { |
| 1268 | - t.Fatalf("encode event: %v", err) |
| 1269 | - } |
| 1270 | - return "Nostr " + base64.StdEncoding.EncodeToString(raw) |
| 1271 | -} |
| 1272 | - |
| 1273 | -func sha256Hex(value string) string { |
| 1274 | - sum := sha256.Sum256([]byte(value)) |
| 1275 | - return hex.EncodeToString(sum[:]) |
| 1276 | -} |
| 1277 | - |
| 1278 | -func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) { |
| 1279 | - now := time.Unix(1_700_000_000, 0) |
| 1280 | - validator := NewValidator(func() time.Time { return now }) |
| 1281 | - root := "https://relay.example/npub1owner/a%3Ab.git" |
| 1282 | - got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack") |
| 1283 | - if !ok || got != root { |
| 1284 | - t.Fatalf("root=%q ok=%v", got, ok) |
| 1285 | - } |
| 1286 | - e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}}) |
| 1287 | - if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil { |
| 1288 | - t.Fatalf("verify proof for an escaped identifier: %v", err) |
| 1289 | - } |
| 1290 | -} |
| 1291 | diff --git a/internal/auth/compatibility_test.go b/internal/auth/compatibility_test.go |
| 1292 | new file mode 100644 |
| 1293 | index 0000000..ffd4af6 |
| 1294 | --- /dev/null |
| 1295 | +++ b/internal/auth/compatibility_test.go |
| 1296 | @@ -0,0 +1,40 @@ |
| 1297 | +package auth_test |
| 1298 | + |
| 1299 | +import ( |
| 1300 | + "encoding/base64" |
| 1301 | + "strings" |
| 1302 | + "testing" |
| 1303 | + "time" |
| 1304 | + |
| 1305 | + legacy "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1306 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1307 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 1308 | +) |
| 1309 | + |
| 1310 | +func TestCompatibilitySharesReplayStateAndErrors(t *testing.T) { |
| 1311 | + if legacy.ErrMissingAuthorization != auth.ErrMissingAuthorization || legacy.ErrGRASP08Unauthorized != auth.ErrGRASP08Unauthorized { |
| 1312 | + t.Fatal("compatibility facade changed error identity") |
| 1313 | + } |
| 1314 | + now := time.Unix(1700000000, 0) |
| 1315 | + old := legacy.NewValidator(func() time.Time { return now }) |
| 1316 | + var public *auth.Validator = old |
| 1317 | + e := nostr.Event{Kind: 27235, CreatedAt: now.Unix(), Tags: [][]string{{"u", "https://relay.example"}, {"method", "GET"}}} |
| 1318 | + if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 1319 | + t.Fatal(err) |
| 1320 | + } |
| 1321 | + raw, err := nostr.Canonical(e) |
| 1322 | + if err != nil { |
| 1323 | + t.Fatal(err) |
| 1324 | + } |
| 1325 | + header := "Nostr " + base64.StdEncoding.EncodeToString(raw) |
| 1326 | + if _, err := old.VerifyNIP98(header, "https://relay.example", "GET", ""); err != nil { |
| 1327 | + t.Fatal(err) |
| 1328 | + } |
| 1329 | + if _, err := public.VerifyNIP98(header, "https://relay.example", "GET", ""); err == nil || !strings.Contains(err.Error(), "replay") { |
| 1330 | + t.Fatalf("facade lost replay state: %v", err) |
| 1331 | + } |
| 1332 | + var manager *auth.ChallengeManager = legacy.NewChallengeManager("wss://relay.example", nil) |
| 1333 | + if _, err := manager.Issue(); err != nil { |
| 1334 | + t.Fatal(err) |
| 1335 | + } |
| 1336 | +} |
| 1337 | diff --git a/internal/auth/doc.go b/internal/auth/doc.go |
| 1338 | index a3bd9cc..d8d5d0e 100644 |
| 1339 | --- a/internal/auth/doc.go |
| 1340 | +++ b/internal/auth/doc.go |
| 1341 | @@ -1,6 +1,5 @@ |
| 1342 | -// Package auth verifies the signed proofs used at HTTP and WebSocket |
| 1343 | -// boundaries. It handles NIP-98 request signatures, Blossom authorization, |
| 1344 | -// GRASP-08 repository proofs and NIP-42 challenges. Callers use the returned |
| 1345 | -// public key to apply relay policy and keep authorization state outside this |
| 1346 | -// package. |
| 1347 | +// Package auth is a transitional compatibility facade for protocol/auth. |
| 1348 | +// Its aliases share the public verifier types and replay state; proof parsing |
| 1349 | +// and validation live only in the public package. New consumers should import |
| 1350 | +// protocol/auth directly. |
| 1351 | package auth |
| 1352 | diff --git a/internal/auth/grasp08.go b/internal/auth/grasp08.go |
| 1353 | deleted file mode 100644 |
| 1354 | index 39ee850..0000000 |
| 1355 | --- a/internal/auth/grasp08.go |
| 1356 | +++ /dev/null |
| 1357 | @@ -1,68 +0,0 @@ |
| 1358 | -package auth |
| 1359 | - |
| 1360 | -import ( |
| 1361 | - "errors" |
| 1362 | - "net/url" |
| 1363 | - "strings" |
| 1364 | - |
| 1365 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1366 | -) |
| 1367 | - |
| 1368 | -// ErrGRASP08Unauthorized is the detail-free GRASP-08 authentication error |
| 1369 | -// returned for every failed repository proof. |
| 1370 | -var ErrGRASP08Unauthorized = errors.New("auth-required:") |
| 1371 | - |
| 1372 | -// VerifyGRASP08 validates a reusable kind 27235 GET proof for the repository |
| 1373 | -// root named by a GRASP-08 Smart HTTP request. The proof remains valid for |
| 1374 | -// one minute across the repository's supported Git requests. |
| 1375 | -func (v *Validator) VerifyGRASP08(header, rawURL string) (event.Event, error) { |
| 1376 | - e, err := decodeToken(header, "GRASP-08") |
| 1377 | - if err != nil { |
| 1378 | - return event.Event{}, ErrGRASP08Unauthorized |
| 1379 | - } |
| 1380 | - if err := event.Validate(e); err != nil || e.Kind != 27235 { |
| 1381 | - return event.Event{}, ErrGRASP08Unauthorized |
| 1382 | - } |
| 1383 | - now := v.now() |
| 1384 | - if e.CreatedAt < now.Unix()-60 || e.CreatedAt > now.Unix()+60 { |
| 1385 | - return event.Event{}, ErrGRASP08Unauthorized |
| 1386 | - } |
| 1387 | - if event.Tag(e, "method") != "GET" { |
| 1388 | - return event.Event{}, ErrGRASP08Unauthorized |
| 1389 | - } |
| 1390 | - root, ok := GRASP08RepositoryRoot(rawURL) |
| 1391 | - if !ok || sameRequestURL(event.Tag(e, "u"), root) != nil { |
| 1392 | - return event.Event{}, ErrGRASP08Unauthorized |
| 1393 | - } |
| 1394 | - return e, nil |
| 1395 | -} |
| 1396 | - |
| 1397 | -// GRASP08RepositoryRoot returns the canonical repository URL for a supported |
| 1398 | -// Smart HTTP request beneath a .git path. |
| 1399 | -func GRASP08RepositoryRoot(raw string) (string, bool) { |
| 1400 | - u, err := url.Parse(raw) |
| 1401 | - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { |
| 1402 | - return "", false |
| 1403 | - } |
| 1404 | - // Keep the client's percent-encoding so the root compares equal to the |
| 1405 | - // signed u tag, which is also kept as sent. |
| 1406 | - path := u.EscapedPath() |
| 1407 | - i := strings.LastIndex(path, ".git") |
| 1408 | - if i < 0 || (i+4 < len(path) && path[i+4] != '/') { |
| 1409 | - return "", false |
| 1410 | - } |
| 1411 | - rootPath := path[:i+4] |
| 1412 | - suffix := path[i+4:] |
| 1413 | - if suffix != "" && suffix != "/info/refs" && suffix != "/git-upload-pack" && suffix != "/git-receive-pack" { |
| 1414 | - return "", false |
| 1415 | - } |
| 1416 | - if strings.HasSuffix(rootPath, "/") { |
| 1417 | - return "", false |
| 1418 | - } |
| 1419 | - unescaped, err := url.PathUnescape(rootPath) |
| 1420 | - if err != nil { |
| 1421 | - return "", false |
| 1422 | - } |
| 1423 | - u.Path, u.RawPath, u.RawQuery, u.Fragment = unescaped, rootPath, "", "" |
| 1424 | - return u.String(), true |
| 1425 | -} |
| 1426 | diff --git a/internal/daemon/landing.go b/internal/daemon/landing.go |
| 1427 | index 10e678c..20b9419 100644 |
| 1428 | --- a/internal/daemon/landing.go |
| 1429 | +++ b/internal/daemon/landing.go |
| 1430 | @@ -10,11 +10,11 @@ import ( |
| 1431 | "strings" |
| 1432 | "time" |
| 1433 | |
| 1434 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1435 | "github.com/FelineStateMachine/tinyrelay/internal/catalog" |
| 1436 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 1437 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 1438 | "github.com/FelineStateMachine/tinyrelay/internal/templates" |
| 1439 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1440 | ) |
| 1441 | |
| 1442 | // ServeLanding handles only the process landing page and permanent relay |
| 1443 | diff --git a/internal/daemon/private_peer.go b/internal/daemon/private_peer.go |
| 1444 | index e582698..4cf68ce 100644 |
| 1445 | --- a/internal/daemon/private_peer.go |
| 1446 | +++ b/internal/daemon/private_peer.go |
| 1447 | @@ -11,8 +11,8 @@ import ( |
| 1448 | "net/url" |
| 1449 | "strings" |
| 1450 | |
| 1451 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1452 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 1453 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1454 | ) |
| 1455 | |
| 1456 | func (t *Tenant) privateHTTPAuth(ctx context.Context, method, rawURL, payloadHash string) (string, error) { |
| 1457 | diff --git a/internal/daemon/room_attachments.go b/internal/daemon/room_attachments.go |
| 1458 | index c18ce61..d57e69b 100644 |
| 1459 | --- a/internal/daemon/room_attachments.go |
| 1460 | +++ b/internal/daemon/room_attachments.go |
| 1461 | @@ -16,9 +16,9 @@ import ( |
| 1462 | "regexp" |
| 1463 | "strings" |
| 1464 | |
| 1465 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1466 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 1467 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 1468 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1469 | ) |
| 1470 | |
| 1471 | const roomAttachmentMaxBytes int64 = 32 << 20 |
| 1472 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 1473 | index 9d49d68..875f697 100644 |
| 1474 | --- a/internal/daemon/services.go |
| 1475 | +++ b/internal/daemon/services.go |
| 1476 | @@ -16,12 +16,10 @@ import ( |
| 1477 | "strings" |
| 1478 | "time" |
| 1479 | |
| 1480 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1481 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 1482 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 1483 | "github.com/FelineStateMachine/tinyrelay/internal/configport" |
| 1484 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1485 | - gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 1486 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 1487 | "github.com/FelineStateMachine/tinyrelay/internal/records" |
| 1488 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 1489 | @@ -29,8 +27,10 @@ import ( |
| 1490 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 1491 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 1492 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 1493 | - webui "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 1494 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 1495 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1496 | + webui "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 1497 | + gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 1498 | ) |
| 1499 | |
| 1500 | // initServices constructs feature services around the tenant store and policy |
| 1501 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 1502 | index f9a5159..219581e 100644 |
| 1503 | --- a/internal/daemon/tenant.go |
| 1504 | +++ b/internal/daemon/tenant.go |
| 1505 | @@ -12,14 +12,12 @@ import ( |
| 1506 | "sync" |
| 1507 | "time" |
| 1508 | |
| 1509 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 1510 | "github.com/FelineStateMachine/tinyrelay/internal/blob" |
| 1511 | "github.com/FelineStateMachine/tinyrelay/internal/catalog" |
| 1512 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 1513 | "github.com/FelineStateMachine/tinyrelay/internal/configport" |
| 1514 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 1515 | "github.com/FelineStateMachine/tinyrelay/internal/gates" |
| 1516 | - gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 1517 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 1518 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 1519 | "github.com/FelineStateMachine/tinyrelay/internal/records" |
| 1520 | @@ -29,7 +27,9 @@ import ( |
| 1521 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 1522 | "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol" |
| 1523 | "github.com/FelineStateMachine/tinyrelay/internal/webpush" |
| 1524 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 1525 | webui "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 1526 | + gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 1527 | ) |
| 1528 | |
| 1529 | type tenantConfig struct { |
| 1530 | diff --git a/internal/event/compatibility_test.go b/internal/event/compatibility_test.go |
| 1531 | new file mode 100644 |
| 1532 | index 0000000..ae863af |
| 1533 | --- /dev/null |
| 1534 | +++ b/internal/event/compatibility_test.go |
| 1535 | @@ -0,0 +1,32 @@ |
| 1536 | +package event |
| 1537 | + |
| 1538 | +import ( |
| 1539 | + "reflect" |
| 1540 | + "testing" |
| 1541 | + |
| 1542 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 1543 | +) |
| 1544 | + |
| 1545 | +func TestCompatibilityPreservesPublicTypeIdentity(t *testing.T) { |
| 1546 | + if reflect.TypeOf(Event{}).PkgPath() != reflect.TypeOf(nostr.Event{}).PkgPath() || reflect.TypeOf(Filter{}).PkgPath() != reflect.TypeOf(nostr.Filter{}).PkgPath() { |
| 1547 | + t.Fatal("compatibility values must alias the public types") |
| 1548 | + } |
| 1549 | +} |
| 1550 | + |
| 1551 | +func TestPrivateKindClassificationAndSearchCompatibility(t *testing.T) { |
| 1552 | + if !IsPrivate(4) || IsPrivate(KIND_DM) { |
| 1553 | + t.Fatal("kind classification mismatch") |
| 1554 | + } |
| 1555 | + for _, kind := range []int{4, 13, KIND_WRAP, 21059, KIND_NOSTR_CONNECT, KIND_PUSH_REGISTRATION} { |
| 1556 | + e := Event{Kind: kind, Content: "hello world"} |
| 1557 | + if Matches(Filter{Search: "hello"}, e) { |
| 1558 | + t.Errorf("private kind %d matched content search", kind) |
| 1559 | + } |
| 1560 | + if !Matches(Filter{Search: "lang:en"}, e) || !Matches(Filter{}, e) { |
| 1561 | + t.Errorf("private kind %d changed non-content matching", kind) |
| 1562 | + } |
| 1563 | + } |
| 1564 | + if !Matches(Filter{Search: "HELLO"}, Event{Kind: 1, Content: "hello world"}) { |
| 1565 | + t.Fatal("public content no longer matches") |
| 1566 | + } |
| 1567 | +} |
| 1568 | diff --git a/internal/event/doc.go b/internal/event/doc.go |
| 1569 | index 763a8d4..72313a8 100644 |
| 1570 | --- a/internal/event/doc.go |
| 1571 | +++ b/internal/event/doc.go |
| 1572 | @@ -1,5 +1,6 @@ |
| 1573 | -// Package event defines the relay's Nostr event and filter values. It owns |
| 1574 | -// wire parsing, canonical serialization, Schnorr signing and signature |
| 1575 | -// validation, plus the common kind and tag helpers used by storage and |
| 1576 | -// protocol gates. |
| 1577 | +// Package event keeps relay feature kind, job and room-reference semantics |
| 1578 | +// separate from the reusable values in protocol/nostr. Event and Filter are |
| 1579 | +// transitional aliases for the public types; parsing, encoding, signing and |
| 1580 | +// generic matching delegate to that package. Matches additionally preserves |
| 1581 | +// the host's private-kind search exclusion for existing consumers. |
| 1582 | package event |
| 1583 | diff --git a/internal/event/event.go b/internal/event/event.go |
| 1584 | index 0257c8b..0791d43 100644 |
| 1585 | --- a/internal/event/event.go |
| 1586 | +++ b/internal/event/event.go |
| 1587 | @@ -1,284 +1,28 @@ |
| 1588 | package event |
| 1589 | |
| 1590 | -import ( |
| 1591 | - "bytes" |
| 1592 | - "crypto/rand" |
| 1593 | - "crypto/sha256" |
| 1594 | - "encoding/hex" |
| 1595 | - "encoding/json" |
| 1596 | - "errors" |
| 1597 | - "fmt" |
| 1598 | - "strconv" |
| 1599 | - "strings" |
| 1600 | - |
| 1601 | - "fiatjaf.com/nostr" |
| 1602 | - "fiatjaf.com/nostr/nip13" |
| 1603 | - "github.com/btcsuite/btcd/btcec/v2" |
| 1604 | - "github.com/btcsuite/btcd/btcec/v2/schnorr" |
| 1605 | -) |
| 1606 | - |
| 1607 | -type Event struct { |
| 1608 | - ID string `json:"id"` |
| 1609 | - PubKey string `json:"pubkey"` |
| 1610 | - CreatedAt int64 `json:"created_at"` |
| 1611 | - Kind int `json:"kind"` |
| 1612 | - Tags [][]string `json:"tags"` |
| 1613 | - Content string `json:"content"` |
| 1614 | - Sig string `json:"sig"` |
| 1615 | -} |
| 1616 | - |
| 1617 | -// Validate checks the wire shape, canonical ID and Schnorr signature of e. |
| 1618 | -// Kind-specific checks are applied by the protocol gate that owns the kind. |
| 1619 | -func Validate(e Event) error { |
| 1620 | - if !hex64(e.ID) { |
| 1621 | - return errors.New("invalid: bad id") |
| 1622 | - } |
| 1623 | - if !hex64(e.PubKey) { |
| 1624 | - return errors.New("invalid: bad pubkey") |
| 1625 | - } |
| 1626 | - if len(e.Sig) != 128 || !isHex(e.Sig) { |
| 1627 | - return errors.New("invalid: bad sig") |
| 1628 | - } |
| 1629 | - if e.Kind < 0 || e.Kind > 65535 { |
| 1630 | - return errors.New("invalid: kind out of range") |
| 1631 | - } |
| 1632 | - if e.CreatedAt < 0 { |
| 1633 | - return errors.New("invalid: bad created_at") |
| 1634 | - } |
| 1635 | - if e.Tags == nil { |
| 1636 | - return errors.New("invalid: tags must be a list") |
| 1637 | - } |
| 1638 | - for _, tag := range e.Tags { |
| 1639 | - if len(tag) == 0 { |
| 1640 | - return errors.New("invalid: bad tag") |
| 1641 | - } |
| 1642 | - } |
| 1643 | - serialized, err := serializeForID(e) |
| 1644 | - if err != nil { |
| 1645 | - return errors.New("invalid: cannot serialize event") |
| 1646 | - } |
| 1647 | - sum := sha256.Sum256(serialized) |
| 1648 | - if e.ID != hex.EncodeToString(sum[:]) || !verifySignature(e, sum[:]) { |
| 1649 | - return errors.New("invalid: id or signature does not match content") |
| 1650 | - } |
| 1651 | - return nil |
| 1652 | -} |
| 1653 | - |
| 1654 | -// Parse decodes and validates one complete JSON event object. |
| 1655 | -func Parse(raw []byte) (Event, error) { |
| 1656 | - if err := requireFields(raw); err != nil { |
| 1657 | - return Event{}, err |
| 1658 | - } |
| 1659 | - var e Event |
| 1660 | - if err := json.Unmarshal(raw, &e); err != nil { |
| 1661 | - return Event{}, fmt.Errorf("invalid event JSON: %w", err) |
| 1662 | - } |
| 1663 | - if err := Validate(e); err != nil { |
| 1664 | - return Event{}, err |
| 1665 | - } |
| 1666 | - return e, nil |
| 1667 | -} |
| 1668 | - |
| 1669 | -// Canonical encodes e as the JSON representation used on relay boundaries. |
| 1670 | -func Canonical(e Event) ([]byte, error) { |
| 1671 | - var out bytes.Buffer |
| 1672 | - encoder := json.NewEncoder(&out) |
| 1673 | - encoder.SetEscapeHTML(false) |
| 1674 | - if err := encoder.Encode(e); err != nil { |
| 1675 | - return nil, err |
| 1676 | - } |
| 1677 | - return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil |
| 1678 | -} |
| 1679 | - |
| 1680 | -// Tag returns the first value for name, or an empty string when the tag is |
| 1681 | -// absent or has no value. |
| 1682 | -func Tag(e Event, name string) string { |
| 1683 | - for _, tag := range e.Tags { |
| 1684 | - if len(tag) > 0 && tag[0] == name && len(tag) > 1 { |
| 1685 | - return tag[1] |
| 1686 | - } |
| 1687 | - } |
| 1688 | - return "" |
| 1689 | -} |
| 1690 | - |
| 1691 | -// TagValues returns every first value belonging to tags named name. |
| 1692 | -func TagValues(e Event, name string) []string { |
| 1693 | - values := make([]string, 0) |
| 1694 | - for _, tag := range e.Tags { |
| 1695 | - if len(tag) > 1 && tag[0] == name { |
| 1696 | - values = append(values, tag[1]) |
| 1697 | - } |
| 1698 | - } |
| 1699 | - return values |
| 1700 | -} |
| 1701 | - |
| 1702 | -// Expiration returns the positive Unix timestamp in the expiration tag. |
| 1703 | -// Malformed, missing and nonpositive values return zero. |
| 1704 | -func Expiration(e Event) int64 { |
| 1705 | - v, err := strconv.ParseInt(Tag(e, "expiration"), 10, 64) |
| 1706 | - if err != nil || v <= 0 { |
| 1707 | - return 0 |
| 1708 | - } |
| 1709 | - return v |
| 1710 | -} |
| 1711 | - |
| 1712 | -// Difficulty returns the NIP-13 proof-of-work difficulty encoded by id. |
| 1713 | -func Difficulty(id string) int { |
| 1714 | - parsed, err := nostr.IDFromHex(id) |
| 1715 | - if err != nil { |
| 1716 | - return 0 |
| 1717 | - } |
| 1718 | - return nip13.Difficulty(parsed) |
| 1719 | -} |
| 1720 | - |
| 1721 | -// CommittedDifficulty returns the target in a nonce tag's third field. |
| 1722 | -func CommittedDifficulty(e Event) int { |
| 1723 | - for _, tag := range e.Tags { |
| 1724 | - if len(tag) > 2 && tag[0] == "nonce" { |
| 1725 | - n, _ := strconv.Atoi(tag[2]) |
| 1726 | - return n |
| 1727 | - } |
| 1728 | - } |
| 1729 | - return 0 |
| 1730 | -} |
| 1731 | - |
| 1732 | -// Sign derives the public key, ID and Schnorr signature for e from secretHex. |
| 1733 | -// A nil tag slice is encoded as an empty tag list before signing. |
| 1734 | -func Sign(e *Event, secretHex string) error { |
| 1735 | - secret, err := decodeSecret(secretHex) |
| 1736 | - if err != nil { |
| 1737 | - return err |
| 1738 | - } |
| 1739 | - if e.Tags == nil { |
| 1740 | - e.Tags = [][]string{} |
| 1741 | - } |
| 1742 | - key, pubkey := btcec.PrivKeyFromBytes(secret[:]) |
| 1743 | - e.PubKey = hex.EncodeToString(schnorr.SerializePubKey(pubkey)) |
| 1744 | - serialized, err := serializeForID(*e) |
| 1745 | - if err != nil { |
| 1746 | - return fmt.Errorf("serialize event: %w", err) |
| 1747 | - } |
| 1748 | - sum := sha256.Sum256(serialized) |
| 1749 | - signature, err := schnorr.Sign(key, sum[:]) |
| 1750 | - if err != nil { |
| 1751 | - return fmt.Errorf("sign event: %w", err) |
| 1752 | - } |
| 1753 | - e.ID = hex.EncodeToString(sum[:]) |
| 1754 | - e.Sig = hex.EncodeToString(signature.Serialize()) |
| 1755 | - return nil |
| 1756 | -} |
| 1757 | - |
| 1758 | -// GenerateKey creates a random nonzero 32-byte private key in hexadecimal. |
| 1759 | -func GenerateKey() (string, error) { |
| 1760 | - for { |
| 1761 | - key := make([]byte, 32) |
| 1762 | - if _, err := rand.Read(key); err != nil { |
| 1763 | - return "", fmt.Errorf("generate private key: %w", err) |
| 1764 | - } |
| 1765 | - if !allZero(key) { |
| 1766 | - return hex.EncodeToString(key), nil |
| 1767 | - } |
| 1768 | - } |
| 1769 | -} |
| 1770 | - |
| 1771 | -// PublicKey derives the x-only Schnorr public key for secretHex. |
| 1772 | -func PublicKey(secretHex string) (string, error) { |
| 1773 | - secret, err := decodeSecret(secretHex) |
| 1774 | - if err != nil { |
| 1775 | - return "", err |
| 1776 | - } |
| 1777 | - _, pubkey := btcec.PrivKeyFromBytes(secret[:]) |
| 1778 | - return hex.EncodeToString(schnorr.SerializePubKey(pubkey)), nil |
| 1779 | -} |
| 1780 | - |
| 1781 | -// IsEphemeral reports whether k is in Nostr's ephemeral kind range. |
| 1782 | -func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 } |
| 1783 | - |
| 1784 | -// IsReplaceable reports whether k is a replaceable event kind. |
| 1785 | -func IsReplaceable(k int) bool { return k == 0 || k == 3 || k >= 10000 && k < 20000 } |
| 1786 | - |
| 1787 | -// IsAddressable reports whether k is an addressable event kind. |
| 1788 | -func IsAddressable(k int) bool { return k >= 30000 && k < 40000 } |
| 1789 | - |
| 1790 | -// IsPrivate reports whether k carries recipient-scoped content. |
| 1791 | +import "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 1792 | + |
| 1793 | +// Event is the public Nostr wire value. The alias keeps feature consumers |
| 1794 | +// source-compatible while they migrate to protocol/nostr. |
| 1795 | +type Event = nostr.Event |
| 1796 | + |
| 1797 | +func Validate(e Event) error { return nostr.Validate(e) } |
| 1798 | +func Parse(raw []byte) (Event, error) { return nostr.Parse(raw) } |
| 1799 | +func Canonical(e Event) ([]byte, error) { return nostr.Canonical(e) } |
| 1800 | +func Tag(e Event, name string) string { return nostr.Tag(e, name) } |
| 1801 | +func TagValues(e Event, name string) []string { return nostr.TagValues(e, name) } |
| 1802 | +func Expiration(e Event) int64 { return nostr.Expiration(e) } |
| 1803 | +func Difficulty(id string) int { return nostr.Difficulty(id) } |
| 1804 | +func CommittedDifficulty(e Event) int { return nostr.CommittedDifficulty(e) } |
| 1805 | +func Sign(e *Event, secretHex string) error { return nostr.Sign(e, secretHex) } |
| 1806 | +func GenerateKey() (string, error) { return nostr.GenerateKey() } |
| 1807 | +func PublicKey(secretHex string) (string, error) { return nostr.PublicKey(secretHex) } |
| 1808 | +func IsEphemeral(k int) bool { return nostr.IsEphemeral(k) } |
| 1809 | +func IsReplaceable(k int) bool { return nostr.IsReplaceable(k) } |
| 1810 | +func IsAddressable(k int) bool { return nostr.IsAddressable(k) } |
| 1811 | + |
| 1812 | +// IsPrivate is the host's recipient-scoped kind classification, not a generic |
| 1813 | +// Nostr range. Keep feature privacy decisions outside protocol/nostr. |
| 1814 | func IsPrivate(k int) bool { |
| 1815 | return k == 4 || k == 13 || k == KIND_WRAP || k == 21059 || k == KIND_NOSTR_CONNECT || k == KIND_PUSH_REGISTRATION |
| 1816 | } |
| 1817 | - |
| 1818 | -func hex64(s string) bool { return len(s) == 64 && isHex(s) } |
| 1819 | -func isHex(s string) bool { |
| 1820 | - for _, char := range s { |
| 1821 | - if char >= '0' && char <= '9' || char >= 'a' && char <= 'f' { |
| 1822 | - continue |
| 1823 | - } |
| 1824 | - return false |
| 1825 | - } |
| 1826 | - return true |
| 1827 | -} |
| 1828 | - |
| 1829 | -func serializeForID(e Event) ([]byte, error) { |
| 1830 | - value := []any{0, e.PubKey, e.CreatedAt, e.Kind, e.Tags, e.Content} |
| 1831 | - var out bytes.Buffer |
| 1832 | - encoder := json.NewEncoder(&out) |
| 1833 | - encoder.SetEscapeHTML(false) |
| 1834 | - if err := encoder.Encode(value); err != nil { |
| 1835 | - return nil, err |
| 1836 | - } |
| 1837 | - serialized := bytes.TrimSuffix(out.Bytes(), []byte{'\n'}) |
| 1838 | - serialized = bytes.ReplaceAll(serialized, []byte(`\u2028`), []byte("\u2028")) |
| 1839 | - serialized = bytes.ReplaceAll(serialized, []byte(`\u2029`), []byte("\u2029")) |
| 1840 | - return serialized, nil |
| 1841 | -} |
| 1842 | - |
| 1843 | -func verifySignature(e Event, hash []byte) bool { |
| 1844 | - pubkey, err := hex.DecodeString(e.PubKey) |
| 1845 | - if err != nil { |
| 1846 | - return false |
| 1847 | - } |
| 1848 | - key, err := schnorr.ParsePubKey(pubkey) |
| 1849 | - if err != nil { |
| 1850 | - return false |
| 1851 | - } |
| 1852 | - signature, err := hex.DecodeString(e.Sig) |
| 1853 | - if err != nil { |
| 1854 | - return false |
| 1855 | - } |
| 1856 | - sig, err := schnorr.ParseSignature(signature) |
| 1857 | - return err == nil && sig.Verify(hash, key) |
| 1858 | -} |
| 1859 | - |
| 1860 | -func decodeSecret(value string) ([32]byte, error) { |
| 1861 | - var secret [32]byte |
| 1862 | - if len(value) != 64 { |
| 1863 | - return secret, errors.New("invalid secret key: expected 64 hex characters") |
| 1864 | - } |
| 1865 | - if _, err := hex.Decode(secret[:], []byte(strings.ToLower(value))); err != nil { |
| 1866 | - return secret, fmt.Errorf("invalid secret key: %w", err) |
| 1867 | - } |
| 1868 | - if allZero(secret[:]) { |
| 1869 | - return secret, errors.New("invalid secret key") |
| 1870 | - } |
| 1871 | - return secret, nil |
| 1872 | -} |
| 1873 | - |
| 1874 | -func allZero(value []byte) bool { |
| 1875 | - for _, item := range value { |
| 1876 | - if item != 0 { |
| 1877 | - return false |
| 1878 | - } |
| 1879 | - } |
| 1880 | - return true |
| 1881 | -} |
| 1882 | - |
| 1883 | -func requireFields(raw []byte) error { |
| 1884 | - var fields map[string]json.RawMessage |
| 1885 | - if err := json.Unmarshal(raw, &fields); err != nil { |
| 1886 | - return fmt.Errorf("invalid event JSON: %w", err) |
| 1887 | - } |
| 1888 | - for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} { |
| 1889 | - if _, ok := fields[field]; !ok { |
| 1890 | - return fmt.Errorf("invalid: missing %s", field) |
| 1891 | - } |
| 1892 | - } |
| 1893 | - return nil |
| 1894 | -} |
| 1895 | diff --git a/internal/event/event_test.go b/internal/event/event_test.go |
| 1896 | deleted file mode 100644 |
| 1897 | index bd0852d..0000000 |
| 1898 | --- a/internal/event/event_test.go |
| 1899 | +++ /dev/null |
| 1900 | @@ -1,127 +0,0 @@ |
| 1901 | -package event |
| 1902 | - |
| 1903 | -import ( |
| 1904 | - "bytes" |
| 1905 | - "crypto/sha256" |
| 1906 | - "encoding/hex" |
| 1907 | - "encoding/json" |
| 1908 | - "strings" |
| 1909 | - "testing" |
| 1910 | -) |
| 1911 | - |
| 1912 | -func TestSignParseValidateCanonical(t *testing.T) { |
| 1913 | - secret := strings.Repeat("0", 63) + "1" |
| 1914 | - e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{{"t", "go"}}, Content: "hello"} |
| 1915 | - if err := Sign(&e, secret); err != nil { |
| 1916 | - t.Fatalf("sign: %v", err) |
| 1917 | - } |
| 1918 | - if err := Validate(e); err != nil { |
| 1919 | - t.Fatalf("validate: %v", err) |
| 1920 | - } |
| 1921 | - got, err := Parse(mustJSON(t, e)) |
| 1922 | - if err != nil { |
| 1923 | - t.Fatalf("parse: %v", err) |
| 1924 | - } |
| 1925 | - canonical, err := Canonical(e) |
| 1926 | - if err != nil { |
| 1927 | - t.Fatalf("canonical: %v", err) |
| 1928 | - } |
| 1929 | - if string(canonical) != string(mustJSON(t, got)) { |
| 1930 | - t.Fatalf("canonical changed event: %s", canonical) |
| 1931 | - } |
| 1932 | -} |
| 1933 | - |
| 1934 | -func TestFilterTagJSONAndEmptyLists(t *testing.T) { |
| 1935 | - f, err := ParseFilter([]byte(`{"#t":["go"],"kinds":[],"limit":0}`)) |
| 1936 | - if err != nil { |
| 1937 | - t.Fatalf("parse filter: %v", err) |
| 1938 | - } |
| 1939 | - if len(f.Tags["t"]) != 1 || len(f.Kinds) != 0 || f.Kinds == nil { |
| 1940 | - t.Fatalf("filter fields lost: %#v", f) |
| 1941 | - } |
| 1942 | - e := Event{ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), Kind: 1, Tags: [][]string{{"t", "go"}}} |
| 1943 | - if Matches(f, e) { |
| 1944 | - t.Fatal("empty kinds must match nothing") |
| 1945 | - } |
| 1946 | - f.Kinds = nil |
| 1947 | - if !Matches(f, e) { |
| 1948 | - t.Fatal("tag filter should match") |
| 1949 | - } |
| 1950 | - raw, err := json.Marshal(f) |
| 1951 | - if err != nil || !strings.Contains(string(raw), `"#t"`) { |
| 1952 | - t.Fatalf("marshal tags: %s (%v)", raw, err) |
| 1953 | - } |
| 1954 | -} |
| 1955 | - |
| 1956 | -func TestSearchTermsAndKinds(t *testing.T) { |
| 1957 | - terms := SearchTerms("hello lang:en world:") |
| 1958 | - if len(terms) != 1 || terms[0] != "hello" { |
| 1959 | - t.Fatalf("terms: %#v", terms) |
| 1960 | - } |
| 1961 | - if !IsEphemeral(20001) || IsEphemeral(30000) || !IsReplaceable(10001) || !IsAddressable(30001) || !IsPrivate(4) || IsPrivate(KIND_DM) { |
| 1962 | - t.Fatal("kind classification mismatch") |
| 1963 | - } |
| 1964 | -} |
| 1965 | - |
| 1966 | -func TestNIP01CanonicalEscapingMatchesJSONStringify(t *testing.T) { |
| 1967 | - e := Event{CreatedAt: 1700000000, Kind: 1, Tags: [][]string{{"x", "a\b\f\u2028\u2029é"}}, Content: "quote \" slash \\ newline\n <tag>"} |
| 1968 | - got, err := serializeForID(e) |
| 1969 | - if err != nil { |
| 1970 | - t.Fatal(err) |
| 1971 | - } |
| 1972 | - if bytes.Contains(got, []byte(`\u003c`)) || !bytes.Contains(got, []byte(`\b`)) || !bytes.Contains(got, []byte(`\f`)) { |
| 1973 | - t.Fatalf("unexpected HTML/control escaping: %s", got) |
| 1974 | - } |
| 1975 | - if strings.Contains(string(got), `\u2028`) || strings.Contains(string(got), `\u2029`) { |
| 1976 | - t.Fatalf("U+2028/U+2029 must remain literal like JSON.stringify: %s", got) |
| 1977 | - } |
| 1978 | -} |
| 1979 | - |
| 1980 | -func TestParseRequiresEveryWireField(t *testing.T) { |
| 1981 | - e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: ""} |
| 1982 | - if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 1983 | - t.Fatal(err) |
| 1984 | - } |
| 1985 | - raw, err := json.Marshal(e) |
| 1986 | - if err != nil { |
| 1987 | - t.Fatal(err) |
| 1988 | - } |
| 1989 | - for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} { |
| 1990 | - var values map[string]json.RawMessage |
| 1991 | - if err := json.Unmarshal(raw, &values); err != nil { |
| 1992 | - t.Fatal(err) |
| 1993 | - } |
| 1994 | - delete(values, field) |
| 1995 | - missing, err := json.Marshal(values) |
| 1996 | - if err != nil { |
| 1997 | - t.Fatal(err) |
| 1998 | - } |
| 1999 | - if _, err := Parse(missing); err == nil { |
| 2000 | - t.Errorf("missing %s accepted", field) |
| 2001 | - } |
| 2002 | - } |
| 2003 | -} |
| 2004 | - |
| 2005 | -func TestNIP01IDIsSHA256OfCanonicalArray(t *testing.T) { |
| 2006 | - e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: "hello"} |
| 2007 | - if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 2008 | - t.Fatal(err) |
| 2009 | - } |
| 2010 | - serialized, err := serializeForID(e) |
| 2011 | - if err != nil { |
| 2012 | - t.Fatal(err) |
| 2013 | - } |
| 2014 | - sum := sha256.Sum256(serialized) |
| 2015 | - if e.ID != hex.EncodeToString(sum[:]) { |
| 2016 | - t.Fatalf("id %s does not hash canonical array %x", e.ID, sum) |
| 2017 | - } |
| 2018 | -} |
| 2019 | - |
| 2020 | -func mustJSON(t *testing.T, value any) []byte { |
| 2021 | - t.Helper() |
| 2022 | - b, err := json.Marshal(value) |
| 2023 | - if err != nil { |
| 2024 | - t.Fatalf("marshal: %v", err) |
| 2025 | - } |
| 2026 | - return b |
| 2027 | -} |
| 2028 | diff --git a/internal/event/filter.go b/internal/event/filter.go |
| 2029 | index 8f804d2..68bd075 100644 |
| 2030 | --- a/internal/event/filter.go |
| 2031 | +++ b/internal/event/filter.go |
| 2032 | @@ -1,193 +1,18 @@ |
| 2033 | package event |
| 2034 | |
| 2035 | -import ( |
| 2036 | - "encoding/json" |
| 2037 | - "fmt" |
| 2038 | - "strings" |
| 2039 | -) |
| 2040 | +import "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 2041 | |
| 2042 | -// Filter describes the event fields and tags a subscription or query accepts. |
| 2043 | -// Fields combine with AND; values within a list are alternatives. Nil IDs, |
| 2044 | -// Authors and Kinds leave that field unrestricted, while an empty non-nil list |
| 2045 | -// matches nothing. Tags uses names without the wire prefix #. Since and Until |
| 2046 | -// are inclusive Unix timestamps. Limit controls a historical result batch; |
| 2047 | -// Matches evaluates each event independently of that limit. |
| 2048 | -type Filter struct { |
| 2049 | - IDs []string `json:"ids,omitempty"` |
| 2050 | - Authors []string `json:"authors,omitempty"` |
| 2051 | - Kinds []int `json:"kinds,omitempty"` |
| 2052 | - Tags map[string][]string `json:"-"` |
| 2053 | - Since *int64 `json:"since,omitempty"` |
| 2054 | - Until *int64 `json:"until,omitempty"` |
| 2055 | - Limit *int `json:"limit,omitempty"` |
| 2056 | - Search string `json:"search,omitempty"` |
| 2057 | -} |
| 2058 | +// Filter is the public subscription and query value. |
| 2059 | +type Filter = nostr.Filter |
| 2060 | |
| 2061 | -// ParseFilter decodes supported Nostr filter fields, including #tag keys, and |
| 2062 | -// checks their JSON types. |
| 2063 | -func ParseFilter(raw []byte) (Filter, error) { |
| 2064 | - var values map[string]json.RawMessage |
| 2065 | - if err := json.Unmarshal(raw, &values); err != nil { |
| 2066 | - return Filter{}, errorsFilter("filter must be an object") |
| 2067 | - } |
| 2068 | - f := Filter{Tags: make(map[string][]string)} |
| 2069 | - for key, value := range values { |
| 2070 | - switch { |
| 2071 | - case key == "ids", key == "authors": |
| 2072 | - if !isJSONArray(value) { |
| 2073 | - return Filter{}, errorsFilter(key + " must be a list of strings") |
| 2074 | - } |
| 2075 | - var out []string |
| 2076 | - if err := json.Unmarshal(value, &out); err != nil { |
| 2077 | - return Filter{}, errorsFilter(key + " must be a list of strings") |
| 2078 | - } |
| 2079 | - if key == "ids" { |
| 2080 | - f.IDs = out |
| 2081 | - } else { |
| 2082 | - f.Authors = out |
| 2083 | - } |
| 2084 | - case key == "kinds": |
| 2085 | - if !isJSONArray(value) { |
| 2086 | - return Filter{}, errorsFilter("kinds must be a list of integers") |
| 2087 | - } |
| 2088 | - if err := json.Unmarshal(value, &f.Kinds); err != nil { |
| 2089 | - return Filter{}, errorsFilter("kinds must be a list of integers") |
| 2090 | - } |
| 2091 | - case key == "since", key == "until": |
| 2092 | - var n int64 |
| 2093 | - if err := json.Unmarshal(value, &n); err != nil { |
| 2094 | - return Filter{}, errorsFilter(key + " must be an integer") |
| 2095 | - } |
| 2096 | - if key == "since" { |
| 2097 | - f.Since = &n |
| 2098 | - } else { |
| 2099 | - f.Until = &n |
| 2100 | - } |
| 2101 | - case key == "limit": |
| 2102 | - var n int |
| 2103 | - if err := json.Unmarshal(value, &n); err != nil { |
| 2104 | - return Filter{}, errorsFilter("limit must be an integer") |
| 2105 | - } |
| 2106 | - f.Limit = &n |
| 2107 | - case key == "search": |
| 2108 | - if err := json.Unmarshal(value, &f.Search); err != nil { |
| 2109 | - return Filter{}, errorsFilter("search must be a string") |
| 2110 | - } |
| 2111 | - case len(key) == 2 && key[0] == '#': |
| 2112 | - if !isJSONArray(value) { |
| 2113 | - return Filter{}, errorsFilter(key + " must be a list of strings") |
| 2114 | - } |
| 2115 | - var out []string |
| 2116 | - if err := json.Unmarshal(value, &out); err != nil { |
| 2117 | - return Filter{}, errorsFilter(key + " must be a list of strings") |
| 2118 | - } |
| 2119 | - f.Tags[key[1:]] = out |
| 2120 | - } |
| 2121 | - } |
| 2122 | - return f, nil |
| 2123 | -} |
| 2124 | +func ParseFilter(raw []byte) (Filter, error) { return nostr.ParseFilter(raw) } |
| 2125 | +func SearchTerms(query string) []string { return nostr.SearchTerms(query) } |
| 2126 | |
| 2127 | -// MarshalJSON encodes tag constraints with their Nostr #name wire keys. |
| 2128 | -func (f Filter) MarshalJSON() ([]byte, error) { |
| 2129 | - values := make(map[string]json.RawMessage, 7+len(f.Tags)) |
| 2130 | - if f.IDs != nil { |
| 2131 | - values["ids"] = mustRaw(f.IDs) |
| 2132 | - } |
| 2133 | - if f.Authors != nil { |
| 2134 | - values["authors"] = mustRaw(f.Authors) |
| 2135 | - } |
| 2136 | - if f.Kinds != nil { |
| 2137 | - values["kinds"] = mustRaw(f.Kinds) |
| 2138 | - } |
| 2139 | - if f.Since != nil { |
| 2140 | - values["since"] = mustRaw(f.Since) |
| 2141 | - } |
| 2142 | - if f.Until != nil { |
| 2143 | - values["until"] = mustRaw(f.Until) |
| 2144 | - } |
| 2145 | - if f.Limit != nil { |
| 2146 | - values["limit"] = mustRaw(f.Limit) |
| 2147 | - } |
| 2148 | - if f.Search != "" { |
| 2149 | - values["search"] = mustRaw(f.Search) |
| 2150 | - } |
| 2151 | - for key, tags := range f.Tags { |
| 2152 | - values["#"+key] = mustRaw(tags) |
| 2153 | - } |
| 2154 | - return json.Marshal(values) |
| 2155 | -} |
| 2156 | - |
| 2157 | -// Matches reports whether e satisfies every populated constraint in f. |
| 2158 | -// Search terms are matched against public content and exclude private kinds. |
| 2159 | +// Matches preserves the host's search visibility rule for existing consumers. |
| 2160 | +// Generic field, tag and content matching lives in protocol/nostr. |
| 2161 | func Matches(f Filter, e Event) bool { |
| 2162 | - if f.IDs != nil && !contains(f.IDs, e.ID) || f.Authors != nil && !contains(f.Authors, e.PubKey) || f.Kinds != nil && !containsInt(f.Kinds, e.Kind) { |
| 2163 | - return false |
| 2164 | - } |
| 2165 | - if f.Since != nil && e.CreatedAt < *f.Since || f.Until != nil && e.CreatedAt > *f.Until { |
| 2166 | + if IsPrivate(e.Kind) && len(nostr.SearchTerms(f.Search)) > 0 { |
| 2167 | return false |
| 2168 | } |
| 2169 | - for name, values := range f.Tags { |
| 2170 | - if !tagMatch(e, name, values) { |
| 2171 | - return false |
| 2172 | - } |
| 2173 | - } |
| 2174 | - terms := SearchTerms(f.Search) |
| 2175 | - if len(terms) == 0 { |
| 2176 | - return true |
| 2177 | - } |
| 2178 | - if IsPrivate(e.Kind) { |
| 2179 | - return false |
| 2180 | - } |
| 2181 | - content := strings.ToLower(e.Content) |
| 2182 | - for _, term := range terms { |
| 2183 | - if !strings.Contains(content, strings.ToLower(term)) { |
| 2184 | - return false |
| 2185 | - } |
| 2186 | - } |
| 2187 | - return true |
| 2188 | -} |
| 2189 | - |
| 2190 | -// SearchTerms splits a search query into terms and drops protocol-style |
| 2191 | -// name:value tokens. |
| 2192 | -func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) } |
| 2193 | - |
| 2194 | -func slicesFilter(words []string) []string { |
| 2195 | - out := make([]string, 0, len(words)) |
| 2196 | - for _, word := range words { |
| 2197 | - if !strings.Contains(word, ":") { |
| 2198 | - out = append(out, word) |
| 2199 | - } |
| 2200 | - } |
| 2201 | - return out |
| 2202 | -} |
| 2203 | -func contains(values []string, want string) bool { |
| 2204 | - for _, value := range values { |
| 2205 | - if value == want { |
| 2206 | - return true |
| 2207 | - } |
| 2208 | - } |
| 2209 | - return false |
| 2210 | -} |
| 2211 | -func containsInt(values []int, want int) bool { |
| 2212 | - for _, value := range values { |
| 2213 | - if value == want { |
| 2214 | - return true |
| 2215 | - } |
| 2216 | - } |
| 2217 | - return false |
| 2218 | -} |
| 2219 | -func tagMatch(e Event, name string, values []string) bool { |
| 2220 | - for _, tag := range e.Tags { |
| 2221 | - if len(tag) > 1 && tag[0] == name && contains(values, tag[1]) { |
| 2222 | - return true |
| 2223 | - } |
| 2224 | - } |
| 2225 | - return false |
| 2226 | -} |
| 2227 | -func mustRaw(value any) json.RawMessage { data, _ := json.Marshal(value); return data } |
| 2228 | -func errorsFilter(message string) error { return fmt.Errorf("%s", message) } |
| 2229 | - |
| 2230 | -func isJSONArray(value json.RawMessage) bool { |
| 2231 | - trimmed := strings.TrimSpace(string(value)) |
| 2232 | - return len(trimmed) > 0 && trimmed[0] == '[' |
| 2233 | + return nostr.Matches(f, e) |
| 2234 | } |
| 2235 | diff --git a/internal/event/sign_empty_tags_test.go b/internal/event/sign_empty_tags_test.go |
| 2236 | deleted file mode 100644 |
| 2237 | index 7790671..0000000 |
| 2238 | --- a/internal/event/sign_empty_tags_test.go |
| 2239 | +++ /dev/null |
| 2240 | @@ -1,24 +0,0 @@ |
| 2241 | -package event |
| 2242 | - |
| 2243 | -import ( |
| 2244 | - "encoding/json" |
| 2245 | - "strings" |
| 2246 | - "testing" |
| 2247 | -) |
| 2248 | - |
| 2249 | -func TestSignEmptyTagsProducesValidWireEvent(t *testing.T) { |
| 2250 | - e := Event{Kind: 0, CreatedAt: 1, Content: "{}"} |
| 2251 | - if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 2252 | - t.Fatal(err) |
| 2253 | - } |
| 2254 | - raw, err := json.Marshal(e) |
| 2255 | - if err != nil { |
| 2256 | - t.Fatal(err) |
| 2257 | - } |
| 2258 | - if _, err := Parse(raw); err != nil { |
| 2259 | - t.Fatalf("signed event rejected: %s: %v", raw, err) |
| 2260 | - } |
| 2261 | - if !strings.Contains(string(raw), `"tags":[]`) { |
| 2262 | - t.Fatalf("tags must be an array: %s", raw) |
| 2263 | - } |
| 2264 | -} |
| 2265 | diff --git a/protocol/auth/auth.go b/protocol/auth/auth.go |
| 2266 | new file mode 100644 |
| 2267 | index 0000000..92a4efd |
| 2268 | --- /dev/null |
| 2269 | +++ b/protocol/auth/auth.go |
| 2270 | @@ -0,0 +1,369 @@ |
| 2271 | +package auth |
| 2272 | + |
| 2273 | +import ( |
| 2274 | + "crypto/rand" |
| 2275 | + "crypto/sha256" |
| 2276 | + "encoding/base64" |
| 2277 | + "encoding/hex" |
| 2278 | + "errors" |
| 2279 | + "fmt" |
| 2280 | + "net/url" |
| 2281 | + "strings" |
| 2282 | + "sync" |
| 2283 | + "time" |
| 2284 | + |
| 2285 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 2286 | +) |
| 2287 | + |
| 2288 | +const tokenSkew = time.Minute |
| 2289 | + |
| 2290 | +var ErrMissingAuthorization = errors.New("auth-required: missing Nostr Authorization header") |
| 2291 | + |
| 2292 | +type Validator struct { |
| 2293 | + now func() time.Time |
| 2294 | + mu sync.Mutex |
| 2295 | + seen map[string]time.Time |
| 2296 | +} |
| 2297 | + |
| 2298 | +// NewValidator uses now to check proof freshness and track replay windows. |
| 2299 | +// A nil clock selects the system clock. |
| 2300 | +func NewValidator(now func() time.Time) *Validator { |
| 2301 | + if now == nil { |
| 2302 | + now = time.Now |
| 2303 | + } |
| 2304 | + return &Validator{now: now, seen: make(map[string]time.Time)} |
| 2305 | +} |
| 2306 | + |
| 2307 | +// VerifyNIP98 validates a kind 27235 request proof against the exact URL, |
| 2308 | +// method and body supplied by the caller. Successfully verified event IDs are |
| 2309 | +// retained for the one-minute replay window. |
| 2310 | +func (v *Validator) VerifyNIP98(header, rawURL, method, body string) (nostr.Event, error) { |
| 2311 | + e, err := decodeToken(header, "NIP-98") |
| 2312 | + if err != nil { |
| 2313 | + return nostr.Event{}, err |
| 2314 | + } |
| 2315 | + if err := nostr.Validate(e); err != nil { |
| 2316 | + return nostr.Event{}, authError(err.Error()) |
| 2317 | + } |
| 2318 | + if e.Kind != 27235 { |
| 2319 | + return nostr.Event{}, authError("token must be kind 27235") |
| 2320 | + } |
| 2321 | + now := v.now() |
| 2322 | + if outsideSkew(now, e.CreatedAt) { |
| 2323 | + return nostr.Event{}, authError("token expired") |
| 2324 | + } |
| 2325 | + if err := sameRequestURL(nostr.Tag(e, "u"), rawURL); err != nil { |
| 2326 | + return nostr.Event{}, err |
| 2327 | + } |
| 2328 | + if strings.ToUpper(nostr.Tag(e, "method")) != strings.ToUpper(method) { |
| 2329 | + return nostr.Event{}, authError("token was signed for another method") |
| 2330 | + } |
| 2331 | + if body != "" && nostr.Tag(e, "payload") != hashBytes([]byte(body)) { |
| 2332 | + return nostr.Event{}, authError("token payload hash does not match the body") |
| 2333 | + } |
| 2334 | + if err := v.mark(e.ID, now); err != nil { |
| 2335 | + return nostr.Event{}, err |
| 2336 | + } |
| 2337 | + return e, nil |
| 2338 | +} |
| 2339 | + |
| 2340 | +// VerifyBlossom validates a BUD-11 authorization for action. It accepts the |
| 2341 | +// protocol's reusable authorization form and checks its action and expiry. |
| 2342 | +func (v *Validator) VerifyBlossom(header, action string) (nostr.Event, error) { |
| 2343 | + return v.verifyBlossom(header, action, "", "", false) |
| 2344 | +} |
| 2345 | + |
| 2346 | +// VerifyBlossomRequest validates a BUD-11 authorization with optional server |
| 2347 | +// and blob-hash scope. Upload, delete, mirror and media actions require an x |
| 2348 | +// tag when this scoped form is used. |
| 2349 | +func (v *Validator) VerifyBlossomRequest(header, action, server, blobHash string) (nostr.Event, error) { |
| 2350 | + return v.verifyBlossom(header, action, server, blobHash, true) |
| 2351 | +} |
| 2352 | + |
| 2353 | +// IsBlossomAuthorization reports whether an Authorization header contains a |
| 2354 | +// signed BUD-11 event. Malformed tokens return false so callers can produce |
| 2355 | +// their normal authentication error through the protocol-specific verifier. |
| 2356 | +func IsBlossomAuthorization(header string) bool { |
| 2357 | + e, err := decodeToken(header, "Blossom") |
| 2358 | + return err == nil && e.Kind == 24242 |
| 2359 | +} |
| 2360 | + |
| 2361 | +func (v *Validator) verifyBlossom(header, action, server, blobHash string, strict bool) (nostr.Event, error) { |
| 2362 | + e, err := decodeToken(header, "Blossom") |
| 2363 | + if err != nil { |
| 2364 | + return nostr.Event{}, err |
| 2365 | + } |
| 2366 | + if err := nostr.Validate(e); err != nil { |
| 2367 | + return nostr.Event{}, authError(err.Error()) |
| 2368 | + } |
| 2369 | + if e.Kind != 24242 { |
| 2370 | + return nostr.Event{}, authError("token must be kind 24242") |
| 2371 | + } |
| 2372 | + if nostr.Tag(e, "t") != action { |
| 2373 | + return nostr.Event{}, authError(fmt.Sprintf("token is not for %s", action)) |
| 2374 | + } |
| 2375 | + if strict && server != "" && !blossomServerScope(e, server) { |
| 2376 | + return nostr.Event{}, authError("token is not scoped to this server") |
| 2377 | + } |
| 2378 | + now := v.now() |
| 2379 | + expiration := nostr.Expiration(e) |
| 2380 | + if expiration == 0 || expiration <= now.Unix() { |
| 2381 | + return nostr.Event{}, authError("token expired") |
| 2382 | + } |
| 2383 | + // BUD-11 authorization timestamps may not be from the future. The |
| 2384 | + // expiration tag, rather than an arbitrary maximum age, controls how long |
| 2385 | + // an otherwise valid token remains usable. |
| 2386 | + if e.CreatedAt > now.Unix() { |
| 2387 | + return nostr.Event{}, authError("token is from the future") |
| 2388 | + } |
| 2389 | + if strict && requiresBlobScope(action) && len(nostr.TagValues(e, "x")) == 0 { |
| 2390 | + return nostr.Event{}, authError("token must include an x tag") |
| 2391 | + } |
| 2392 | + if strict && blobHash != "" && len(nostr.TagValues(e, "x")) > 0 && !contains(nostr.TagValues(e, "x"), blobHash) { |
| 2393 | + return nostr.Event{}, authError("token x tag does not name this blob") |
| 2394 | + } |
| 2395 | + return e, nil |
| 2396 | +} |
| 2397 | + |
| 2398 | +func requiresBlobScope(action string) bool { |
| 2399 | + switch action { |
| 2400 | + case "upload", "delete", "mirror", "media": |
| 2401 | + return true |
| 2402 | + default: |
| 2403 | + return false |
| 2404 | + } |
| 2405 | +} |
| 2406 | + |
| 2407 | +func blossomServerScope(e nostr.Event, server string) bool { |
| 2408 | + for _, value := range nostr.TagValues(e, "server") { |
| 2409 | + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(server)) { |
| 2410 | + return true |
| 2411 | + } |
| 2412 | + } |
| 2413 | + return len(nostr.TagValues(e, "server")) == 0 |
| 2414 | +} |
| 2415 | + |
| 2416 | +func (v *Validator) WhoAsks(header, rawURL, method, body, action, blobHash string) ([]string, error) { |
| 2417 | + if strings.TrimSpace(header) == "" { |
| 2418 | + return nil, nil |
| 2419 | + } |
| 2420 | + e, err := decodeToken(header, "authorization") |
| 2421 | + if err != nil { |
| 2422 | + return nil, err |
| 2423 | + } |
| 2424 | + if e.Kind == 24242 { |
| 2425 | + if action == "" { |
| 2426 | + return nil, authError("this door takes a NIP-98 signature") |
| 2427 | + } |
| 2428 | + e, err = v.VerifyBlossom(header, action) |
| 2429 | + if err != nil { |
| 2430 | + return nil, err |
| 2431 | + } |
| 2432 | + xs := nostr.TagValues(e, "x") |
| 2433 | + if blobHash != "" && len(xs) > 0 && !contains(xs, blobHash) { |
| 2434 | + return nil, authError("token x tag does not name this blob") |
| 2435 | + } |
| 2436 | + return []string{e.PubKey}, nil |
| 2437 | + } |
| 2438 | + e, err = v.VerifyNIP98(header, rawURL, method, body) |
| 2439 | + if err != nil { |
| 2440 | + return nil, err |
| 2441 | + } |
| 2442 | + return []string{e.PubKey}, nil |
| 2443 | +} |
| 2444 | + |
| 2445 | +// ValidateBlobPayload checks the hash binding on a token that was already |
| 2446 | +// authenticated by WhoAsks. It deliberately does not mark the event as seen a |
| 2447 | +// second time: upload authorization and the streamed body validator inspect |
| 2448 | +// the same proof at two different points in the request. |
| 2449 | +func (v *Validator) ValidateBlobPayload(header, actualSHA string) error { |
| 2450 | + e, err := decodeToken(header, "authorization") |
| 2451 | + if err != nil { |
| 2452 | + return err |
| 2453 | + } |
| 2454 | + if err := nostr.Validate(e); err != nil { |
| 2455 | + return authError(err.Error()) |
| 2456 | + } |
| 2457 | + switch e.Kind { |
| 2458 | + case 24242: |
| 2459 | + if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 2460 | + return authError("token x tag does not name this blob") |
| 2461 | + } |
| 2462 | + case 27235: |
| 2463 | + if nostr.Tag(e, "payload") != actualSHA { |
| 2464 | + return authError("token payload hash does not match the blob hash") |
| 2465 | + } |
| 2466 | + default: |
| 2467 | + return authError("token must be kind 24242 or 27235") |
| 2468 | + } |
| 2469 | + return nil |
| 2470 | +} |
| 2471 | + |
| 2472 | +// ValidateBlossomPayload rechecks the content hash for a Blossom-authenticated |
| 2473 | +// operation after a streaming body has been materialized. It does not mark the |
| 2474 | +// authorization event as seen a second time. |
| 2475 | +func (v *Validator) ValidateBlossomPayload(header, action, actualSHA string) error { |
| 2476 | + e, err := v.VerifyBlossom(header, action) |
| 2477 | + if err != nil { |
| 2478 | + return err |
| 2479 | + } |
| 2480 | + if values := nostr.TagValues(e, "x"); len(values) > 0 && !contains(values, actualSHA) { |
| 2481 | + return authError("token x tag does not name this blob") |
| 2482 | + } |
| 2483 | + return nil |
| 2484 | +} |
| 2485 | + |
| 2486 | +type ChallengeManager struct { |
| 2487 | + relayURL string |
| 2488 | + now func() time.Time |
| 2489 | + mu sync.Mutex |
| 2490 | + active map[string]time.Time |
| 2491 | +} |
| 2492 | + |
| 2493 | +// NewChallengeManager creates a NIP-42 challenge store for relayURL. Issued |
| 2494 | +// challenges are single-use and expire with the validator replay window. |
| 2495 | +func NewChallengeManager(relayURL string, now func() time.Time) *ChallengeManager { |
| 2496 | + if now == nil { |
| 2497 | + now = time.Now |
| 2498 | + } |
| 2499 | + return &ChallengeManager{relayURL: relayURL, now: now, active: make(map[string]time.Time)} |
| 2500 | +} |
| 2501 | + |
| 2502 | +// Issue creates and stores a cryptographically random NIP-42 challenge. |
| 2503 | +func (m *ChallengeManager) Issue() (string, error) { |
| 2504 | + raw := make([]byte, 32) |
| 2505 | + if _, err := rand.Read(raw); err != nil { |
| 2506 | + return "", fmt.Errorf("create NIP-42 challenge: %w", err) |
| 2507 | + } |
| 2508 | + challenge := hex.EncodeToString(raw) |
| 2509 | + now := m.now() |
| 2510 | + m.mu.Lock() |
| 2511 | + m.prune(now) |
| 2512 | + m.active[challenge] = now.Add(tokenSkew) |
| 2513 | + m.mu.Unlock() |
| 2514 | + return challenge, nil |
| 2515 | +} |
| 2516 | + |
| 2517 | +// Verify validates and consumes a NIP-42 AUTH event for the configured relay. |
| 2518 | +func (m *ChallengeManager) Verify(e nostr.Event) error { |
| 2519 | + if err := nostr.Validate(e); err != nil { |
| 2520 | + return authError(err.Error()) |
| 2521 | + } |
| 2522 | + if e.Kind != 22242 { |
| 2523 | + return authError("AUTH event must be kind 22242") |
| 2524 | + } |
| 2525 | + now := m.now() |
| 2526 | + if outsideSkew(now, e.CreatedAt) { |
| 2527 | + return authError("AUTH event timestamp expired") |
| 2528 | + } |
| 2529 | + if nostr.Tag(e, "relay") != m.relayURL { |
| 2530 | + return authError("AUTH event names another relay") |
| 2531 | + } |
| 2532 | + challenge := nostr.Tag(e, "challenge") |
| 2533 | + m.mu.Lock() |
| 2534 | + defer m.mu.Unlock() |
| 2535 | + m.prune(now) |
| 2536 | + if _, ok := m.active[challenge]; !ok { |
| 2537 | + return authError("AUTH challenge is unknown or already used") |
| 2538 | + } |
| 2539 | + delete(m.active, challenge) |
| 2540 | + return nil |
| 2541 | +} |
| 2542 | + |
| 2543 | +func (m *ChallengeManager) prune(now time.Time) { |
| 2544 | + for challenge, expires := range m.active { |
| 2545 | + if !expires.After(now) { |
| 2546 | + delete(m.active, challenge) |
| 2547 | + } |
| 2548 | + } |
| 2549 | +} |
| 2550 | + |
| 2551 | +func (v *Validator) mark(id string, now time.Time) error { |
| 2552 | + v.mu.Lock() |
| 2553 | + defer v.mu.Unlock() |
| 2554 | + for seenID, at := range v.seen { |
| 2555 | + if now.Sub(at) > tokenSkew { |
| 2556 | + delete(v.seen, seenID) |
| 2557 | + } |
| 2558 | + } |
| 2559 | + if _, ok := v.seen[id]; ok { |
| 2560 | + return authError("token replay detected") |
| 2561 | + } |
| 2562 | + v.seen[id] = now |
| 2563 | + return nil |
| 2564 | +} |
| 2565 | + |
| 2566 | +func decodeToken(header, tokenName string) (nostr.Event, error) { |
| 2567 | + parts := strings.Fields(header) |
| 2568 | + if len(parts) != 2 || !strings.EqualFold(parts[0], "Nostr") { |
| 2569 | + return nostr.Event{}, ErrMissingAuthorization |
| 2570 | + } |
| 2571 | + raw, err := decodeBase64(parts[1]) |
| 2572 | + if err != nil { |
| 2573 | + return nostr.Event{}, authError(fmt.Sprintf("malformed %s token", tokenName)) |
| 2574 | + } |
| 2575 | + e, err := nostr.Parse(raw) |
| 2576 | + if err != nil { |
| 2577 | + return nostr.Event{}, authError(err.Error()) |
| 2578 | + } |
| 2579 | + return e, nil |
| 2580 | +} |
| 2581 | + |
| 2582 | +func decodeBase64(value string) ([]byte, error) { |
| 2583 | + for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { |
| 2584 | + if raw, err := encoding.DecodeString(value); err == nil { |
| 2585 | + return raw, nil |
| 2586 | + } |
| 2587 | + } |
| 2588 | + return nil, errors.New("invalid base64") |
| 2589 | +} |
| 2590 | + |
| 2591 | +func sameRequestURL(signed, requested string) error { |
| 2592 | + want, ok := normalizedRequestURL(requested) |
| 2593 | + if !ok { |
| 2594 | + return authError("token was signed for another URL") |
| 2595 | + } |
| 2596 | + got, ok := normalizedRequestURL(signed) |
| 2597 | + if !ok || got != want { |
| 2598 | + return authError("token was signed for another URL") |
| 2599 | + } |
| 2600 | + return nil |
| 2601 | +} |
| 2602 | + |
| 2603 | +func normalizedRequestURL(raw string) (string, bool) { |
| 2604 | + u, err := url.Parse(strings.TrimSpace(raw)) |
| 2605 | + if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { |
| 2606 | + return "", false |
| 2607 | + } |
| 2608 | + path := u.EscapedPath() |
| 2609 | + if path == "" { |
| 2610 | + path = "/" |
| 2611 | + } |
| 2612 | + // URL.Host preserves a possibly meaningful port while hostname matching is |
| 2613 | + // case insensitive. Query strings remain part of the NIP-98 binding. |
| 2614 | + host := strings.ToLower(u.Hostname()) |
| 2615 | + if port := u.Port(); port != "" { |
| 2616 | + host += ":" + port |
| 2617 | + } |
| 2618 | + return strings.ToLower(u.Scheme) + "://" + host + path + "?" + u.RawQuery, true |
| 2619 | +} |
| 2620 | + |
| 2621 | +func outsideSkew(now time.Time, createdAt int64) bool { |
| 2622 | + delta := now.Unix() - createdAt |
| 2623 | + return delta > int64(tokenSkew/time.Second) || delta < -int64(tokenSkew/time.Second) |
| 2624 | +} |
| 2625 | + |
| 2626 | +func hashBytes(value []byte) string { |
| 2627 | + sum := sha256.Sum256(value) |
| 2628 | + return hex.EncodeToString(sum[:]) |
| 2629 | +} |
| 2630 | + |
| 2631 | +func authError(reason string) error { return fmt.Errorf("auth-required: %s", reason) } |
| 2632 | +func contains(values []string, value string) bool { |
| 2633 | + for _, candidate := range values { |
| 2634 | + if candidate == value { |
| 2635 | + return true |
| 2636 | + } |
| 2637 | + } |
| 2638 | + return false |
| 2639 | +} |
| 2640 | diff --git a/protocol/auth/auth_test.go b/protocol/auth/auth_test.go |
| 2641 | new file mode 100644 |
| 2642 | index 0000000..f587e5e |
| 2643 | --- /dev/null |
| 2644 | +++ b/protocol/auth/auth_test.go |
| 2645 | @@ -0,0 +1,248 @@ |
| 2646 | +package auth |
| 2647 | + |
| 2648 | +import ( |
| 2649 | + "crypto/sha256" |
| 2650 | + "encoding/base64" |
| 2651 | + "encoding/hex" |
| 2652 | + "strings" |
| 2653 | + "testing" |
| 2654 | + "time" |
| 2655 | + |
| 2656 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 2657 | +) |
| 2658 | + |
| 2659 | +func TestNIP98ValidatesRequestAndRejectsReplay(t *testing.T) { |
| 2660 | + now := time.Unix(1_700_000_000, 0) |
| 2661 | + validator := NewValidator(func() time.Time { return now }) |
| 2662 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", ""}}) |
| 2663 | + // A non-empty request body must carry its exact SHA-256 tag. |
| 2664 | + e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?ignored=1"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 2665 | + header := token(t, e) |
| 2666 | + got, err := validator.VerifyNIP98(header, "https://RELAY.example/api?ignored=1", "post", "hello") |
| 2667 | + if err != nil || got.PubKey == "" { |
| 2668 | + t.Fatalf("verify valid token: %v", err) |
| 2669 | + } |
| 2670 | + if _, err := validator.VerifyNIP98(header, "https://relay.example/api?ignored=1", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "replay") { |
| 2671 | + t.Fatalf("expected replay rejection, got %v", err) |
| 2672 | + } |
| 2673 | +} |
| 2674 | + |
| 2675 | +func TestNIP98ExpiryAndPayload(t *testing.T) { |
| 2676 | + now := time.Unix(1_700_000_000, 0) |
| 2677 | + validator := NewValidator(func() time.Time { return now }) |
| 2678 | + e := signedEvent(t, 27235, now.Add(-61*time.Second).Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("hello")}}) |
| 2679 | + if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "expired") { |
| 2680 | + t.Fatalf("expected expiry, got %v", err) |
| 2681 | + } |
| 2682 | + e = signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "POST"}, {"payload", sha256Hex("other")}}) |
| 2683 | + if _, err := validator.VerifyNIP98(token(t, e), "https://relay.example/api", "POST", "hello"); err == nil || !strings.Contains(err.Error(), "payload") { |
| 2684 | + t.Fatalf("expected payload rejection, got %v", err) |
| 2685 | + } |
| 2686 | +} |
| 2687 | + |
| 2688 | +func TestNIP98BindsSchemeQueryAndPath(t *testing.T) { |
| 2689 | + now := time.Unix(1_700_000_000, 0) |
| 2690 | + validator := NewValidator(func() time.Time { return now }) |
| 2691 | + for _, requested := range []string{ |
| 2692 | + "http://relay.example/api?a=1", |
| 2693 | + "https://relay.example/other?a=1", |
| 2694 | + "https://relay.example/api?a=2", |
| 2695 | + "https://relay.example/api?a=1#fragment", |
| 2696 | + "https://user:pass@relay.example/api?a=1", |
| 2697 | + } { |
| 2698 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}}) |
| 2699 | + if _, err := validator.VerifyNIP98(token(t, e), requested, "GET", ""); err == nil { |
| 2700 | + t.Fatalf("accepted request URL %q", requested) |
| 2701 | + } |
| 2702 | + } |
| 2703 | +} |
| 2704 | + |
| 2705 | +func TestGRASP08ReusesRepositoryRootProofAcrossSmartHTTP(t *testing.T) { |
| 2706 | + now := time.Unix(1_700_000_000, 0) |
| 2707 | + validator := NewValidator(func() time.Time { return now }) |
| 2708 | + root := "https://relay.example/npub1owner/repo.git" |
| 2709 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}, {"payload", sha256Hex("different body")}}) |
| 2710 | + header := token(t, e) |
| 2711 | + for _, request := range []string{root + "/info/refs?service=git-upload-pack", root + "/git-upload-pack"} { |
| 2712 | + if _, err := validator.VerifyGRASP08(header, request); err != nil { |
| 2713 | + t.Fatalf("verify reusable proof for %s: %v", request, err) |
| 2714 | + } |
| 2715 | + } |
| 2716 | +} |
| 2717 | + |
| 2718 | +func TestGRASP08RepositoryRootHandlesGitSubstringInSlug(t *testing.T) { |
| 2719 | + got, ok := GRASP08RepositoryRoot("https://relay.example/npub1owner/foo.git-tools.git/info/refs?service=git-upload-pack") |
| 2720 | + if !ok || got != "https://relay.example/npub1owner/foo.git-tools.git" { |
| 2721 | + t.Fatalf("root=%q ok=%v", got, ok) |
| 2722 | + } |
| 2723 | +} |
| 2724 | + |
| 2725 | +func TestGRASP08RejectsWrongRootMethodAndAgeWithUniformError(t *testing.T) { |
| 2726 | + now := time.Unix(1_700_000_000, 0) |
| 2727 | + validator := NewValidator(func() time.Time { return now }) |
| 2728 | + for _, tc := range []struct { |
| 2729 | + name string |
| 2730 | + tags [][]string |
| 2731 | + at int64 |
| 2732 | + url string |
| 2733 | + }{ |
| 2734 | + {"wrong root", [][]string{{"u", "https://relay.example/npub1owner/other.git"}, {"method", "GET"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 2735 | + {"wrong method", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "POST"}}, now.Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 2736 | + {"expired", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}, now.Add(-61 * time.Second).Unix(), "https://relay.example/npub1owner/repo.git/info/refs"}, |
| 2737 | + } { |
| 2738 | + t.Run(tc.name, func(t *testing.T) { |
| 2739 | + _, err := validator.VerifyGRASP08(token(t, signedEvent(t, 27235, tc.at, "", tc.tags)), tc.url) |
| 2740 | + if err != ErrGRASP08Unauthorized { |
| 2741 | + t.Fatalf("error=%v, want uniform error", err) |
| 2742 | + } |
| 2743 | + }) |
| 2744 | + } |
| 2745 | +} |
| 2746 | + |
| 2747 | +func TestGRASP08RejectsUnsupportedGitEndpoint(t *testing.T) { |
| 2748 | + now := time.Unix(1_700_000_000, 0) |
| 2749 | + validator := NewValidator(func() time.Time { return now }) |
| 2750 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/npub1owner/repo.git"}, {"method", "GET"}}) |
| 2751 | + for _, path := range []string{"/objects/pack/a", "/info/refs/extra", "/"} { |
| 2752 | + if _, err := validator.VerifyGRASP08(token(t, e), "https://relay.example/npub1owner/repo.git"+path); err != ErrGRASP08Unauthorized { |
| 2753 | + t.Fatalf("endpoint %s accepted with error %v", path, err) |
| 2754 | + } |
| 2755 | + } |
| 2756 | +} |
| 2757 | + |
| 2758 | +func TestBlossomTokenMayBeReusedAcrossHeadAndPut(t *testing.T) { |
| 2759 | + now := time.Unix(1_700_000_000, 0) |
| 2760 | + validator := NewValidator(func() time.Time { return now }) |
| 2761 | + e := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}}) |
| 2762 | + header := token(t, e) |
| 2763 | + if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 2764 | + t.Fatalf("verify first Blossom request: %v", err) |
| 2765 | + } |
| 2766 | + if _, err := validator.VerifyBlossom(header, "upload"); err != nil { |
| 2767 | + t.Fatalf("verify reused Blossom request: %v", err) |
| 2768 | + } |
| 2769 | +} |
| 2770 | + |
| 2771 | +func TestValidateBlobPayloadBindsBlossomAndNIP98Hashes(t *testing.T) { |
| 2772 | + now := time.Unix(1_700_000_000, 0) |
| 2773 | + validator := NewValidator(func() time.Time { return now }) |
| 2774 | + hash := sha256Hex("blob") |
| 2775 | + blossom := signedEvent(t, 24242, now.Unix(), "upload", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 2776 | + if err := validator.ValidateBlobPayload(token(t, blossom), hash); err != nil { |
| 2777 | + t.Fatalf("valid Blossom hash: %v", err) |
| 2778 | + } |
| 2779 | + if err := validator.ValidateBlobPayload(token(t, blossom), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "blob") { |
| 2780 | + t.Fatalf("expected Blossom hash rejection, got %v", err) |
| 2781 | + } |
| 2782 | + nip98 := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/upload"}, {"method", "PUT"}, {"payload", hash}}) |
| 2783 | + if err := validator.ValidateBlobPayload(token(t, nip98), hash); err != nil { |
| 2784 | + t.Fatalf("valid NIP98 hash: %v", err) |
| 2785 | + } |
| 2786 | + if err := validator.ValidateBlobPayload(token(t, nip98), sha256Hex("other")); err == nil || !strings.Contains(err.Error(), "payload") { |
| 2787 | + t.Fatalf("expected NIP98 hash rejection, got %v", err) |
| 2788 | + } |
| 2789 | +} |
| 2790 | + |
| 2791 | +func TestValidateBlossomPayloadBindsMirrorHash(t *testing.T) { |
| 2792 | + now := time.Unix(1_700_000_000, 0) |
| 2793 | + validator := NewValidator(func() time.Time { return now }) |
| 2794 | + hash := sha256Hex("blob") |
| 2795 | + tokenEvent := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "mirror"}, {"expiration", "1700000300"}, {"x", hash}}) |
| 2796 | + header := token(t, tokenEvent) |
| 2797 | + if err := validator.ValidateBlossomPayload(header, "mirror", hash); err != nil { |
| 2798 | + t.Fatalf("valid mirror hash: %v", err) |
| 2799 | + } |
| 2800 | + if err := validator.ValidateBlossomPayload(header, "mirror", sha256Hex("other")); err == nil { |
| 2801 | + t.Fatal("accepted mismatched mirror hash") |
| 2802 | + } |
| 2803 | +} |
| 2804 | + |
| 2805 | +func TestVerifyBlossomRequestRequiresScopeAndServer(t *testing.T) { |
| 2806 | + now := time.Unix(1_700_000_000, 0) |
| 2807 | + validator := NewValidator(func() time.Time { return now }) |
| 2808 | + hash := sha256Hex("blob") |
| 2809 | + valid := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}, {"x", hash}}) |
| 2810 | + if _, err := validator.VerifyBlossomRequest(token(t, valid), "upload", "relay.example", hash); err != nil { |
| 2811 | + t.Fatalf("valid scoped Blossom token: %v", err) |
| 2812 | + } |
| 2813 | + wrongServer := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "other.example"}, {"x", hash}}) |
| 2814 | + if _, err := validator.VerifyBlossomRequest(token(t, wrongServer), "upload", "relay.example", hash); err == nil { |
| 2815 | + t.Fatal("accepted token scoped to another server") |
| 2816 | + } |
| 2817 | + missingHash := signedEvent(t, 24242, now.Unix(), "", [][]string{{"t", "upload"}, {"expiration", "1700000300"}, {"server", "relay.example"}}) |
| 2818 | + if _, err := validator.VerifyBlossomRequest(token(t, missingHash), "upload", "relay.example", ""); err == nil { |
| 2819 | + t.Fatal("accepted upload token without x scope") |
| 2820 | + } |
| 2821 | +} |
| 2822 | + |
| 2823 | +func TestVerifyBlossomRequestAllowsUnscopedGet(t *testing.T) { |
| 2824 | + now := time.Unix(1_700_000_000, 0) |
| 2825 | + validator := NewValidator(func() time.Time { return now }) |
| 2826 | + hash := sha256Hex("blob") |
| 2827 | + e := signedEvent(t, 24242, now.Add(-24*time.Hour).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700086400"}}) |
| 2828 | + if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", hash); err != nil { |
| 2829 | + t.Fatalf("unscoped GET token rejected: %v", err) |
| 2830 | + } |
| 2831 | +} |
| 2832 | + |
| 2833 | +func TestVerifyBlossomRequestRejectsFutureCreatedAt(t *testing.T) { |
| 2834 | + now := time.Unix(1_700_000_000, 0) |
| 2835 | + validator := NewValidator(func() time.Time { return now }) |
| 2836 | + e := signedEvent(t, 24242, now.Add(time.Second).Unix(), "", [][]string{{"t", "get"}, {"expiration", "1700000300"}}) |
| 2837 | + if _, err := validator.VerifyBlossomRequest(token(t, e), "get", "relay.example", ""); err == nil || !strings.Contains(err.Error(), "future") { |
| 2838 | + t.Fatalf("future token accepted: %v", err) |
| 2839 | + } |
| 2840 | +} |
| 2841 | + |
| 2842 | +func TestChallengeManagerBindsRelayAndConsumesChallenge(t *testing.T) { |
| 2843 | + now := time.Unix(1_700_000_000, 0) |
| 2844 | + m := NewChallengeManager("wss://relay.example/tenant", func() time.Time { return now }) |
| 2845 | + challenge, err := m.Issue() |
| 2846 | + if err != nil { |
| 2847 | + t.Fatalf("issue challenge: %v", err) |
| 2848 | + } |
| 2849 | + e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example/tenant"}, {"challenge", challenge}}) |
| 2850 | + if err := m.Verify(e); err != nil { |
| 2851 | + t.Fatalf("verify challenge: %v", err) |
| 2852 | + } |
| 2853 | + if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") { |
| 2854 | + t.Fatalf("expected one-time challenge rejection, got %v", err) |
| 2855 | + } |
| 2856 | +} |
| 2857 | + |
| 2858 | +func signedEvent(t *testing.T, kind int, createdAt int64, content string, tags [][]string) nostr.Event { |
| 2859 | + t.Helper() |
| 2860 | + e := nostr.Event{CreatedAt: createdAt, Kind: kind, Content: content, Tags: tags} |
| 2861 | + if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 2862 | + t.Fatalf("sign event: %v", err) |
| 2863 | + } |
| 2864 | + return e |
| 2865 | +} |
| 2866 | + |
| 2867 | +func token(t *testing.T, e nostr.Event) string { |
| 2868 | + t.Helper() |
| 2869 | + raw, err := nostr.Canonical(e) |
| 2870 | + if err != nil { |
| 2871 | + t.Fatalf("encode event: %v", err) |
| 2872 | + } |
| 2873 | + return "Nostr " + base64.StdEncoding.EncodeToString(raw) |
| 2874 | +} |
| 2875 | + |
| 2876 | +func sha256Hex(value string) string { |
| 2877 | + sum := sha256.Sum256([]byte(value)) |
| 2878 | + return hex.EncodeToString(sum[:]) |
| 2879 | +} |
| 2880 | + |
| 2881 | +func TestGRASP08RepositoryRootKeepsClientEscaping(t *testing.T) { |
| 2882 | + now := time.Unix(1_700_000_000, 0) |
| 2883 | + validator := NewValidator(func() time.Time { return now }) |
| 2884 | + root := "https://relay.example/npub1owner/a%3Ab.git" |
| 2885 | + got, ok := GRASP08RepositoryRoot(root + "/info/refs?service=git-upload-pack") |
| 2886 | + if !ok || got != root { |
| 2887 | + t.Fatalf("root=%q ok=%v", got, ok) |
| 2888 | + } |
| 2889 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", root}, {"method", "GET"}}) |
| 2890 | + if _, err := validator.VerifyGRASP08(token(t, e), root+"/git-upload-pack"); err != nil { |
| 2891 | + t.Fatalf("verify proof for an escaped identifier: %v", err) |
| 2892 | + } |
| 2893 | +} |
| 2894 | diff --git a/protocol/auth/doc.go b/protocol/auth/doc.go |
| 2895 | new file mode 100644 |
| 2896 | index 0000000..35e4159 |
| 2897 | --- /dev/null |
| 2898 | +++ b/protocol/auth/doc.go |
| 2899 | @@ -0,0 +1,25 @@ |
| 2900 | +// Package auth verifies Nostr request proofs without deciding host access. |
| 2901 | +// It supports NIP-98 HTTP request proofs, NIP-42 relay challenges, BUD-11 |
| 2902 | +// Blossom authorization and GRASP-08 repository proofs. Successful proof |
| 2903 | +// verification identifies a signer; callers must separately authorize that |
| 2904 | +// signer and apply resource, tenant, storage and hosting policy. |
| 2905 | +// |
| 2906 | +// Construct validators and challenge managers with their constructors. A nil |
| 2907 | +// clock uses time.Now. Their replay and challenge stores are in memory and |
| 2908 | +// scoped to each instance; reuse an instance across requests that share a |
| 2909 | +// replay boundary. The stores are protected for concurrent use. They do not |
| 2910 | +// persist across restarts or coordinate across processes. |
| 2911 | +// |
| 2912 | +// NIP-98 proofs are single-use within the one-minute replay window. Their URL |
| 2913 | +// binding preserves path escaping, port and query while ignoring scheme and |
| 2914 | +// hostname case. Methods compare case-insensitively. A nonempty body requires |
| 2915 | +// the matching payload hash. NIP-42 challenges are single-use and expire after |
| 2916 | +// one minute; relay URLs match exactly. Blossom proofs are reusable until |
| 2917 | +// their expiration and cannot be issued in the future. GRASP-08 GET proofs |
| 2918 | +// are reusable for supported Smart HTTP requests beneath the signed repository |
| 2919 | +// root, within the existing one-minute timestamp skew. Unlike NIP-98, GRASP-08 |
| 2920 | +// requires an uppercase GET method and returns a uniform authentication error. |
| 2921 | +// |
| 2922 | +// Payload validation after streaming does not consume the proof again. It |
| 2923 | +// must follow request authentication and does not replace host authorization. |
| 2924 | +package auth |
| 2925 | diff --git a/protocol/auth/grasp08.go b/protocol/auth/grasp08.go |
| 2926 | new file mode 100644 |
| 2927 | index 0000000..0e48acb |
| 2928 | --- /dev/null |
| 2929 | +++ b/protocol/auth/grasp08.go |
| 2930 | @@ -0,0 +1,68 @@ |
| 2931 | +package auth |
| 2932 | + |
| 2933 | +import ( |
| 2934 | + "errors" |
| 2935 | + "net/url" |
| 2936 | + "strings" |
| 2937 | + |
| 2938 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 2939 | +) |
| 2940 | + |
| 2941 | +// ErrGRASP08Unauthorized is the detail-free GRASP-08 authentication error |
| 2942 | +// returned for every failed repository proof. |
| 2943 | +var ErrGRASP08Unauthorized = errors.New("auth-required:") |
| 2944 | + |
| 2945 | +// VerifyGRASP08 validates a reusable kind 27235 GET proof for the repository |
| 2946 | +// root named by a GRASP-08 Smart HTTP request. The proof remains valid for |
| 2947 | +// one minute across the repository's supported Git requests. |
| 2948 | +func (v *Validator) VerifyGRASP08(header, rawURL string) (nostr.Event, error) { |
| 2949 | + e, err := decodeToken(header, "GRASP-08") |
| 2950 | + if err != nil { |
| 2951 | + return nostr.Event{}, ErrGRASP08Unauthorized |
| 2952 | + } |
| 2953 | + if err := nostr.Validate(e); err != nil || e.Kind != 27235 { |
| 2954 | + return nostr.Event{}, ErrGRASP08Unauthorized |
| 2955 | + } |
| 2956 | + now := v.now() |
| 2957 | + if e.CreatedAt < now.Unix()-60 || e.CreatedAt > now.Unix()+60 { |
| 2958 | + return nostr.Event{}, ErrGRASP08Unauthorized |
| 2959 | + } |
| 2960 | + if nostr.Tag(e, "method") != "GET" { |
| 2961 | + return nostr.Event{}, ErrGRASP08Unauthorized |
| 2962 | + } |
| 2963 | + root, ok := GRASP08RepositoryRoot(rawURL) |
| 2964 | + if !ok || sameRequestURL(nostr.Tag(e, "u"), root) != nil { |
| 2965 | + return nostr.Event{}, ErrGRASP08Unauthorized |
| 2966 | + } |
| 2967 | + return e, nil |
| 2968 | +} |
| 2969 | + |
| 2970 | +// GRASP08RepositoryRoot returns the canonical repository URL for a supported |
| 2971 | +// Smart HTTP request beneath a .git path. |
| 2972 | +func GRASP08RepositoryRoot(raw string) (string, bool) { |
| 2973 | + u, err := url.Parse(raw) |
| 2974 | + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { |
| 2975 | + return "", false |
| 2976 | + } |
| 2977 | + // Keep the client's percent-encoding so the root compares equal to the |
| 2978 | + // signed u tag, which is also kept as sent. |
| 2979 | + path := u.EscapedPath() |
| 2980 | + i := strings.LastIndex(path, ".git") |
| 2981 | + if i < 0 || (i+4 < len(path) && path[i+4] != '/') { |
| 2982 | + return "", false |
| 2983 | + } |
| 2984 | + rootPath := path[:i+4] |
| 2985 | + suffix := path[i+4:] |
| 2986 | + if suffix != "" && suffix != "/info/refs" && suffix != "/git-upload-pack" && suffix != "/git-receive-pack" { |
| 2987 | + return "", false |
| 2988 | + } |
| 2989 | + if strings.HasSuffix(rootPath, "/") { |
| 2990 | + return "", false |
| 2991 | + } |
| 2992 | + unescaped, err := url.PathUnescape(rootPath) |
| 2993 | + if err != nil { |
| 2994 | + return "", false |
| 2995 | + } |
| 2996 | + u.Path, u.RawPath, u.RawQuery, u.Fragment = unescaped, rootPath, "", "" |
| 2997 | + return u.String(), true |
| 2998 | +} |
| 2999 | diff --git a/protocol/auth/replay_test.go b/protocol/auth/replay_test.go |
| 3000 | new file mode 100644 |
| 3001 | index 0000000..a8119d6 |
| 3002 | --- /dev/null |
| 3003 | +++ b/protocol/auth/replay_test.go |
| 3004 | @@ -0,0 +1,96 @@ |
| 3005 | +package auth |
| 3006 | + |
| 3007 | +import ( |
| 3008 | + "encoding/base64" |
| 3009 | + "strings" |
| 3010 | + "sync" |
| 3011 | + "sync/atomic" |
| 3012 | + "testing" |
| 3013 | + "time" |
| 3014 | + |
| 3015 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 3016 | +) |
| 3017 | + |
| 3018 | +func TestNIP98ReplayWindowBoundary(t *testing.T) { |
| 3019 | + now := time.Unix(1700000000, 0) |
| 3020 | + v := NewValidator(func() time.Time { return now }) |
| 3021 | + // A future-dated proof is still fresh after its first replay window ends. |
| 3022 | + e := signedEvent(t, 27235, now.Unix()+60, "", [][]string{{"u", "https://relay.example/api"}, {"method", "GET"}}) |
| 3023 | + header := token(t, e) |
| 3024 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api", "GET", ""); err != nil { |
| 3025 | + t.Fatal(err) |
| 3026 | + } |
| 3027 | + now = now.Add(time.Minute) |
| 3028 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api", "GET", ""); err == nil || !strings.Contains(err.Error(), "replay") { |
| 3029 | + t.Fatalf("proof must remain seen at exactly one minute: %v", err) |
| 3030 | + } |
| 3031 | + now = now.Add(time.Second) |
| 3032 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api", "GET", ""); err != nil { |
| 3033 | + t.Fatalf("expired replay entry should be pruned: %v", err) |
| 3034 | + } |
| 3035 | +} |
| 3036 | + |
| 3037 | +func TestNIP98AcceptsAllExistingBase64Forms(t *testing.T) { |
| 3038 | + now := time.Unix(1700000000, 0) |
| 3039 | + e := signedEvent(t, 27235, now.Unix(), "\u083e\u083f", [][]string{{"u", "https://relay.example/api"}, {"method", "GET"}}) |
| 3040 | + raw, err := nostr.Canonical(e) |
| 3041 | + if err != nil { |
| 3042 | + t.Fatal(err) |
| 3043 | + } |
| 3044 | + for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { |
| 3045 | + v := NewValidator(func() time.Time { return now }) |
| 3046 | + header := "nOsTr " + encoding.EncodeToString(raw) |
| 3047 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api", "GET", ""); err != nil { |
| 3048 | + t.Fatalf("base64 form rejected: %v", err) |
| 3049 | + } |
| 3050 | + } |
| 3051 | +} |
| 3052 | + |
| 3053 | +func TestNIP42ChallengeExpiresAtOneMinute(t *testing.T) { |
| 3054 | + now := time.Unix(1700000000, 0) |
| 3055 | + m := NewChallengeManager("wss://relay.example", func() time.Time { return now }) |
| 3056 | + challenge, err := m.Issue() |
| 3057 | + if err != nil { |
| 3058 | + t.Fatal(err) |
| 3059 | + } |
| 3060 | + e := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example"}, {"challenge", challenge}}) |
| 3061 | + now = now.Add(time.Minute) |
| 3062 | + if err := m.Verify(e); err == nil || !strings.Contains(err.Error(), "challenge") { |
| 3063 | + t.Fatalf("challenge must expire even while timestamp remains fresh: %v", err) |
| 3064 | + } |
| 3065 | +} |
| 3066 | + |
| 3067 | +func TestConcurrentProofConsumptionIsSingleUse(t *testing.T) { |
| 3068 | + now := time.Unix(1700000000, 0) |
| 3069 | + v := NewValidator(func() time.Time { return now }) |
| 3070 | + e := signedEvent(t, 27235, now.Unix(), "", [][]string{{"u", "https://relay.example/api"}, {"method", "GET"}}) |
| 3071 | + header := token(t, e) |
| 3072 | + m := NewChallengeManager("wss://relay.example", func() time.Time { return now }) |
| 3073 | + challenge, err := m.Issue() |
| 3074 | + if err != nil { |
| 3075 | + t.Fatal(err) |
| 3076 | + } |
| 3077 | + authEvent := signedEvent(t, 22242, now.Unix(), "", [][]string{{"relay", "wss://relay.example"}, {"challenge", challenge}}) |
| 3078 | + for name, verify := range map[string]func() error{ |
| 3079 | + "NIP-98": func() error { _, err := v.VerifyNIP98(header, "https://relay.example/api", "GET", ""); return err }, |
| 3080 | + "NIP-42": func() error { return m.Verify(authEvent) }, |
| 3081 | + } { |
| 3082 | + t.Run(name, func(t *testing.T) { |
| 3083 | + var successes atomic.Int32 |
| 3084 | + var wg sync.WaitGroup |
| 3085 | + for range 16 { |
| 3086 | + wg.Add(1) |
| 3087 | + go func() { |
| 3088 | + defer wg.Done() |
| 3089 | + if verify() == nil { |
| 3090 | + successes.Add(1) |
| 3091 | + } |
| 3092 | + }() |
| 3093 | + } |
| 3094 | + wg.Wait() |
| 3095 | + if got := successes.Load(); got != 1 { |
| 3096 | + t.Fatalf("successful consumers = %d, want 1", got) |
| 3097 | + } |
| 3098 | + }) |
| 3099 | + } |
| 3100 | +} |
| 3101 | diff --git a/protocol/contracts_test.go b/protocol/contracts_test.go |
| 3102 | new file mode 100644 |
| 3103 | index 0000000..cbeaf23 |
| 3104 | --- /dev/null |
| 3105 | +++ b/protocol/contracts_test.go |
| 3106 | @@ -0,0 +1,68 @@ |
| 3107 | +package protocol_test |
| 3108 | + |
| 3109 | +import ( |
| 3110 | + "os" |
| 3111 | + "os/exec" |
| 3112 | + "path/filepath" |
| 3113 | + "runtime" |
| 3114 | + "strconv" |
| 3115 | + "strings" |
| 3116 | + "testing" |
| 3117 | +) |
| 3118 | + |
| 3119 | +const modulePath = "github.com/FelineStateMachine/tinyrelay" |
| 3120 | + |
| 3121 | +// Run a real consumer outside tinyrelay's module so Go's internal import |
| 3122 | +// restriction, public type identity and standalone dependencies are checked. |
| 3123 | +func TestExternalNostrConsumer(t *testing.T) { |
| 3124 | + runConsumer(t, "nostr") |
| 3125 | +} |
| 3126 | + |
| 3127 | +func TestExternalAuthConsumer(t *testing.T) { |
| 3128 | + runConsumer(t, "auth") |
| 3129 | +} |
| 3130 | + |
| 3131 | +func runConsumer(t *testing.T, fixture string) { |
| 3132 | + t.Helper() |
| 3133 | + root, err := filepath.Abs("..") |
| 3134 | + if err != nil { |
| 3135 | + t.Fatal(err) |
| 3136 | + } |
| 3137 | + mod, err := os.ReadFile(filepath.Join(root, "go.mod")) |
| 3138 | + if err != nil { |
| 3139 | + t.Fatal(err) |
| 3140 | + } |
| 3141 | + var goVersion string |
| 3142 | + for _, line := range strings.Split(string(mod), "\n") { |
| 3143 | + if strings.HasPrefix(line, "go ") { |
| 3144 | + goVersion = strings.TrimPrefix(line, "go ") |
| 3145 | + } |
| 3146 | + } |
| 3147 | + if goVersion == "" { |
| 3148 | + t.Fatal("module has no Go version") |
| 3149 | + } |
| 3150 | + dir := t.TempDir() |
| 3151 | + write := func(name string, data []byte) { |
| 3152 | + t.Helper() |
| 3153 | + if err := os.WriteFile(filepath.Join(dir, name), data, 0600); err != nil { |
| 3154 | + t.Fatal(err) |
| 3155 | + } |
| 3156 | + } |
| 3157 | + write("go.mod", []byte("module example.com/protocol-consumer\n\ngo "+goVersion+"\n\nrequire "+modulePath+" v0.0.0\nreplace "+modulePath+" => "+strconv.Quote(filepath.ToSlash(root))+"\n")) |
| 3158 | + for _, file := range []struct{ src, dst string }{ |
| 3159 | + {filepath.Join(root, "go.sum"), "go.sum"}, |
| 3160 | + {filepath.Join("testdata", fixture, "consumer_test.go"), "consumer_test.go"}, |
| 3161 | + } { |
| 3162 | + data, err := os.ReadFile(file.src) |
| 3163 | + if err != nil { |
| 3164 | + t.Fatal(err) |
| 3165 | + } |
| 3166 | + write(file.dst, data) |
| 3167 | + } |
| 3168 | + cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), "test", "-mod=mod", "-count=1", "./...") |
| 3169 | + cmd.Dir = dir |
| 3170 | + cmd.Env = append(os.Environ(), "GOWORK=off") |
| 3171 | + if out, err := cmd.CombinedOutput(); err != nil { |
| 3172 | + t.Fatalf("external %s consumer: %v\n%s", fixture, err, out) |
| 3173 | + } |
| 3174 | +} |
| 3175 | diff --git a/protocol/dependencies_test.go b/protocol/dependencies_test.go |
| 3176 | new file mode 100644 |
| 3177 | index 0000000..8cdf112 |
| 3178 | --- /dev/null |
| 3179 | +++ b/protocol/dependencies_test.go |
| 3180 | @@ -0,0 +1,100 @@ |
| 3181 | +package protocol_test |
| 3182 | + |
| 3183 | +import ( |
| 3184 | + "go/ast" |
| 3185 | + "go/parser" |
| 3186 | + "go/token" |
| 3187 | + "os/exec" |
| 3188 | + "path/filepath" |
| 3189 | + "runtime" |
| 3190 | + "slices" |
| 3191 | + "strings" |
| 3192 | + "testing" |
| 3193 | +) |
| 3194 | + |
| 3195 | +func TestPublicProtocolDependencyGraph(t *testing.T) { |
| 3196 | + for _, name := range []string{"nostr", "auth"} { |
| 3197 | + t.Run(name, func(t *testing.T) { |
| 3198 | + cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), "list", "-deps", "-f", "{{.ImportPath}}", "./"+name) |
| 3199 | + out, err := cmd.CombinedOutput() |
| 3200 | + if err != nil { |
| 3201 | + t.Fatalf("dependency graph: %v\n%s", err, out) |
| 3202 | + } |
| 3203 | + found := false |
| 3204 | + for _, dependency := range strings.Fields(string(out)) { |
| 3205 | + if dependency == modulePath+"/protocol/"+name { |
| 3206 | + found = true |
| 3207 | + } |
| 3208 | + if strings.HasPrefix(dependency, modulePath+"/") && dependency != modulePath+"/protocol/nostr" && dependency != modulePath+"/protocol/auth" { |
| 3209 | + t.Errorf("protocol/%s depends on host package %s", name, dependency) |
| 3210 | + } |
| 3211 | + for _, forbidden := range []string{"modernc.org/sqlite", "github.com/prometheus/", "go.opentelemetry.io/"} { |
| 3212 | + if strings.HasPrefix(dependency, forbidden) { |
| 3213 | + t.Errorf("protocol/%s depends on host infrastructure %s", name, dependency) |
| 3214 | + } |
| 3215 | + } |
| 3216 | + } |
| 3217 | + if !found { |
| 3218 | + t.Fatal("dependency graph omitted target package") |
| 3219 | + } |
| 3220 | + }) |
| 3221 | + } |
| 3222 | +} |
| 3223 | + |
| 3224 | +// Keep this generic package's public surface deliberate: feature-specific |
| 3225 | +// kind constants, private-kind classification and room/job semantics belong |
| 3226 | +// to their owning packages, not the Nostr wire model. |
| 3227 | +func TestNostrPublicSurface(t *testing.T) { |
| 3228 | + want := strings.Fields("Event Filter Validate Parse Canonical Tag TagValues Expiration Difficulty CommittedDifficulty Sign GenerateKey PublicKey IsEphemeral IsReplaceable IsAddressable ParseFilter Matches SearchTerms Filter.MarshalJSON") |
| 3229 | + var got []string |
| 3230 | + files, err := filepath.Glob("nostr/*.go") |
| 3231 | + if err != nil { |
| 3232 | + t.Fatal(err) |
| 3233 | + } |
| 3234 | + for _, path := range files { |
| 3235 | + if strings.HasSuffix(path, "_test.go") { |
| 3236 | + continue |
| 3237 | + } |
| 3238 | + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) |
| 3239 | + if err != nil { |
| 3240 | + t.Fatal(err) |
| 3241 | + } |
| 3242 | + for _, decl := range file.Decls { |
| 3243 | + switch decl := decl.(type) { |
| 3244 | + case *ast.FuncDecl: |
| 3245 | + if !decl.Name.IsExported() { |
| 3246 | + continue |
| 3247 | + } |
| 3248 | + name := decl.Name.Name |
| 3249 | + if decl.Recv != nil { |
| 3250 | + receiver := decl.Recv.List[0].Type |
| 3251 | + if pointer, ok := receiver.(*ast.StarExpr); ok { |
| 3252 | + receiver = pointer.X |
| 3253 | + } |
| 3254 | + name = receiver.(*ast.Ident).Name + "." + name |
| 3255 | + } |
| 3256 | + got = append(got, name) |
| 3257 | + case *ast.GenDecl: |
| 3258 | + for _, spec := range decl.Specs { |
| 3259 | + switch spec := spec.(type) { |
| 3260 | + case *ast.TypeSpec: |
| 3261 | + if spec.Name.IsExported() { |
| 3262 | + got = append(got, spec.Name.Name) |
| 3263 | + } |
| 3264 | + case *ast.ValueSpec: |
| 3265 | + for _, name := range spec.Names { |
| 3266 | + if name.IsExported() { |
| 3267 | + got = append(got, name.Name) |
| 3268 | + } |
| 3269 | + } |
| 3270 | + } |
| 3271 | + } |
| 3272 | + } |
| 3273 | + } |
| 3274 | + } |
| 3275 | + slices.Sort(got) |
| 3276 | + slices.Sort(want) |
| 3277 | + if !slices.Equal(got, want) { |
| 3278 | + t.Fatalf("Nostr public API changed; review protocol scope\ngot: %v\nwant: %v", got, want) |
| 3279 | + } |
| 3280 | +} |
| 3281 | diff --git a/protocol/nostr/doc.go b/protocol/nostr/doc.go |
| 3282 | new file mode 100644 |
| 3283 | index 0000000..af2682e |
| 3284 | --- /dev/null |
| 3285 | +++ b/protocol/nostr/doc.go |
| 3286 | @@ -0,0 +1,13 @@ |
| 3287 | +// Package nostr provides signed Nostr events, JSON filters, canonical wire |
| 3288 | +// encoding, signature validation, tag helpers and standard kind ranges. |
| 3289 | +// |
| 3290 | +// Validate and Parse check the event wire shape, ID and signature. They do not |
| 3291 | +// apply kind-specific schemas or decide whether a host should accept an event. |
| 3292 | +// Matches evaluates filter constraints and content search without enforcing |
| 3293 | +// visibility. Callers must apply authorization and private-content policy. |
| 3294 | +// |
| 3295 | +// Canonical encodes a complete wire object; Sign and Validate use the NIP-01 |
| 3296 | +// canonical array internally to derive and verify the event ID. Filter search |
| 3297 | +// uses case-insensitive content terms and ignores name:value tokens. It is not |
| 3298 | +// a ranked search engine. |
| 3299 | +package nostr |
| 3300 | diff --git a/protocol/nostr/event.go b/protocol/nostr/event.go |
| 3301 | new file mode 100644 |
| 3302 | index 0000000..2a7d8ad |
| 3303 | --- /dev/null |
| 3304 | +++ b/protocol/nostr/event.go |
| 3305 | @@ -0,0 +1,279 @@ |
| 3306 | +package nostr |
| 3307 | + |
| 3308 | +import ( |
| 3309 | + "bytes" |
| 3310 | + "crypto/rand" |
| 3311 | + "crypto/sha256" |
| 3312 | + "encoding/hex" |
| 3313 | + "encoding/json" |
| 3314 | + "errors" |
| 3315 | + "fmt" |
| 3316 | + "strconv" |
| 3317 | + "strings" |
| 3318 | + |
| 3319 | + "fiatjaf.com/nostr" |
| 3320 | + "fiatjaf.com/nostr/nip13" |
| 3321 | + "github.com/btcsuite/btcd/btcec/v2" |
| 3322 | + "github.com/btcsuite/btcd/btcec/v2/schnorr" |
| 3323 | +) |
| 3324 | + |
| 3325 | +type Event struct { |
| 3326 | + ID string `json:"id"` |
| 3327 | + PubKey string `json:"pubkey"` |
| 3328 | + CreatedAt int64 `json:"created_at"` |
| 3329 | + Kind int `json:"kind"` |
| 3330 | + Tags [][]string `json:"tags"` |
| 3331 | + Content string `json:"content"` |
| 3332 | + Sig string `json:"sig"` |
| 3333 | +} |
| 3334 | + |
| 3335 | +// Validate checks the wire shape, canonical ID and Schnorr signature of e. |
| 3336 | +// Kind-specific checks are applied by the protocol gate that owns the kind. |
| 3337 | +func Validate(e Event) error { |
| 3338 | + if !hex64(e.ID) { |
| 3339 | + return errors.New("invalid: bad id") |
| 3340 | + } |
| 3341 | + if !hex64(e.PubKey) { |
| 3342 | + return errors.New("invalid: bad pubkey") |
| 3343 | + } |
| 3344 | + if len(e.Sig) != 128 || !isHex(e.Sig) { |
| 3345 | + return errors.New("invalid: bad sig") |
| 3346 | + } |
| 3347 | + if e.Kind < 0 || e.Kind > 65535 { |
| 3348 | + return errors.New("invalid: kind out of range") |
| 3349 | + } |
| 3350 | + if e.CreatedAt < 0 { |
| 3351 | + return errors.New("invalid: bad created_at") |
| 3352 | + } |
| 3353 | + if e.Tags == nil { |
| 3354 | + return errors.New("invalid: tags must be a list") |
| 3355 | + } |
| 3356 | + for _, tag := range e.Tags { |
| 3357 | + if len(tag) == 0 { |
| 3358 | + return errors.New("invalid: bad tag") |
| 3359 | + } |
| 3360 | + } |
| 3361 | + serialized, err := serializeForID(e) |
| 3362 | + if err != nil { |
| 3363 | + return errors.New("invalid: cannot serialize event") |
| 3364 | + } |
| 3365 | + sum := sha256.Sum256(serialized) |
| 3366 | + if e.ID != hex.EncodeToString(sum[:]) || !verifySignature(e, sum[:]) { |
| 3367 | + return errors.New("invalid: id or signature does not match content") |
| 3368 | + } |
| 3369 | + return nil |
| 3370 | +} |
| 3371 | + |
| 3372 | +// Parse decodes and validates one complete JSON event object. |
| 3373 | +func Parse(raw []byte) (Event, error) { |
| 3374 | + if err := requireFields(raw); err != nil { |
| 3375 | + return Event{}, err |
| 3376 | + } |
| 3377 | + var e Event |
| 3378 | + if err := json.Unmarshal(raw, &e); err != nil { |
| 3379 | + return Event{}, fmt.Errorf("invalid event JSON: %w", err) |
| 3380 | + } |
| 3381 | + if err := Validate(e); err != nil { |
| 3382 | + return Event{}, err |
| 3383 | + } |
| 3384 | + return e, nil |
| 3385 | +} |
| 3386 | + |
| 3387 | +// Canonical encodes e as the JSON representation used on relay boundaries. |
| 3388 | +func Canonical(e Event) ([]byte, error) { |
| 3389 | + var out bytes.Buffer |
| 3390 | + encoder := json.NewEncoder(&out) |
| 3391 | + encoder.SetEscapeHTML(false) |
| 3392 | + if err := encoder.Encode(e); err != nil { |
| 3393 | + return nil, err |
| 3394 | + } |
| 3395 | + return bytes.TrimSuffix(out.Bytes(), []byte{'\n'}), nil |
| 3396 | +} |
| 3397 | + |
| 3398 | +// Tag returns the first value for name, or an empty string when the tag is |
| 3399 | +// absent or has no value. |
| 3400 | +func Tag(e Event, name string) string { |
| 3401 | + for _, tag := range e.Tags { |
| 3402 | + if len(tag) > 0 && tag[0] == name && len(tag) > 1 { |
| 3403 | + return tag[1] |
| 3404 | + } |
| 3405 | + } |
| 3406 | + return "" |
| 3407 | +} |
| 3408 | + |
| 3409 | +// TagValues returns every first value belonging to tags named name. |
| 3410 | +func TagValues(e Event, name string) []string { |
| 3411 | + values := make([]string, 0) |
| 3412 | + for _, tag := range e.Tags { |
| 3413 | + if len(tag) > 1 && tag[0] == name { |
| 3414 | + values = append(values, tag[1]) |
| 3415 | + } |
| 3416 | + } |
| 3417 | + return values |
| 3418 | +} |
| 3419 | + |
| 3420 | +// Expiration returns the positive Unix timestamp in the expiration tag. |
| 3421 | +// Malformed, missing and nonpositive values return zero. |
| 3422 | +func Expiration(e Event) int64 { |
| 3423 | + v, err := strconv.ParseInt(Tag(e, "expiration"), 10, 64) |
| 3424 | + if err != nil || v <= 0 { |
| 3425 | + return 0 |
| 3426 | + } |
| 3427 | + return v |
| 3428 | +} |
| 3429 | + |
| 3430 | +// Difficulty returns the NIP-13 proof-of-work difficulty encoded by id. |
| 3431 | +func Difficulty(id string) int { |
| 3432 | + parsed, err := nostr.IDFromHex(id) |
| 3433 | + if err != nil { |
| 3434 | + return 0 |
| 3435 | + } |
| 3436 | + return nip13.Difficulty(parsed) |
| 3437 | +} |
| 3438 | + |
| 3439 | +// CommittedDifficulty returns the target in a nonce tag's third field. |
| 3440 | +func CommittedDifficulty(e Event) int { |
| 3441 | + for _, tag := range e.Tags { |
| 3442 | + if len(tag) > 2 && tag[0] == "nonce" { |
| 3443 | + n, _ := strconv.Atoi(tag[2]) |
| 3444 | + return n |
| 3445 | + } |
| 3446 | + } |
| 3447 | + return 0 |
| 3448 | +} |
| 3449 | + |
| 3450 | +// Sign derives the public key, ID and Schnorr signature for e from secretHex. |
| 3451 | +// A nil tag slice is encoded as an empty tag list before signing. |
| 3452 | +func Sign(e *Event, secretHex string) error { |
| 3453 | + secret, err := decodeSecret(secretHex) |
| 3454 | + if err != nil { |
| 3455 | + return err |
| 3456 | + } |
| 3457 | + if e.Tags == nil { |
| 3458 | + e.Tags = [][]string{} |
| 3459 | + } |
| 3460 | + key, pubkey := btcec.PrivKeyFromBytes(secret[:]) |
| 3461 | + e.PubKey = hex.EncodeToString(schnorr.SerializePubKey(pubkey)) |
| 3462 | + serialized, err := serializeForID(*e) |
| 3463 | + if err != nil { |
| 3464 | + return fmt.Errorf("serialize event: %w", err) |
| 3465 | + } |
| 3466 | + sum := sha256.Sum256(serialized) |
| 3467 | + signature, err := schnorr.Sign(key, sum[:]) |
| 3468 | + if err != nil { |
| 3469 | + return fmt.Errorf("sign event: %w", err) |
| 3470 | + } |
| 3471 | + e.ID = hex.EncodeToString(sum[:]) |
| 3472 | + e.Sig = hex.EncodeToString(signature.Serialize()) |
| 3473 | + return nil |
| 3474 | +} |
| 3475 | + |
| 3476 | +// GenerateKey creates a random nonzero 32-byte private key in hexadecimal. |
| 3477 | +func GenerateKey() (string, error) { |
| 3478 | + for { |
| 3479 | + key := make([]byte, 32) |
| 3480 | + if _, err := rand.Read(key); err != nil { |
| 3481 | + return "", fmt.Errorf("generate private key: %w", err) |
| 3482 | + } |
| 3483 | + if !allZero(key) { |
| 3484 | + return hex.EncodeToString(key), nil |
| 3485 | + } |
| 3486 | + } |
| 3487 | +} |
| 3488 | + |
| 3489 | +// PublicKey derives the x-only Schnorr public key for secretHex. |
| 3490 | +func PublicKey(secretHex string) (string, error) { |
| 3491 | + secret, err := decodeSecret(secretHex) |
| 3492 | + if err != nil { |
| 3493 | + return "", err |
| 3494 | + } |
| 3495 | + _, pubkey := btcec.PrivKeyFromBytes(secret[:]) |
| 3496 | + return hex.EncodeToString(schnorr.SerializePubKey(pubkey)), nil |
| 3497 | +} |
| 3498 | + |
| 3499 | +// IsEphemeral reports whether k is in Nostr's ephemeral kind range. |
| 3500 | +func IsEphemeral(k int) bool { return k >= 20000 && k < 30000 } |
| 3501 | + |
| 3502 | +// IsReplaceable reports whether k is a replaceable event kind. |
| 3503 | +func IsReplaceable(k int) bool { return k == 0 || k == 3 || k >= 10000 && k < 20000 } |
| 3504 | + |
| 3505 | +// IsAddressable reports whether k is an addressable event kind. |
| 3506 | +func IsAddressable(k int) bool { return k >= 30000 && k < 40000 } |
| 3507 | + |
| 3508 | +func hex64(s string) bool { return len(s) == 64 && isHex(s) } |
| 3509 | +func isHex(s string) bool { |
| 3510 | + for _, char := range s { |
| 3511 | + if char >= '0' && char <= '9' || char >= 'a' && char <= 'f' { |
| 3512 | + continue |
| 3513 | + } |
| 3514 | + return false |
| 3515 | + } |
| 3516 | + return true |
| 3517 | +} |
| 3518 | + |
| 3519 | +func serializeForID(e Event) ([]byte, error) { |
| 3520 | + value := []any{0, e.PubKey, e.CreatedAt, e.Kind, e.Tags, e.Content} |
| 3521 | + var out bytes.Buffer |
| 3522 | + encoder := json.NewEncoder(&out) |
| 3523 | + encoder.SetEscapeHTML(false) |
| 3524 | + if err := encoder.Encode(value); err != nil { |
| 3525 | + return nil, err |
| 3526 | + } |
| 3527 | + serialized := bytes.TrimSuffix(out.Bytes(), []byte{'\n'}) |
| 3528 | + serialized = bytes.ReplaceAll(serialized, []byte(`\u2028`), []byte("\u2028")) |
| 3529 | + serialized = bytes.ReplaceAll(serialized, []byte(`\u2029`), []byte("\u2029")) |
| 3530 | + return serialized, nil |
| 3531 | +} |
| 3532 | + |
| 3533 | +func verifySignature(e Event, hash []byte) bool { |
| 3534 | + pubkey, err := hex.DecodeString(e.PubKey) |
| 3535 | + if err != nil { |
| 3536 | + return false |
| 3537 | + } |
| 3538 | + key, err := schnorr.ParsePubKey(pubkey) |
| 3539 | + if err != nil { |
| 3540 | + return false |
| 3541 | + } |
| 3542 | + signature, err := hex.DecodeString(e.Sig) |
| 3543 | + if err != nil { |
| 3544 | + return false |
| 3545 | + } |
| 3546 | + sig, err := schnorr.ParseSignature(signature) |
| 3547 | + return err == nil && sig.Verify(hash, key) |
| 3548 | +} |
| 3549 | + |
| 3550 | +func decodeSecret(value string) ([32]byte, error) { |
| 3551 | + var secret [32]byte |
| 3552 | + if len(value) != 64 { |
| 3553 | + return secret, errors.New("invalid secret key: expected 64 hex characters") |
| 3554 | + } |
| 3555 | + if _, err := hex.Decode(secret[:], []byte(strings.ToLower(value))); err != nil { |
| 3556 | + return secret, fmt.Errorf("invalid secret key: %w", err) |
| 3557 | + } |
| 3558 | + if allZero(secret[:]) { |
| 3559 | + return secret, errors.New("invalid secret key") |
| 3560 | + } |
| 3561 | + return secret, nil |
| 3562 | +} |
| 3563 | + |
| 3564 | +func allZero(value []byte) bool { |
| 3565 | + for _, item := range value { |
| 3566 | + if item != 0 { |
| 3567 | + return false |
| 3568 | + } |
| 3569 | + } |
| 3570 | + return true |
| 3571 | +} |
| 3572 | + |
| 3573 | +func requireFields(raw []byte) error { |
| 3574 | + var fields map[string]json.RawMessage |
| 3575 | + if err := json.Unmarshal(raw, &fields); err != nil { |
| 3576 | + return fmt.Errorf("invalid event JSON: %w", err) |
| 3577 | + } |
| 3578 | + for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} { |
| 3579 | + if _, ok := fields[field]; !ok { |
| 3580 | + return fmt.Errorf("invalid: missing %s", field) |
| 3581 | + } |
| 3582 | + } |
| 3583 | + return nil |
| 3584 | +} |
| 3585 | diff --git a/protocol/nostr/event_test.go b/protocol/nostr/event_test.go |
| 3586 | new file mode 100644 |
| 3587 | index 0000000..09bceac |
| 3588 | --- /dev/null |
| 3589 | +++ b/protocol/nostr/event_test.go |
| 3590 | @@ -0,0 +1,127 @@ |
| 3591 | +package nostr |
| 3592 | + |
| 3593 | +import ( |
| 3594 | + "bytes" |
| 3595 | + "crypto/sha256" |
| 3596 | + "encoding/hex" |
| 3597 | + "encoding/json" |
| 3598 | + "strings" |
| 3599 | + "testing" |
| 3600 | +) |
| 3601 | + |
| 3602 | +func TestSignParseValidateCanonical(t *testing.T) { |
| 3603 | + secret := strings.Repeat("0", 63) + "1" |
| 3604 | + e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{{"t", "go"}}, Content: "hello"} |
| 3605 | + if err := Sign(&e, secret); err != nil { |
| 3606 | + t.Fatalf("sign: %v", err) |
| 3607 | + } |
| 3608 | + if err := Validate(e); err != nil { |
| 3609 | + t.Fatalf("validate: %v", err) |
| 3610 | + } |
| 3611 | + got, err := Parse(mustJSON(t, e)) |
| 3612 | + if err != nil { |
| 3613 | + t.Fatalf("parse: %v", err) |
| 3614 | + } |
| 3615 | + canonical, err := Canonical(e) |
| 3616 | + if err != nil { |
| 3617 | + t.Fatalf("canonical: %v", err) |
| 3618 | + } |
| 3619 | + if string(canonical) != string(mustJSON(t, got)) { |
| 3620 | + t.Fatalf("canonical changed event: %s", canonical) |
| 3621 | + } |
| 3622 | +} |
| 3623 | + |
| 3624 | +func TestFilterTagJSONAndEmptyLists(t *testing.T) { |
| 3625 | + f, err := ParseFilter([]byte(`{"#t":["go"],"kinds":[],"limit":0}`)) |
| 3626 | + if err != nil { |
| 3627 | + t.Fatalf("parse filter: %v", err) |
| 3628 | + } |
| 3629 | + if len(f.Tags["t"]) != 1 || len(f.Kinds) != 0 || f.Kinds == nil { |
| 3630 | + t.Fatalf("filter fields lost: %#v", f) |
| 3631 | + } |
| 3632 | + e := Event{ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), Kind: 1, Tags: [][]string{{"t", "go"}}} |
| 3633 | + if Matches(f, e) { |
| 3634 | + t.Fatal("empty kinds must match nothing") |
| 3635 | + } |
| 3636 | + f.Kinds = nil |
| 3637 | + if !Matches(f, e) { |
| 3638 | + t.Fatal("tag filter should match") |
| 3639 | + } |
| 3640 | + raw, err := json.Marshal(f) |
| 3641 | + if err != nil || !strings.Contains(string(raw), `"#t"`) { |
| 3642 | + t.Fatalf("marshal tags: %s (%v)", raw, err) |
| 3643 | + } |
| 3644 | +} |
| 3645 | + |
| 3646 | +func TestSearchTermsAndKinds(t *testing.T) { |
| 3647 | + terms := SearchTerms("hello lang:en world:") |
| 3648 | + if len(terms) != 1 || terms[0] != "hello" { |
| 3649 | + t.Fatalf("terms: %#v", terms) |
| 3650 | + } |
| 3651 | + if !IsEphemeral(20001) || IsEphemeral(30000) || !IsReplaceable(10001) || !IsAddressable(30001) { |
| 3652 | + t.Fatal("kind classification mismatch") |
| 3653 | + } |
| 3654 | +} |
| 3655 | + |
| 3656 | +func TestNIP01CanonicalEscapingMatchesJSONStringify(t *testing.T) { |
| 3657 | + e := Event{CreatedAt: 1700000000, Kind: 1, Tags: [][]string{{"x", "a\b\f\u2028\u2029é"}}, Content: "quote \" slash \\ newline\n <tag>"} |
| 3658 | + got, err := serializeForID(e) |
| 3659 | + if err != nil { |
| 3660 | + t.Fatal(err) |
| 3661 | + } |
| 3662 | + if bytes.Contains(got, []byte(`\u003c`)) || !bytes.Contains(got, []byte(`\b`)) || !bytes.Contains(got, []byte(`\f`)) { |
| 3663 | + t.Fatalf("unexpected HTML/control escaping: %s", got) |
| 3664 | + } |
| 3665 | + if strings.Contains(string(got), `\u2028`) || strings.Contains(string(got), `\u2029`) { |
| 3666 | + t.Fatalf("U+2028/U+2029 must remain literal like JSON.stringify: %s", got) |
| 3667 | + } |
| 3668 | +} |
| 3669 | + |
| 3670 | +func TestParseRequiresEveryWireField(t *testing.T) { |
| 3671 | + e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: ""} |
| 3672 | + if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 3673 | + t.Fatal(err) |
| 3674 | + } |
| 3675 | + raw, err := json.Marshal(e) |
| 3676 | + if err != nil { |
| 3677 | + t.Fatal(err) |
| 3678 | + } |
| 3679 | + for _, field := range []string{"id", "pubkey", "created_at", "kind", "tags", "content", "sig"} { |
| 3680 | + var values map[string]json.RawMessage |
| 3681 | + if err := json.Unmarshal(raw, &values); err != nil { |
| 3682 | + t.Fatal(err) |
| 3683 | + } |
| 3684 | + delete(values, field) |
| 3685 | + missing, err := json.Marshal(values) |
| 3686 | + if err != nil { |
| 3687 | + t.Fatal(err) |
| 3688 | + } |
| 3689 | + if _, err := Parse(missing); err == nil { |
| 3690 | + t.Errorf("missing %s accepted", field) |
| 3691 | + } |
| 3692 | + } |
| 3693 | +} |
| 3694 | + |
| 3695 | +func TestNIP01IDIsSHA256OfCanonicalArray(t *testing.T) { |
| 3696 | + e := Event{CreatedAt: 1, Kind: 1, Tags: [][]string{}, Content: "hello"} |
| 3697 | + if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 3698 | + t.Fatal(err) |
| 3699 | + } |
| 3700 | + serialized, err := serializeForID(e) |
| 3701 | + if err != nil { |
| 3702 | + t.Fatal(err) |
| 3703 | + } |
| 3704 | + sum := sha256.Sum256(serialized) |
| 3705 | + if e.ID != hex.EncodeToString(sum[:]) { |
| 3706 | + t.Fatalf("id %s does not hash canonical array %x", e.ID, sum) |
| 3707 | + } |
| 3708 | +} |
| 3709 | + |
| 3710 | +func mustJSON(t *testing.T, value any) []byte { |
| 3711 | + t.Helper() |
| 3712 | + b, err := json.Marshal(value) |
| 3713 | + if err != nil { |
| 3714 | + t.Fatalf("marshal: %v", err) |
| 3715 | + } |
| 3716 | + return b |
| 3717 | +} |
| 3718 | diff --git a/protocol/nostr/filter.go b/protocol/nostr/filter.go |
| 3719 | new file mode 100644 |
| 3720 | index 0000000..70fc920 |
| 3721 | --- /dev/null |
| 3722 | +++ b/protocol/nostr/filter.go |
| 3723 | @@ -0,0 +1,192 @@ |
| 3724 | +package nostr |
| 3725 | + |
| 3726 | +import ( |
| 3727 | + "encoding/json" |
| 3728 | + "fmt" |
| 3729 | + "strings" |
| 3730 | +) |
| 3731 | + |
| 3732 | +// Filter describes the event fields and tags a subscription or query accepts. |
| 3733 | +// Fields combine with AND; values within a list are alternatives. Nil IDs, |
| 3734 | +// Authors and Kinds leave that field unrestricted, while an empty non-nil list |
| 3735 | +// matches nothing. Tags uses names without the wire prefix #. Since and Until |
| 3736 | +// are inclusive Unix timestamps. Limit controls a historical result batch; |
| 3737 | +// Matches evaluates each event independently of that limit. |
| 3738 | +type Filter struct { |
| 3739 | + IDs []string `json:"ids,omitempty"` |
| 3740 | + Authors []string `json:"authors,omitempty"` |
| 3741 | + Kinds []int `json:"kinds,omitempty"` |
| 3742 | + Tags map[string][]string `json:"-"` |
| 3743 | + Since *int64 `json:"since,omitempty"` |
| 3744 | + Until *int64 `json:"until,omitempty"` |
| 3745 | + Limit *int `json:"limit,omitempty"` |
| 3746 | + Search string `json:"search,omitempty"` |
| 3747 | +} |
| 3748 | + |
| 3749 | +// ParseFilter decodes supported Nostr filter fields, including #tag keys, and |
| 3750 | +// checks their JSON types. |
| 3751 | +func ParseFilter(raw []byte) (Filter, error) { |
| 3752 | + var values map[string]json.RawMessage |
| 3753 | + if err := json.Unmarshal(raw, &values); err != nil { |
| 3754 | + return Filter{}, errorsFilter("filter must be an object") |
| 3755 | + } |
| 3756 | + f := Filter{Tags: make(map[string][]string)} |
| 3757 | + for key, value := range values { |
| 3758 | + switch { |
| 3759 | + case key == "ids", key == "authors": |
| 3760 | + if !isJSONArray(value) { |
| 3761 | + return Filter{}, errorsFilter(key + " must be a list of strings") |
| 3762 | + } |
| 3763 | + var out []string |
| 3764 | + if err := json.Unmarshal(value, &out); err != nil { |
| 3765 | + return Filter{}, errorsFilter(key + " must be a list of strings") |
| 3766 | + } |
| 3767 | + if key == "ids" { |
| 3768 | + f.IDs = out |
| 3769 | + } else { |
| 3770 | + f.Authors = out |
| 3771 | + } |
| 3772 | + case key == "kinds": |
| 3773 | + if !isJSONArray(value) { |
| 3774 | + return Filter{}, errorsFilter("kinds must be a list of integers") |
| 3775 | + } |
| 3776 | + if err := json.Unmarshal(value, &f.Kinds); err != nil { |
| 3777 | + return Filter{}, errorsFilter("kinds must be a list of integers") |
| 3778 | + } |
| 3779 | + case key == "since", key == "until": |
| 3780 | + var n int64 |
| 3781 | + if err := json.Unmarshal(value, &n); err != nil { |
| 3782 | + return Filter{}, errorsFilter(key + " must be an integer") |
| 3783 | + } |
| 3784 | + if key == "since" { |
| 3785 | + f.Since = &n |
| 3786 | + } else { |
| 3787 | + f.Until = &n |
| 3788 | + } |
| 3789 | + case key == "limit": |
| 3790 | + var n int |
| 3791 | + if err := json.Unmarshal(value, &n); err != nil { |
| 3792 | + return Filter{}, errorsFilter("limit must be an integer") |
| 3793 | + } |
| 3794 | + f.Limit = &n |
| 3795 | + case key == "search": |
| 3796 | + if err := json.Unmarshal(value, &f.Search); err != nil { |
| 3797 | + return Filter{}, errorsFilter("search must be a string") |
| 3798 | + } |
| 3799 | + case len(key) == 2 && key[0] == '#': |
| 3800 | + if !isJSONArray(value) { |
| 3801 | + return Filter{}, errorsFilter(key + " must be a list of strings") |
| 3802 | + } |
| 3803 | + var out []string |
| 3804 | + if err := json.Unmarshal(value, &out); err != nil { |
| 3805 | + return Filter{}, errorsFilter(key + " must be a list of strings") |
| 3806 | + } |
| 3807 | + f.Tags[key[1:]] = out |
| 3808 | + } |
| 3809 | + } |
| 3810 | + return f, nil |
| 3811 | +} |
| 3812 | + |
| 3813 | +// MarshalJSON encodes tag constraints with their Nostr #name wire keys. |
| 3814 | +func (f Filter) MarshalJSON() ([]byte, error) { |
| 3815 | + values := make(map[string]json.RawMessage, 7+len(f.Tags)) |
| 3816 | + if f.IDs != nil { |
| 3817 | + values["ids"] = mustRaw(f.IDs) |
| 3818 | + } |
| 3819 | + if f.Authors != nil { |
| 3820 | + values["authors"] = mustRaw(f.Authors) |
| 3821 | + } |
| 3822 | + if f.Kinds != nil { |
| 3823 | + values["kinds"] = mustRaw(f.Kinds) |
| 3824 | + } |
| 3825 | + if f.Since != nil { |
| 3826 | + values["since"] = mustRaw(f.Since) |
| 3827 | + } |
| 3828 | + if f.Until != nil { |
| 3829 | + values["until"] = mustRaw(f.Until) |
| 3830 | + } |
| 3831 | + if f.Limit != nil { |
| 3832 | + values["limit"] = mustRaw(f.Limit) |
| 3833 | + } |
| 3834 | + if f.Search != "" { |
| 3835 | + values["search"] = mustRaw(f.Search) |
| 3836 | + } |
| 3837 | + for key, tags := range f.Tags { |
| 3838 | + values["#"+key] = mustRaw(tags) |
| 3839 | + } |
| 3840 | + return json.Marshal(values) |
| 3841 | +} |
| 3842 | + |
| 3843 | +// Matches reports whether e satisfies every populated constraint in f. |
| 3844 | +// Search terms are matched against content without classifying event kinds. |
| 3845 | +// Callers must enforce visibility and authorization before returning matches. |
| 3846 | +func Matches(f Filter, e Event) bool { |
| 3847 | + if f.IDs != nil && !contains(f.IDs, e.ID) || f.Authors != nil && !contains(f.Authors, e.PubKey) || f.Kinds != nil && !containsInt(f.Kinds, e.Kind) { |
| 3848 | + return false |
| 3849 | + } |
| 3850 | + if f.Since != nil && e.CreatedAt < *f.Since || f.Until != nil && e.CreatedAt > *f.Until { |
| 3851 | + return false |
| 3852 | + } |
| 3853 | + for name, values := range f.Tags { |
| 3854 | + if !tagMatch(e, name, values) { |
| 3855 | + return false |
| 3856 | + } |
| 3857 | + } |
| 3858 | + terms := SearchTerms(f.Search) |
| 3859 | + if len(terms) == 0 { |
| 3860 | + return true |
| 3861 | + } |
| 3862 | + |
| 3863 | + content := strings.ToLower(e.Content) |
| 3864 | + for _, term := range terms { |
| 3865 | + if !strings.Contains(content, strings.ToLower(term)) { |
| 3866 | + return false |
| 3867 | + } |
| 3868 | + } |
| 3869 | + return true |
| 3870 | +} |
| 3871 | + |
| 3872 | +// SearchTerms splits a search query into terms and drops protocol-style |
| 3873 | +// name:value tokens. |
| 3874 | +func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) } |
| 3875 | + |
| 3876 | +func slicesFilter(words []string) []string { |
| 3877 | + out := make([]string, 0, len(words)) |
| 3878 | + for _, word := range words { |
| 3879 | + if !strings.Contains(word, ":") { |
| 3880 | + out = append(out, word) |
| 3881 | + } |
| 3882 | + } |
| 3883 | + return out |
| 3884 | +} |
| 3885 | +func contains(values []string, want string) bool { |
| 3886 | + for _, value := range values { |
| 3887 | + if value == want { |
| 3888 | + return true |
| 3889 | + } |
| 3890 | + } |
| 3891 | + return false |
| 3892 | +} |
| 3893 | +func containsInt(values []int, want int) bool { |
| 3894 | + for _, value := range values { |
| 3895 | + if value == want { |
| 3896 | + return true |
| 3897 | + } |
| 3898 | + } |
| 3899 | + return false |
| 3900 | +} |
| 3901 | +func tagMatch(e Event, name string, values []string) bool { |
| 3902 | + for _, tag := range e.Tags { |
| 3903 | + if len(tag) > 1 && tag[0] == name && contains(values, tag[1]) { |
| 3904 | + return true |
| 3905 | + } |
| 3906 | + } |
| 3907 | + return false |
| 3908 | +} |
| 3909 | +func mustRaw(value any) json.RawMessage { data, _ := json.Marshal(value); return data } |
| 3910 | +func errorsFilter(message string) error { return fmt.Errorf("%s", message) } |
| 3911 | + |
| 3912 | +func isJSONArray(value json.RawMessage) bool { |
| 3913 | + trimmed := strings.TrimSpace(string(value)) |
| 3914 | + return len(trimmed) > 0 && trimmed[0] == '[' |
| 3915 | +} |
| 3916 | diff --git a/protocol/nostr/sign_empty_tags_test.go b/protocol/nostr/sign_empty_tags_test.go |
| 3917 | new file mode 100644 |
| 3918 | index 0000000..9670df5 |
| 3919 | --- /dev/null |
| 3920 | +++ b/protocol/nostr/sign_empty_tags_test.go |
| 3921 | @@ -0,0 +1,24 @@ |
| 3922 | +package nostr |
| 3923 | + |
| 3924 | +import ( |
| 3925 | + "encoding/json" |
| 3926 | + "strings" |
| 3927 | + "testing" |
| 3928 | +) |
| 3929 | + |
| 3930 | +func TestSignEmptyTagsProducesValidWireEvent(t *testing.T) { |
| 3931 | + e := Event{Kind: 0, CreatedAt: 1, Content: "{}"} |
| 3932 | + if err := Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 3933 | + t.Fatal(err) |
| 3934 | + } |
| 3935 | + raw, err := json.Marshal(e) |
| 3936 | + if err != nil { |
| 3937 | + t.Fatal(err) |
| 3938 | + } |
| 3939 | + if _, err := Parse(raw); err != nil { |
| 3940 | + t.Fatalf("signed event rejected: %s: %v", raw, err) |
| 3941 | + } |
| 3942 | + if !strings.Contains(string(raw), `"tags":[]`) { |
| 3943 | + t.Fatalf("tags must be an array: %s", raw) |
| 3944 | + } |
| 3945 | +} |
| 3946 | diff --git a/protocol/testdata/auth/consumer_test.go b/protocol/testdata/auth/consumer_test.go |
| 3947 | new file mode 100644 |
| 3948 | index 0000000..22ba3fe |
| 3949 | --- /dev/null |
| 3950 | +++ b/protocol/testdata/auth/consumer_test.go |
| 3951 | @@ -0,0 +1,77 @@ |
| 3952 | +package consumer_test |
| 3953 | + |
| 3954 | +import ( |
| 3955 | + "encoding/base64" |
| 3956 | + "errors" |
| 3957 | + "strings" |
| 3958 | + "testing" |
| 3959 | + "time" |
| 3960 | + |
| 3961 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 3962 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 3963 | +) |
| 3964 | + |
| 3965 | +func TestPublicProofVerifierReplayContracts(t *testing.T) { |
| 3966 | + now := time.Unix(1700000000, 0) |
| 3967 | + v := auth.NewValidator(func() time.Time { return now }) |
| 3968 | + sign := func(kind int, tags [][]string) (nostr.Event, string) { |
| 3969 | + t.Helper() |
| 3970 | + e := nostr.Event{Kind: kind, CreatedAt: now.Unix(), Tags: tags} |
| 3971 | + if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 3972 | + t.Fatal(err) |
| 3973 | + } |
| 3974 | + raw, err := nostr.Canonical(e) |
| 3975 | + if err != nil { |
| 3976 | + t.Fatal(err) |
| 3977 | + } |
| 3978 | + return e, "Nostr " + base64.StdEncoding.EncodeToString(raw) |
| 3979 | + } |
| 3980 | + _, header := sign(27235, [][]string{{"u", "https://relay.example/api?a=1"}, {"method", "GET"}}) |
| 3981 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api?a=2", "GET", ""); err == nil { |
| 3982 | + t.Fatal("query mismatch accepted") |
| 3983 | + } |
| 3984 | + var proof nostr.Event |
| 3985 | + var err error |
| 3986 | + proof, err = v.VerifyNIP98(header, "https://RELAY.example/api?a=1", "get", "") |
| 3987 | + if err != nil || proof.PubKey == "" { |
| 3988 | + t.Fatalf("NIP-98 proof: %v", err) |
| 3989 | + } |
| 3990 | + if _, err := v.VerifyNIP98(header, "https://relay.example/api?a=1", "GET", ""); err == nil || !strings.Contains(err.Error(), "replay") { |
| 3991 | + t.Fatalf("NIP-98 replay: %v", err) |
| 3992 | + } |
| 3993 | + if _, err := v.VerifyNIP98("", "https://relay.example/api", "GET", ""); !errors.Is(err, auth.ErrMissingAuthorization) { |
| 3994 | + t.Fatalf("missing header: %v", err) |
| 3995 | + } |
| 3996 | + |
| 3997 | + _, header = sign(24242, [][]string{{"t", "get"}, {"expiration", "1700000300"}}) |
| 3998 | + if !auth.IsBlossomAuthorization(header) { |
| 3999 | + t.Fatal("Blossom classification") |
| 4000 | + } |
| 4001 | + for range 2 { |
| 4002 | + if _, err := v.VerifyBlossomRequest(header, "get", "relay.example", ""); err != nil { |
| 4003 | + t.Fatalf("reusable Blossom: %v", err) |
| 4004 | + } |
| 4005 | + } |
| 4006 | + root := "https://relay.example/owner/repo.git" |
| 4007 | + _, header = sign(27235, [][]string{{"u", root}, {"method", "GET"}}) |
| 4008 | + for _, suffix := range []string{"/info/refs?service=git-upload-pack", "/git-upload-pack"} { |
| 4009 | + if _, err := v.VerifyGRASP08(header, root+suffix); err != nil { |
| 4010 | + t.Fatalf("reusable GRASP-08: %v", err) |
| 4011 | + } |
| 4012 | + } |
| 4013 | + if _, err := v.VerifyGRASP08(header, root+"/objects"); err != auth.ErrGRASP08Unauthorized { |
| 4014 | + t.Fatalf("GRASP-08 error identity: %v", err) |
| 4015 | + } |
| 4016 | + m := auth.NewChallengeManager("wss://relay.example", func() time.Time { return now }) |
| 4017 | + challenge, err := m.Issue() |
| 4018 | + if err != nil { |
| 4019 | + t.Fatal(err) |
| 4020 | + } |
| 4021 | + e, _ := sign(22242, [][]string{{"relay", "wss://relay.example"}, {"challenge", challenge}}) |
| 4022 | + if err := m.Verify(e); err != nil { |
| 4023 | + t.Fatal(err) |
| 4024 | + } |
| 4025 | + if err := m.Verify(e); err == nil { |
| 4026 | + t.Fatal("NIP-42 challenge reused") |
| 4027 | + } |
| 4028 | +} |
| 4029 | diff --git a/protocol/testdata/nostr/consumer_test.go b/protocol/testdata/nostr/consumer_test.go |
| 4030 | new file mode 100644 |
| 4031 | index 0000000..732f6ab |
| 4032 | --- /dev/null |
| 4033 | +++ b/protocol/testdata/nostr/consumer_test.go |
| 4034 | @@ -0,0 +1,40 @@ |
| 4035 | +package consumer_test |
| 4036 | + |
| 4037 | +import ( |
| 4038 | + "reflect" |
| 4039 | + "strings" |
| 4040 | + "testing" |
| 4041 | + |
| 4042 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4043 | + "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 4044 | + "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 4045 | +) |
| 4046 | + |
| 4047 | +func TestPublicEventRoundTripAndConsumerTypes(t *testing.T) { |
| 4048 | + e := nostr.Event{CreatedAt: 1, Kind: 1, Content: "hello <world>\u2028", Tags: [][]string{{"t", "go"}}} |
| 4049 | + if err := nostr.Sign(&e, strings.Repeat("0", 63)+"1"); err != nil { |
| 4050 | + t.Fatal(err) |
| 4051 | + } |
| 4052 | + raw, err := nostr.Canonical(e) |
| 4053 | + if err != nil { |
| 4054 | + t.Fatal(err) |
| 4055 | + } |
| 4056 | + parsed, err := nostr.Parse(raw) |
| 4057 | + if err != nil || !reflect.DeepEqual(parsed, e) { |
| 4058 | + t.Fatalf("round trip: %#v, %v", parsed, err) |
| 4059 | + } |
| 4060 | + var gitEvent tinygit.Event = parsed |
| 4061 | + var message tinyclient.RoomMessage = gitEvent |
| 4062 | + if reflect.TypeOf(message).PkgPath() != "github.com/FelineStateMachine/tinyrelay/protocol/nostr" { |
| 4063 | + t.Fatalf("public event is owned by %s", reflect.TypeOf(message).PkgPath()) |
| 4064 | + } |
| 4065 | + f, err := nostr.ParseFilter([]byte(`{"#t":["go"],"since":1,"until":1}`)) |
| 4066 | + if err != nil || !nostr.Matches(f, message) { |
| 4067 | + t.Fatalf("filter round trip: %#v, %v", f, err) |
| 4068 | + } |
| 4069 | + // Matching is not host visibility policy, even for private feature kinds. |
| 4070 | + message.Kind, message.Content = 30390, "hello" |
| 4071 | + if !nostr.Matches(nostr.Filter{Search: "hello"}, message) { |
| 4072 | + t.Fatal("generic matcher applied host privacy policy") |
| 4073 | + } |
| 4074 | +} |
| 4075 | diff --git a/tinyclient/public_pages.go b/tinyclient/public_pages.go |
| 4076 | index 9ecc615..7d4aa52 100644 |
| 4077 | --- a/tinyclient/public_pages.go |
| 4078 | +++ b/tinyclient/public_pages.go |
| 4079 | @@ -11,7 +11,7 @@ import ( |
| 4080 | "strings" |
| 4081 | "time" |
| 4082 | |
| 4083 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4084 | + event "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4085 | ) |
| 4086 | |
| 4087 | func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Values) ([]any, error) { |
| 4088 | diff --git a/tinyclient/read_contracts.go b/tinyclient/read_contracts.go |
| 4089 | index 1673ff4..24bba9a 100644 |
| 4090 | --- a/tinyclient/read_contracts.go |
| 4091 | +++ b/tinyclient/read_contracts.go |
| 4092 | @@ -5,7 +5,7 @@ import ( |
| 4093 | "encoding/json" |
| 4094 | "net/url" |
| 4095 | |
| 4096 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4097 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4098 | ) |
| 4099 | |
| 4100 | // RoomsReader supplies the typed reads used by room pages. actor is the |
| 4101 | @@ -62,7 +62,7 @@ type RoomMember struct { |
| 4102 | AddedAt int64 `json:"added_at,omitempty"` |
| 4103 | } |
| 4104 | |
| 4105 | -type RoomMessage = event.Event |
| 4106 | +type RoomMessage = nostr.Event |
| 4107 | |
| 4108 | // RoomPage is the typed result for a room or thread read. A room read fills |
| 4109 | // Members and Messages; a thread read fills Root and Replies. Edits contains |
| 4110 | diff --git a/tinyclient/site_metadata.go b/tinyclient/site_metadata.go |
| 4111 | index 3f2e819..e1543ba 100644 |
| 4112 | --- a/tinyclient/site_metadata.go |
| 4113 | +++ b/tinyclient/site_metadata.go |
| 4114 | @@ -5,7 +5,7 @@ import ( |
| 4115 | "strings" |
| 4116 | |
| 4117 | "fiatjaf.com/nostr" |
| 4118 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4119 | + event "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4120 | ) |
| 4121 | |
| 4122 | const ( |
| 4123 | diff --git a/tinyclient/site_metadata_test.go b/tinyclient/site_metadata_test.go |
| 4124 | index 60e3995..07e3202 100644 |
| 4125 | --- a/tinyclient/site_metadata_test.go |
| 4126 | +++ b/tinyclient/site_metadata_test.go |
| 4127 | @@ -5,8 +5,8 @@ import ( |
| 4128 | "strings" |
| 4129 | "testing" |
| 4130 | |
| 4131 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4132 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 4133 | + event "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4134 | ) |
| 4135 | |
| 4136 | func TestSitePresentationMatchesRelayManifests(t *testing.T) { |
| 4137 | diff --git a/tinygit/README.md b/tinygit/README.md |
| 4138 | index 94d5afc..3fc88fe 100644 |
| 4139 | --- a/tinygit/README.md |
| 4140 | +++ b/tinygit/README.md |
| 4141 | @@ -51,9 +51,10 @@ Import `github.com/FelineStateMachine/tinyrelay/tinygit`. |
| 4142 | - `OpenServer` owns the standalone service's store and returns an HTTP handler. Stop serving requests before calling `Close`. |
| 4143 | - `New` constructs the lower-level engine using `Config`. The caller owns its store, policy, access decisions, synchronization transports and lifecycle. |
| 4144 | - `OpenStore`, `Event`, `Policy` and `Store` make the public API usable from another Go module. |
| 4145 | +- `ParseMetadata` parses repository claims and validates their supported shape without checking signatures, host authority or Git objects. Call `nostr.Validate` separately before trusting a claimed author. |
| 4146 | - `Validate`, `CommitAfterStore` and the promotion callback separate admission, durable event persistence and visible Git state. Integration code must preserve that ordering and its transaction guarantees. |
| 4147 | |
| 4148 | -The engine still shares internal event, authentication, policy and SQLite implementations with tinyrelay. It is a public package in the root Go module, not an independent dependency-free Go module. See [Module boundaries and protocol contracts](../docs/module-contracts.md) for the next dependency seams. |
| 4149 | +The engine uses public `protocol/nostr` event types and `protocol/auth` proof verification. It still shares internal policy, feature helpers and SQLite implementations with tinyrelay. It is a public package in the root Go module, not an independent dependency-free Go module. See [Module boundaries and protocol contracts](../docs/module-contracts.md) for the next dependency seams. |
| 4150 | |
| 4151 | ## Verify |
| 4152 | |
| 4153 | diff --git a/tinygit/admission.go b/tinygit/admission.go |
| 4154 | index aa95130..b7bbc34 100644 |
| 4155 | --- a/tinygit/admission.go |
| 4156 | +++ b/tinygit/admission.go |
| 4157 | @@ -17,12 +17,15 @@ import ( |
| 4158 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 4159 | ) |
| 4160 | |
| 4161 | -// ValidateEvent checks the event's signature and structural validity. |
| 4162 | +// ValidateEvent checks the wire shape, canonical ID and signature, not |
| 4163 | +// repository metadata shape or host admission. Use ParseMetadata for the pure |
| 4164 | +// metadata check, or Validate for the complete admission boundary. |
| 4165 | func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) } |
| 4166 | |
| 4167 | -// Validate performs GRASP admission and returns the repository authority |
| 4168 | -// that the caller must associate with its event transaction. It has no store |
| 4169 | -// side effects. |
| 4170 | +// Validate checks the signature and metadata shape, resolves repository |
| 4171 | +// authority, then applies host admission. Shape failures precede host callbacks. |
| 4172 | +// The caller must associate the returned authority with its event transaction. |
| 4173 | +// Validate has no store side effects and does not require Git objects locally. |
| 4174 | func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, error) { |
| 4175 | if err := event.Validate(e); err != nil { |
| 4176 | return Repository{}, err |
| 4177 | @@ -50,9 +53,6 @@ func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, err |
| 4178 | if r.EventID == "" { |
| 4179 | return Repository{}, errors.New("blocked: repository announcement is missing") |
| 4180 | } |
| 4181 | - if err := g.validateRefs(r.Refs); err != nil { |
| 4182 | - return Repository{}, err |
| 4183 | - } |
| 4184 | // Missing objects are a durable pending transition. The signed state is |
| 4185 | // accepted, but remains invisible until receive-pack supplies every tip. |
| 4186 | } |
| 4187 | @@ -299,9 +299,9 @@ func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, e |
| 4188 | return true, nil |
| 4189 | } |
| 4190 | |
| 4191 | -// Publish ingests a signed repository announcement or state event. Callers |
| 4192 | -// must validate the event before invoking this method; ValidateEvent is |
| 4193 | -// provided for callers that want one explicit boundary. |
| 4194 | +// Publish validates and admits a signed repository announcement or state |
| 4195 | +// before saving it and staging the Git transition. Enabled GRASP-06 events |
| 4196 | +// use their separate admission path. |
| 4197 | func (g *GitRelay) Publish(ctx context.Context, e event.Event) error { |
| 4198 | if err := event.Validate(e); err != nil { |
| 4199 | return err |
| 4200 | @@ -324,54 +324,22 @@ func (g *GitRelay) Publish(ctx context.Context, e event.Event) error { |
| 4201 | return g.CommitAfterStore(ctx, e, repo) |
| 4202 | } |
| 4203 | |
| 4204 | +// parseRepository resolves parsed metadata against the host's announcement |
| 4205 | +// and maintainer authority. All shape checks finish before consulting the host. |
| 4206 | func (g *GitRelay) parseRepository(e event.Event) (Repository, error) { |
| 4207 | - id := event.Tag(e, "d") |
| 4208 | - if !validIdentifier(id) { |
| 4209 | - return Repository{}, errors.New("invalid: repository identifier") |
| 4210 | + m, err := ParseMetadata(e) |
| 4211 | + if err != nil { |
| 4212 | + return Repository{}, err |
| 4213 | } |
| 4214 | - r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}} |
| 4215 | - if e.Kind == 30617 { |
| 4216 | - for _, tag := range e.Tags { |
| 4217 | - if len(tag) < 2 { |
| 4218 | - continue |
| 4219 | - } |
| 4220 | - switch tag[0] { |
| 4221 | - case "private": |
| 4222 | - r.Private = tag[1] == "true" |
| 4223 | - case "clone": |
| 4224 | - r.Clone = append(r.Clone, tag[1:]...) |
| 4225 | - case "relays": |
| 4226 | - r.Relays = append(r.Relays, tag[1:]...) |
| 4227 | - case "maintainers": |
| 4228 | - r.Maintainers = append(r.Maintainers, tag[1:]...) |
| 4229 | - } |
| 4230 | - } |
| 4231 | - return r, nil |
| 4232 | + state := Repository{Owner: m.Author, Identifier: m.Identifier, EventID: m.EventID, |
| 4233 | + Private: m.Private, Clone: m.Clone, Relays: m.Relays, Maintainers: m.Maintainers, |
| 4234 | + Refs: m.Refs, Head: m.Head} |
| 4235 | + if m.Kind == 30617 { |
| 4236 | + return state, nil |
| 4237 | } |
| 4238 | // State is replaceable: bind it to the current announcement owner and |
| 4239 | // carry the announcement's identity into the hook/state file. |
| 4240 | - state := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}} |
| 4241 | - for _, tag := range e.Tags { |
| 4242 | - if len(tag) == 0 { |
| 4243 | - continue |
| 4244 | - } |
| 4245 | - if tag[0] == "HEAD" { |
| 4246 | - if len(tag) != 2 || state.Head != "" { |
| 4247 | - return Repository{}, errors.New("invalid: repository state has multiple HEAD tags") |
| 4248 | - } |
| 4249 | - state.Head = tag[1] |
| 4250 | - continue |
| 4251 | - } |
| 4252 | - if len(tag) < 2 { |
| 4253 | - continue |
| 4254 | - } |
| 4255 | - if strings.HasPrefix(tag[0], "refs/") { |
| 4256 | - if _, duplicate := state.Refs[tag[0]]; duplicate || tag[1] == "" || !isObjectID(tag[1]) || strings.Trim(tag[1], "0") == "" { |
| 4257 | - return Repository{}, fmt.Errorf("invalid: ref %s is not a SHA-1", tag[0]) |
| 4258 | - } |
| 4259 | - state.Refs[tag[0]] = tag[1] |
| 4260 | - } |
| 4261 | - } |
| 4262 | + id := m.Identifier |
| 4263 | g.mu.RLock() |
| 4264 | ann := g.repos[key(e.PubKey, id)] |
| 4265 | g.mu.RUnlock() |
| 4266 | @@ -402,37 +370,11 @@ func (g *GitRelay) parseRepository(e event.Event) (Repository, error) { |
| 4267 | state.Relays = append([]string(nil), ann.Relays...) |
| 4268 | state.Maintainers = append([]string(nil), ann.Maintainers...) |
| 4269 | state.EventID = e.ID |
| 4270 | - if err := validateHead(state.Head, state.Refs); err != nil { |
| 4271 | - return Repository{}, err |
| 4272 | - } |
| 4273 | return state, nil |
| 4274 | } |
| 4275 | |
| 4276 | -func validateHead(head string, refs map[string]string) error { |
| 4277 | - if head == "" { |
| 4278 | - return nil |
| 4279 | - } |
| 4280 | - if !strings.HasPrefix(head, "ref: ") { |
| 4281 | - return errors.New("invalid: HEAD must use ref: syntax") |
| 4282 | - } |
| 4283 | - ref := strings.TrimPrefix(head, "ref: ") |
| 4284 | - if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") { |
| 4285 | - return errors.New("invalid: HEAD must name a branch or tag") |
| 4286 | - } |
| 4287 | - if !validRef(ref) { |
| 4288 | - return errors.New("invalid: HEAD ref") |
| 4289 | - } |
| 4290 | - return nil |
| 4291 | -} |
| 4292 | - |
| 4293 | -func (g *GitRelay) validateRefs(refs map[string]string) error { |
| 4294 | - for ref, oid := range refs { |
| 4295 | - if !validRef(ref) || oid != "" && !isObjectID(oid) { |
| 4296 | - return fmt.Errorf("invalid: ref %s", ref) |
| 4297 | - } |
| 4298 | - } |
| 4299 | - return nil |
| 4300 | -} |
| 4301 | +// validateRefs preserves the internal caller boundary for synchronization. |
| 4302 | +func (g *GitRelay) validateRefs(refs map[string]string) error { return validateRefs(refs) } |
| 4303 | |
| 4304 | // ErrIncomplete reports a synchronization pass that saved progress and has |
| 4305 | // more history to fetch. The scheduler continues it soon without counting a |
| 4306 | diff --git a/tinygit/api.go b/tinygit/api.go |
| 4307 | index 19d837b..b33aded 100644 |
| 4308 | --- a/tinygit/api.go |
| 4309 | +++ b/tinygit/api.go |
| 4310 | @@ -4,14 +4,14 @@ import ( |
| 4311 | "context" |
| 4312 | "path/filepath" |
| 4313 | |
| 4314 | - "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4315 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 4316 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 4317 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4318 | ) |
| 4319 | |
| 4320 | // Event is the signed Nostr wire event accepted by the Git engine. Clients |
| 4321 | // sign events themselves; the server never needs their private keys. |
| 4322 | -type Event = event.Event |
| 4323 | +type Event = nostr.Event |
| 4324 | |
| 4325 | // Policy configures the embedded engine. It currently shares tinyrelay's |
| 4326 | // policy model; DefaultPolicy provides the initial values. |
| 4327 | diff --git a/tinygit/doc.go b/tinygit/doc.go |
| 4328 | index 124f6ea..4bb129c 100644 |
| 4329 | --- a/tinygit/doc.go |
| 4330 | +++ b/tinygit/doc.go |
| 4331 | @@ -10,8 +10,17 @@ |
| 4332 | // Synchronization runs through Service.Tick or transport callbacks invoked by |
| 4333 | // the caller. |
| 4334 | // |
| 4335 | -// Validate checks event and maintainer admission. After the caller durably |
| 4336 | -// stores the event, CommitAfterStore stages the corresponding Git transition. |
| 4337 | +// ParseMetadata checks repository announcement and state shape without a store, |
| 4338 | +// signature check or host policy. Its Metadata result contains claims, not |
| 4339 | +// resolved repository authority. Use protocol/nostr.Validate for the separate |
| 4340 | +// wire, event ID and signature check. Neither check establishes maintainer |
| 4341 | +// authority, hosting permission or Git object availability, and the metadata |
| 4342 | +// parser does not claim full NIP or GRASP compliance. |
| 4343 | +// |
| 4344 | +// Validate and ValidateImported check signatures and metadata shape before |
| 4345 | +// resolving repository authority and applying host admission. A valid shape may |
| 4346 | +// still be denied by Config.Authorize or lack a current announcement. After the |
| 4347 | +// caller durably stores an admitted event, CommitAfterStore stages its Git transition. |
| 4348 | // CommitAfterStoreNoNotify serves callers already holding the host publication |
| 4349 | // fence. PromotePending completes a receive-pack repair and invokes OnPromote |
| 4350 | // after state becomes visible. |
| 4351 | diff --git a/tinygit/metadata.go b/tinygit/metadata.go |
| 4352 | new file mode 100644 |
| 4353 | index 0000000..d62436e |
| 4354 | --- /dev/null |
| 4355 | +++ b/tinygit/metadata.go |
| 4356 | @@ -0,0 +1,120 @@ |
| 4357 | +package tinygit |
| 4358 | + |
| 4359 | +import ( |
| 4360 | + "errors" |
| 4361 | + "fmt" |
| 4362 | + "strings" |
| 4363 | + |
| 4364 | + "github.com/FelineStateMachine/tinyrelay/protocol/nostr" |
| 4365 | +) |
| 4366 | + |
| 4367 | +// Metadata contains the claims parsed from a repository announcement (kind |
| 4368 | +// 30617) or state (kind 30618). Author is the event's claimed signer, not a |
| 4369 | +// resolved repository owner. Announcement fields are not inherited by states. |
| 4370 | +// A Metadata value conveys neither signature validity nor hosting authority. |
| 4371 | +type Metadata struct { |
| 4372 | + Kind int |
| 4373 | + Author string |
| 4374 | + Identifier string |
| 4375 | + EventID string |
| 4376 | + Private bool |
| 4377 | + Clone []string |
| 4378 | + Relays []string |
| 4379 | + Maintainers []string |
| 4380 | + Refs map[string]string |
| 4381 | + Head string |
| 4382 | +} |
| 4383 | + |
| 4384 | +// ParseMetadata checks and parses tinygit's supported repository metadata |
| 4385 | +// shape: identifier, state ref names and nonzero SHA-1 tips, and symbolic HEAD |
| 4386 | +// syntax. HEAD may name a branch or tag not present in Refs, including an unborn |
| 4387 | +// branch. Unknown tags and supported extension fields are ignored. |
| 4388 | +// |
| 4389 | +// This is a pure shape/semantic check, not full NIP or GRASP compliance. It does |
| 4390 | +// not validate the event ID or signature, consult announcements or maintainers, |
| 4391 | +// authorize hosting, check clone or relay endpoints, or inspect Git objects. |
| 4392 | +// Use nostr.Validate separately before trusting Author. GitRelay.Validate and |
| 4393 | +// ValidateImported combine signature validation, this parser and host admission; |
| 4394 | +// missing Git objects remain a separate pending-state concern. |
| 4395 | +func ParseMetadata(e nostr.Event) (Metadata, error) { |
| 4396 | + if e.Kind != 30617 && e.Kind != 30618 { |
| 4397 | + return Metadata{}, fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind) |
| 4398 | + } |
| 4399 | + id := nostr.Tag(e, "d") |
| 4400 | + if !validIdentifier(id) { |
| 4401 | + return Metadata{}, errors.New("invalid: repository identifier") |
| 4402 | + } |
| 4403 | + m := Metadata{Kind: e.Kind, Author: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}} |
| 4404 | + if e.Kind == 30617 { |
| 4405 | + for _, tag := range e.Tags { |
| 4406 | + if len(tag) < 2 { |
| 4407 | + continue |
| 4408 | + } |
| 4409 | + switch tag[0] { |
| 4410 | + case "private": |
| 4411 | + m.Private = tag[1] == "true" |
| 4412 | + case "clone": |
| 4413 | + m.Clone = append(m.Clone, tag[1:]...) |
| 4414 | + case "relays": |
| 4415 | + m.Relays = append(m.Relays, tag[1:]...) |
| 4416 | + case "maintainers": |
| 4417 | + m.Maintainers = append(m.Maintainers, tag[1:]...) |
| 4418 | + } |
| 4419 | + } |
| 4420 | + return m, nil |
| 4421 | + } |
| 4422 | + for _, tag := range e.Tags { |
| 4423 | + if len(tag) == 0 { |
| 4424 | + continue |
| 4425 | + } |
| 4426 | + if tag[0] == "HEAD" { |
| 4427 | + if len(tag) != 2 || m.Head != "" { |
| 4428 | + return Metadata{}, errors.New("invalid: repository state has multiple HEAD tags") |
| 4429 | + } |
| 4430 | + m.Head = tag[1] |
| 4431 | + continue |
| 4432 | + } |
| 4433 | + if len(tag) < 2 { |
| 4434 | + continue |
| 4435 | + } |
| 4436 | + if strings.HasPrefix(tag[0], "refs/") { |
| 4437 | + if _, duplicate := m.Refs[tag[0]]; duplicate || tag[1] == "" || !isObjectID(tag[1]) || strings.Trim(tag[1], "0") == "" { |
| 4438 | + return Metadata{}, fmt.Errorf("invalid: ref %s is not a SHA-1", tag[0]) |
| 4439 | + } |
| 4440 | + m.Refs[tag[0]] = tag[1] |
| 4441 | + } |
| 4442 | + } |
| 4443 | + if err := validateRefs(m.Refs); err != nil { |
| 4444 | + return Metadata{}, err |
| 4445 | + } |
| 4446 | + if err := validateHead(m.Head, m.Refs); err != nil { |
| 4447 | + return Metadata{}, err |
| 4448 | + } |
| 4449 | + return m, nil |
| 4450 | +} |
| 4451 | + |
| 4452 | +func validateHead(head string, refs map[string]string) error { |
| 4453 | + if head == "" { |
| 4454 | + return nil |
| 4455 | + } |
| 4456 | + if !strings.HasPrefix(head, "ref: ") { |
| 4457 | + return errors.New("invalid: HEAD must use ref: syntax") |
| 4458 | + } |
| 4459 | + ref := strings.TrimPrefix(head, "ref: ") |
| 4460 | + if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") { |
| 4461 | + return errors.New("invalid: HEAD must name a branch or tag") |
| 4462 | + } |
| 4463 | + if !validRef(ref) { |
| 4464 | + return errors.New("invalid: HEAD ref") |
| 4465 | + } |
| 4466 | + return nil |
| 4467 | +} |
| 4468 | + |
| 4469 | +func validateRefs(refs map[string]string) error { |
| 4470 | + for ref, oid := range refs { |
| 4471 | + if !validRef(ref) || oid != "" && !isObjectID(oid) { |
| 4472 | + return fmt.Errorf("invalid: ref %s", ref) |
| 4473 | + } |
| 4474 | + } |
| 4475 | + return nil |
| 4476 | +} |
| 4477 | diff --git a/tinygit/metadata_admission_test.go b/tinygit/metadata_admission_test.go |
| 4478 | new file mode 100644 |
| 4479 | index 0000000..8388fd8 |
| 4480 | --- /dev/null |
| 4481 | +++ b/tinygit/metadata_admission_test.go |
| 4482 | @@ -0,0 +1,127 @@ |
| 4483 | +package tinygit |
| 4484 | + |
| 4485 | +import ( |
| 4486 | + "context" |
| 4487 | + "errors" |
| 4488 | + "path/filepath" |
| 4489 | + "strings" |
| 4490 | + "testing" |
| 4491 | + |
| 4492 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 4493 | +) |
| 4494 | + |
| 4495 | +func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) { |
| 4496 | + ctx := context.Background() |
| 4497 | + store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db")) |
| 4498 | + if err != nil { |
| 4499 | + t.Fatal(err) |
| 4500 | + } |
| 4501 | + defer store.Close() |
| 4502 | + calls := 0 |
| 4503 | + denied := errors.New("blocked: host does not accept this repository") |
| 4504 | + g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error { |
| 4505 | + calls++ |
| 4506 | + return denied |
| 4507 | + }}) |
| 4508 | + if err != nil { |
| 4509 | + t.Fatal(err) |
| 4510 | + } |
| 4511 | + secret := strings.Repeat("0", 63) + "1" |
| 4512 | + for _, kind := range []int{30617, 30618} { |
| 4513 | + e := Event{Kind: kind, CreatedAt: 1, Tags: [][]string{{"d", "demo"}}} |
| 4514 | + if err := event.Sign(&e, secret); err != nil { |
| 4515 | + t.Fatal(err) |
| 4516 | + } |
| 4517 | + if _, err := ParseMetadata(e); err != nil { |
| 4518 | + t.Fatalf("valid shape rejected: %v", err) |
| 4519 | + } |
| 4520 | + if kind == 30618 { |
| 4521 | + // Pure parsing needs no announcement; admission still does. |
| 4522 | + if _, err := g.Validate(ctx, e); err == nil || !strings.Contains(err.Error(), "announcement is missing") { |
| 4523 | + t.Fatalf("state without authority admitted: %v", err) |
| 4524 | + } |
| 4525 | + g.repos[key(e.PubKey, "demo")] = Repository{Owner: e.PubKey, Identifier: "demo", EventID: "announcement"} |
| 4526 | + } |
| 4527 | + for _, admit := range []func(context.Context, Event) error{ |
| 4528 | + func(ctx context.Context, e Event) error { _, err := g.Validate(ctx, e); return err }, |
| 4529 | + func(ctx context.Context, e Event) error { _, err := g.ValidateImported(ctx, e); return err }, |
| 4530 | + g.Publish, |
| 4531 | + } { |
| 4532 | + calls = 0 |
| 4533 | + if err := admit(ctx, e); !errors.Is(err, denied) || calls != 1 { |
| 4534 | + t.Fatalf("shape bypassed host admission: kind=%d calls=%d err=%v", kind, calls, err) |
| 4535 | + } |
| 4536 | + // A tampered signature must fail before host authorization, even |
| 4537 | + // though the metadata shape still parses. |
| 4538 | + calls = 0 |
| 4539 | + tampered := e |
| 4540 | + tampered.Content = "tampered" |
| 4541 | + if err := admit(ctx, tampered); err == nil || errors.Is(err, denied) || calls != 0 { |
| 4542 | + t.Fatalf("signature stage bypassed: calls=%d err=%v", calls, err) |
| 4543 | + } |
| 4544 | + } |
| 4545 | + } |
| 4546 | + var count int |
| 4547 | + if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events").Scan(&count); err != nil || count != 0 { |
| 4548 | + t.Fatalf("rejected metadata persisted: count=%d err=%v", count, err) |
| 4549 | + } |
| 4550 | +} |
| 4551 | + |
| 4552 | +func TestMetadataShapePrecedesRepositoryLookup(t *testing.T) { |
| 4553 | + // No store or host is needed to reject shape. In particular, HEAD must |
| 4554 | + // not be deferred until after resolving the announcement and maintainers. |
| 4555 | + g := &GitRelay{} |
| 4556 | + for _, tag := range [][]string{{"HEAD", "detached"}, {"refs/heads/bad..name", strings.Repeat("a", 40)}} { |
| 4557 | + e := Event{Kind: 30618, Tags: [][]string{{"d", "demo"}, tag}} |
| 4558 | + if _, err := g.parseRepository(e); err == nil || !strings.HasPrefix(err.Error(), "invalid:") { |
| 4559 | + t.Fatalf("shape error must precede repository lookup: %v", err) |
| 4560 | + } |
| 4561 | + } |
| 4562 | +} |
| 4563 | + |
| 4564 | +func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) { |
| 4565 | + ctx := context.Background() |
| 4566 | + store, err := OpenStore(ctx, filepath.Join(t.TempDir(), "events.db")) |
| 4567 | + if err != nil { |
| 4568 | + t.Fatal(err) |
| 4569 | + } |
| 4570 | + defer store.Close() |
| 4571 | + calls := 0 |
| 4572 | + denied := errors.New("host denial") |
| 4573 | + g, err := New(Config{Store: store, Root: filepath.Join(t.TempDir(), "git"), Authorize: func(context.Context, Event, Repository) error { |
| 4574 | + calls++ |
| 4575 | + return denied |
| 4576 | + }}) |
| 4577 | + if err != nil { |
| 4578 | + t.Fatal(err) |
| 4579 | + } |
| 4580 | + secret := strings.Repeat("0", 63) + "1" |
| 4581 | + owner, err := event.PublicKey(secret) |
| 4582 | + if err != nil { |
| 4583 | + t.Fatal(err) |
| 4584 | + } |
| 4585 | + g.repos[key(owner, "demo")] = Repository{Owner: owner, Identifier: "demo", EventID: "announcement"} |
| 4586 | + e := Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d", "demo"}, {"refs/heads/bad..name", strings.Repeat("a", 40)}}} |
| 4587 | + if err := event.Sign(&e, secret); err != nil { |
| 4588 | + t.Fatal(err) |
| 4589 | + } |
| 4590 | + for name, admit := range map[string]func(context.Context, Event) error{ |
| 4591 | + "validate": func(ctx context.Context, e Event) error { _, err := g.Validate(ctx, e); return err }, |
| 4592 | + "import": func(ctx context.Context, e Event) error { _, err := g.ValidateImported(ctx, e); return err }, |
| 4593 | + "publish": g.Publish, |
| 4594 | + } { |
| 4595 | + t.Run(name, func(t *testing.T) { |
| 4596 | + calls = 0 |
| 4597 | + if err := admit(ctx, e); err == nil || !strings.HasPrefix(err.Error(), "invalid:") { |
| 4598 | + t.Errorf("invalid shape returned %v, want shape error before host denial", err) |
| 4599 | + } |
| 4600 | + if calls != 0 { |
| 4601 | + t.Errorf("host authorizer called %d times for invalid shape", calls) |
| 4602 | + } |
| 4603 | + var count int |
| 4604 | + if err := store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE id=?", e.ID).Scan(&count); err != nil || count != 0 { |
| 4605 | + t.Fatalf("invalid shape persisted: count=%d err=%v", count, err) |
| 4606 | + } |
| 4607 | + }) |
| 4608 | + } |
| 4609 | +} |
| 4610 | diff --git a/tinygit/metadata_external_test.go b/tinygit/metadata_external_test.go |
| 4611 | new file mode 100644 |
| 4612 | index 0000000..731da48 |
| 4613 | --- /dev/null |
| 4614 | +++ b/tinygit/metadata_external_test.go |
| 4615 | @@ -0,0 +1,82 @@ |
| 4616 | +package tinygit_test |
| 4617 | + |
| 4618 | +import ( |
| 4619 | + "reflect" |
| 4620 | + "strings" |
| 4621 | + "testing" |
| 4622 | + |
| 4623 | + "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 4624 | +) |
| 4625 | + |
| 4626 | +func TestParseMetadataRejectsInvalidShapes(t *testing.T) { |
| 4627 | + oid := strings.Repeat("a", 40) |
| 4628 | + for name, tags := range map[string][][]string{ |
| 4629 | + "missing identifier": {}, |
| 4630 | + "unsafe identifier": {{"d", "../demo"}}, |
| 4631 | + "invalid ref": {{"d", "demo"}, {"refs/heads/a..b", oid}}, |
| 4632 | + "invalid oid": {{"d", "demo"}, {"refs/heads/main", "not-a-sha1"}}, |
| 4633 | + "uppercase oid": {{"d", "demo"}, {"refs/heads/main", strings.Repeat("A", 40)}}, |
| 4634 | + "zero oid": {{"d", "demo"}, {"refs/heads/main", strings.Repeat("0", 40)}}, |
| 4635 | + "empty oid": {{"d", "demo"}, {"refs/heads/main", ""}}, |
| 4636 | + "duplicate ref": {{"d", "demo"}, {"refs/heads/main", oid}, {"refs/heads/main", oid}}, |
| 4637 | + "duplicate head": {{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"HEAD", "ref: refs/heads/main"}}, |
| 4638 | + "short head": {{"d", "demo"}, {"HEAD"}}, |
| 4639 | + "long head": {{"d", "demo"}, {"HEAD", "ref: refs/heads/main", "extra"}}, |
| 4640 | + "detached head": {{"d", "demo"}, {"HEAD", oid}}, |
| 4641 | + "nonbranch head": {{"d", "demo"}, {"HEAD", "ref: refs/nostr/topic"}}, |
| 4642 | + "invalid head ref": {{"d", "demo"}, {"HEAD", "ref: refs/heads/bad..name"}}, |
| 4643 | + } { |
| 4644 | + t.Run(name, func(t *testing.T) { |
| 4645 | + m, err := tinygit.ParseMetadata(tinygit.Event{Kind: 30618, Tags: tags}) |
| 4646 | + if err == nil || !strings.HasPrefix(err.Error(), "invalid:") || !reflect.DeepEqual(m, tinygit.Metadata{}) { |
| 4647 | + t.Fatalf("invalid shape: metadata=%#v err=%v", m, err) |
| 4648 | + } |
| 4649 | + }) |
| 4650 | + } |
| 4651 | + if _, err := tinygit.ParseMetadata(tinygit.Event{Kind: 1, Tags: [][]string{{"d", "demo"}}}); err == nil || !strings.HasPrefix(err.Error(), "unsupported:") { |
| 4652 | + t.Fatalf("unsupported kind: %v", err) |
| 4653 | + } |
| 4654 | +} |
| 4655 | + |
| 4656 | +func TestParseMetadataPreservesAnnouncementClaims(t *testing.T) { |
| 4657 | + e := tinygit.Event{Kind: 30617, PubKey: "author", Tags: [][]string{ |
| 4658 | + {}, {"d", "demo"}, {"d", "ignored-second-identifier"}, |
| 4659 | + {"private", "true"}, {"clone", "https://one", "https://two"}, {"clone", "https://three"}, |
| 4660 | + {"relays", "wss://one", "wss://two"}, {"maintainers", "claimed-key", "another-key"}, |
| 4661 | + {"unknown"}, {"HEAD", "not-state"}, |
| 4662 | + }} |
| 4663 | + m, err := tinygit.ParseMetadata(e) |
| 4664 | + if err != nil { |
| 4665 | + t.Fatal(err) |
| 4666 | + } |
| 4667 | + if !m.Private || m.Identifier != "demo" || m.Author != "author" || m.Head != "" || len(m.Refs) != 0 || |
| 4668 | + !reflect.DeepEqual(m.Clone, []string{"https://one", "https://two", "https://three"}) || |
| 4669 | + !reflect.DeepEqual(m.Relays, []string{"wss://one", "wss://two"}) || |
| 4670 | + !reflect.DeepEqual(m.Maintainers, []string{"claimed-key", "another-key"}) { |
| 4671 | + t.Fatalf("announcement claims = %#v", m) |
| 4672 | + } |
| 4673 | + m.Clone[0], m.Relays[0], m.Maintainers[0] = "changed", "changed", "changed" |
| 4674 | + again, err := tinygit.ParseMetadata(e) |
| 4675 | + if err != nil || again.Clone[0] != "https://one" || again.Relays[0] != "wss://one" || again.Maintainers[0] != "claimed-key" { |
| 4676 | + t.Fatalf("metadata aliases input tags: %#v, %v", again, err) |
| 4677 | + } |
| 4678 | +} |
| 4679 | + |
| 4680 | +func TestParseMetadataWithoutSignatureOrHost(t *testing.T) { |
| 4681 | + // Shape parsing is useful before signing. These are claims, not authority. |
| 4682 | + e := tinygit.Event{Kind: 30618, PubKey: "unsigned-author", ID: "unverified-id", Tags: [][]string{ |
| 4683 | + {"d", "demo"}, {"HEAD", "ref: refs/heads/unborn"}, |
| 4684 | + {"refs/heads/main", strings.Repeat("a", 40), "extension"}, |
| 4685 | + {"refs/tags/v1", strings.Repeat("b", 40)}, {"unknown", "ignored"}, |
| 4686 | + }} |
| 4687 | + m, err := tinygit.ParseMetadata(e) |
| 4688 | + if err != nil { |
| 4689 | + t.Fatal(err) |
| 4690 | + } |
| 4691 | + if m.Kind != e.Kind || m.Author != e.PubKey || m.EventID != e.ID || m.Identifier != "demo" || m.Head != "ref: refs/heads/unborn" { |
| 4692 | + t.Fatalf("metadata claims = %#v", m) |
| 4693 | + } |
| 4694 | + if !reflect.DeepEqual(m.Refs, map[string]string{"refs/heads/main": strings.Repeat("a", 40), "refs/tags/v1": strings.Repeat("b", 40)}) { |
| 4695 | + t.Fatalf("refs = %#v", m.Refs) |
| 4696 | + } |
| 4697 | +} |
| 4698 | diff --git a/tinygit/private_transport.go b/tinygit/private_transport.go |
| 4699 | index beb6d88..6390f9d 100644 |
| 4700 | --- a/tinygit/private_transport.go |
| 4701 | +++ b/tinygit/private_transport.go |
| 4702 | @@ -15,8 +15,8 @@ import ( |
| 4703 | "os" |
| 4704 | "strings" |
| 4705 | |
| 4706 | - "github.com/FelineStateMachine/tinyrelay/internal/auth" |
| 4707 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 4708 | + "github.com/FelineStateMachine/tinyrelay/protocol/auth" |
| 4709 | ) |
| 4710 | |
| 4711 | // HTTPAuthSigner returns the authorization header for a private peer request. |