Skip to content

Commit 28567c9

Browse files
authored
Merge pull request #114 from hyp3rd/feat/dist-mem-cache
fix(security): use constant-time bearer-token comparison in client API
2 parents 1dafe57 + 5a94530 commit 28567c9

24 files changed

Lines changed: 3660 additions & 119 deletions

.github/workflows/openapi.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
name: openapi
3+
4+
# Validate the embedded OpenAPI specification on every change.
5+
# Two checks run:
6+
# * redocly lint — schema-level validation against the OpenAPI 3.1
7+
# meta-spec (broken refs, missing required fields, malformed
8+
# types, etc.).
9+
# * Go drift test — asserts every fiber route registered in
10+
# `cmd/hypercache-server/main.go` has a matching path in
11+
# `cmd/hypercache-server/openapi.yaml` (and vice-versa).
12+
#
13+
# Together they prevent the spec from silently drifting from the
14+
# binary — neither one alone catches both classes of breakage.
15+
16+
on:
17+
pull_request:
18+
paths:
19+
- "cmd/hypercache-server/openapi.yaml"
20+
- "cmd/hypercache-server/openapi.go"
21+
- "cmd/hypercache-server/openapi_test.go"
22+
- "cmd/hypercache-server/main.go"
23+
- ".github/workflows/openapi.yml"
24+
push:
25+
branches: [ main ]
26+
paths:
27+
- "cmd/hypercache-server/openapi.yaml"
28+
- "cmd/hypercache-server/openapi.go"
29+
- "cmd/hypercache-server/openapi_test.go"
30+
- "cmd/hypercache-server/main.go"
31+
- ".github/workflows/openapi.yml"
32+
33+
permissions:
34+
contents: read
35+
36+
jobs:
37+
spec-lint:
38+
name: redocly lint
39+
runs-on: ubuntu-latest
40+
timeout-minutes: 5
41+
steps:
42+
- uses: actions/checkout@v6
43+
44+
- name: Setup Node
45+
uses: actions/setup-node@v6
46+
with:
47+
node-version: "22"
48+
49+
# Pin a known-good redocly major. Patch updates are picked up
50+
# automatically on next run; major bumps are explicit so a
51+
# breaking change in the linter can't silently fail the build.
52+
- name: redocly lint
53+
run: npx --yes @redocly/cli@1 lint cmd/hypercache-server/openapi.yaml
54+
55+
drift-test:
56+
name: code↔spec drift
57+
runs-on: ubuntu-latest
58+
timeout-minutes: 5
59+
steps:
60+
- uses: actions/checkout@v6
61+
62+
- name: Load project settings
63+
id: settings
64+
run: |
65+
set -a
66+
source .project-settings.env
67+
set +a
68+
echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT"
69+
70+
- name: Setup Go
71+
uses: actions/setup-go@v6
72+
with:
73+
go-version: "${{ steps.settings.outputs.go_version }}"
74+
check-latest: true
75+
76+
- name: Cache Go modules
77+
uses: actions/cache@v5
78+
with:
79+
path: |
80+
~/go/pkg/mod
81+
~/.cache/go-build
82+
key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{
83+
hashFiles('**/go.sum') }}
84+
restore-keys: |
85+
${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-
86+
87+
- name: Drift test
88+
run: go test -run TestOpenAPISpecMatchesRoutes ./cmd/hypercache-server/ -count=1

.mdl_style.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# allow_different_nesting permits same-text headings as long as they sit
1313
# under distinct parent headings — which is exactly the Keep-a-Changelog
1414
# shape, and still catches genuine duplicates within the same section.
15-
rule "MD024", :allow_different_nesting => true
15+
rule "MD024", :allow_different_nesting => true, :siblings_only => true
1616

1717
# MkDocs pages start with YAML frontmatter (---\ntitle: ...\n---), so
1818
# the first line cannot be a top-level heading. MD041 fights that

.yamllint.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ ignore: |
1414
cspell.config.yaml
1515
FUNDING.yml
1616
codeql.yml
17+
cmd/hypercache-server/openapi.yaml

CHANGELOG.md

Lines changed: 142 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,110 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Added
1010

11+
- **Client API auth v2: multi-token, scoped, mTLS-capable.** New
12+
[`pkg/httpauth/`](pkg/httpauth/) package with `Policy`,
13+
`TokenIdentity`, `CertIdentity`, `Scope` types and a
14+
scope-enforcing fiber middleware. Replaces the single-token
15+
bearerAuth helper in `cmd/hypercache-server/main.go`. Three
16+
credential classes resolved in priority order (bearer → mTLS
17+
cert → ServerVerify hook), with constant-time multi-token
18+
compare that visits every configured token even on early match
19+
to prevent token-cardinality timing leaks. Per-route scope
20+
enforcement: `GET`/`HEAD`/owners-lookup/`batch-get` require
21+
`ScopeRead`; `PUT`/`DELETE`/`batch-put`/`batch-delete` require
22+
`ScopeWrite`. Anonymous identity (with `AllowAnonymous: true`)
23+
receives all scopes — used by the binary to preserve the
24+
zero-config dev posture.
25+
- **YAML auth config + legacy env-var coexistence.**
26+
`HYPERCACHE_AUTH_CONFIG=/etc/hypercache/auth.yaml` (new) loads
27+
a multi-token policy with per-identity scopes:
28+
29+
```yaml
30+
tokens:
31+
- id: app-prod
32+
token: "<secret>"
33+
scopes: [read, write]
34+
- id: ops
35+
token: "<secret>"
36+
scopes: [admin]
37+
cert_identities:
38+
- subject_cn: app.internal
39+
scopes: [read]
40+
allow_anonymous: false
41+
```
42+
43+
The legacy `HYPERCACHE_AUTH_TOKEN` keeps working byte-identical:
44+
one synthesized identity with all three scopes. The two env
45+
vars are NOT mutually exclusive — `HYPERCACHE_AUTH_CONFIG`
46+
governs the client API, `HYPERCACHE_AUTH_TOKEN` continues to
47+
drive the dist transport's symmetric peer auth (single trust
48+
domain). Both can be set in the same deployment without
49+
conflict. Missing or malformed config files exit the binary
50+
non-zero rather than fall through to permissive open mode —
51+
fail-closed by design.
52+
- **mTLS on the client API.** New env vars
53+
`HYPERCACHE_API_TLS_CERT`, `HYPERCACHE_API_TLS_KEY`, and
54+
`HYPERCACHE_API_TLS_CLIENT_CA` wrap the listener with
55+
`tls.NewListener`. With CA set, `RequireAndVerifyClientCert`
56+
is enabled and the verified peer cert's Subject CN is matched
57+
against the policy's `CertIdentities` to resolve the calling
58+
identity. Plaintext, standard-TLS, and mTLS shapes all share
59+
one listener path. End-to-end coverage at
60+
[cmd/hypercache-server/mtls_e2e_test.go](cmd/hypercache-server/mtls_e2e_test.go)
61+
drives a real handshake against an in-process CA / server-cert
62+
/ client-cert chain and asserts CN-to-identity resolution
63+
works in both directions (matching CN → 200, non-matching
64+
CN → 401).
65+
66+
### Security
67+
68+
- **Constant-time bearer-token compare on the client API.** Replaced
69+
the plaintext `got != want` check at
70+
[cmd/hypercache-server/main.go](cmd/hypercache-server/main.go) with
71+
`crypto/subtle.ConstantTimeCompare` to defeat timing side-channels.
72+
A naive string compare returns as soon as the first differing byte
73+
is found, leaking per-byte equality of `HYPERCACHE_AUTH_TOKEN` to a
74+
remote attacker who can measure response time. The fix mirrors the
75+
dist transport's existing constant-time check at
76+
[pkg/backend/dist_http_server.go:144-152](pkg/backend/dist_http_server.go#L144-L152).
77+
No public API change; the env-var contract and "empty token →
78+
open mode" back-compatible behavior are unchanged. New auth-test suite
79+
at [cmd/hypercache-server/auth_test.go](cmd/hypercache-server/auth_test.go)
80+
pins the contract: missing/wrong/malformed/lowercase/wrong-length
81+
bearer headers all return 401, public meta routes (`/healthz`,
82+
`/v1/openapi.yaml`) stay reachable without credentials, every
83+
protected route fires the wrapper. The new `newAuthedServer`
84+
helper drives `registerClientRoutes` directly so future wiring
85+
regressions are caught (the existing `handlers_test.go::newTestServer`
86+
deliberately bypasses auth for handler-correctness coverage).
87+
88+
### Added
89+
90+
- **OpenAPI 3.1 specification + drift-detection.** The
91+
`hypercache-server` binary now embeds its own contract via
92+
[`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml)
93+
(`//go:embed`) and serves it at `GET /v1/openapi.yaml` — every
94+
running node is self-describing. The spec covers all nine client
95+
routes (single-key PUT/GET/HEAD/DELETE, owners lookup, three
96+
batch operations, plus the `/healthz` and `/v1/openapi.yaml`
97+
meta endpoints), with reusable `ErrorResponse`, `ItemEnvelope`,
98+
and batch-operation schemas, the `bearerAuth` security scheme,
99+
and `operationId` on every operation for codegen-friendliness.
100+
A drift detector at
101+
[cmd/hypercache-server/openapi_test.go](cmd/hypercache-server/openapi_test.go)
102+
drives `registerClientRoutes` directly and asserts every
103+
fiber-registered route has a matching path in the spec — and
104+
vice-versa — so the contract cannot silently fall out of sync
105+
with the binary. Two CI workflows back this up at
106+
[.github/workflows/openapi.yml](.github/workflows/openapi.yml):
107+
`redocly lint` validates the schema against the OpenAPI 3.1
108+
meta-spec, and the Go drift test runs on every change to
109+
`main.go` or the spec. The docs site renders the same spec
110+
inline at the new
111+
[API Reference](docs/api.md) page via the
112+
`mkdocs-swagger-ui-tag` plugin — a single source of truth for
113+
the binary, the docs, and any client codegen that points at a
114+
live cluster.
11115
- **Documentation site on GitHub Pages**, built with MkDocs Material
12116
and published automatically on every push to `main`. Eight
13117
navigated pages — landing, quickstart, 5-node cluster tutorial,
@@ -31,24 +135,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
31135
- **Richer client API — metadata inspection, JSON envelopes, batch
32136
operations.** Three additions to the
33137
`cmd/hypercache-server` HTTP surface:
34-
- `HEAD /v1/cache/:key` returns the value's metadata in
35-
`X-Cache-*` response headers (Version, Origin, Last-Updated,
36-
TTL-Ms, Expires-At, Owners, Node) with no body — fast
37-
existence + TTL inspection without paying the value-transfer
38-
cost. 200 if present, 404 if not.
39-
- `GET /v1/cache/:key` now honors `Accept: application/json`
40-
and returns an `itemEnvelope` with the same metadata as
41-
HEAD plus the base64-encoded value. The bare-`curl` default
42-
remains raw bytes via `application/octet-stream` — current
43-
clients are unaffected.
44-
- `POST /v1/cache/batch/{get,put,delete}` enable bulk operations
45-
in a single round-trip. Each request carries an array; the
46-
response carries one result entry per item with per-item
47-
status, owners, and error reporting. `batch-put` items
48-
accept either UTF-8 strings (default) or base64-encoded byte
49-
payloads via `value_encoding: "base64"`. Per-item errors are
50-
surfaced in `error` + `code` fields without failing the
51-
whole batch.
138+
- `HEAD /v1/cache/:key` returns the value's metadata in
139+
`X-Cache-*` response headers (Version, Origin, Last-Updated,
140+
TTL-Ms, Expires-At, Owners, Node) with no body — fast
141+
existence + TTL inspection without paying the value-transfer
142+
cost. 200 if present, 404 if not.
143+
- `GET /v1/cache/:key` now honors `Accept: application/json`
144+
and returns an `itemEnvelope` with the same metadata as
145+
HEAD plus the base64-encoded value. The bare-`curl` default
146+
remains raw bytes via `application/octet-stream` — current
147+
clients are unaffected.
148+
- `POST /v1/cache/batch/{get,put,delete}` enable bulk operations
149+
in a single round-trip. Each request carries an array; the
150+
response carries one result entry per item with per-item
151+
status, owners, and error reporting. `batch-put` items
152+
accept either UTF-8 strings (default) or base64-encoded byte
153+
payloads via `value_encoding: "base64"`. Per-item errors are
154+
surfaced in `error` + `code` fields without failing the
155+
whole batch.
52156
Six unit tests at
53157
[cmd/hypercache-server/handlers_test.go](cmd/hypercache-server/handlers_test.go)
54158
pin the contracts: HEAD present/missing, Accept-JSON envelope
@@ -57,26 +161,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
57161
- **SWIM self-refutation + cross-process gossip dissemination.**
58162
Closes the last `experimental` marker on the heartbeat path.
59163
Three pieces:
60-
- **`acceptGossip` self-refute** — incoming entries that
61-
reference the local node as Suspect or Dead at incarnation
62-
≥ ours now bump the local incarnation and re-mark Alive.
63-
Higher-incarnation-wins propagation in the same function
64-
disseminates the refutation cluster-wide, so a falsely-
65-
suspected node can clear suspicion through gossip alone
66-
(pre-fix the only path was a fresh probe).
67-
- **HTTP gossip wire** — new `Gossip(ctx, targetID, members)`
68-
method on `DistTransport`, new
69-
`POST /internal/gossip` server endpoint (auth-wrapped),
70-
new `GossipMember` wire DTO. `runGossipTick` now falls
71-
through to the HTTP path when the transport isn't an
72-
`InProcessTransport`, so cross-process clusters disseminate
73-
membership state — pre-Phase-E this was an in-process-only
74-
no-op.
75-
- The `experimental` qualifier is removed from
76-
`heartbeatLoop`'s comment + the heartbeat-section field
77-
doc; SWIM-style indirect probes (Phase B.1) and
78-
self-refutation (this round) together provide the SWIM
79-
properties the marker was tracking.
164+
- **`acceptGossip` self-refute** — incoming entries that
165+
reference the local node as Suspect or Dead at incarnation
166+
≥ ours now bump the local incarnation and re-mark Alive.
167+
Higher-incarnation-wins propagation in the same function
168+
disseminates the refutation cluster-wide, so a falsely-
169+
suspected node can clear suspicion through gossip alone
170+
(pre-fix the only path was a fresh probe).
171+
- **HTTP gossip wire** — new `Gossip(ctx, targetID, members)`
172+
method on `DistTransport`, new
173+
`POST /internal/gossip` server endpoint (auth-wrapped),
174+
new `GossipMember` wire DTO. `runGossipTick` now falls
175+
through to the HTTP path when the transport isn't an
176+
`InProcessTransport`, so cross-process clusters disseminate
177+
membership state — pre-Phase-E this was an in-process-only
178+
no-op.
179+
- The `experimental` qualifier is removed from
180+
`heartbeatLoop`'s comment + the heartbeat-section field
181+
doc; SWIM-style indirect probes (Phase B.1) and
182+
self-refutation (this round) together provide the SWIM
183+
properties the marker was tracking.
80184
Regression coverage at
81185
[tests/integration/dist_swim_refute_test.go](tests/integration/dist_swim_refute_test.go):
82186
`TestDistSWIM_HTTPGossipExchange` exercises the wire (A pushes

_mkdocs/hooks.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
"""MkDocs hooks for the HyperCache site.
22
3-
Rewrites repo-relative links to source files (`../pkg/foo.go`,
4-
`../../hypercache.go`, etc.) into canonical GitHub URLs, so the same
5-
markdown source renders correctly both on github.com and on the
6-
GitHub Pages MkDocs build.
7-
8-
Without this, the operations runbook and the RFCs reference dozens
9-
of source files via paths like `../pkg/backend/dist_memory.go`.
10-
GitHub renders those as in-repo links; MkDocs's strict mode flags
11-
them as broken because `pkg/` is not part of the documentation
12-
tree. Rewriting them at build time keeps the source markdown
13-
GitHub-friendly while letting strict mode actually enforce
14-
docs-internal correctness.
3+
Two responsibilities:
4+
5+
1. Rewrites repo-relative links to source files (`../pkg/foo.go`,
6+
`../../hypercache.go`, etc.) into canonical GitHub URLs, so the
7+
same markdown source renders correctly both on github.com and on
8+
the GitHub Pages MkDocs build. Without this, the operations
9+
runbook and the RFCs reference dozens of source files via paths
10+
like `../pkg/backend/dist_memory.go`. GitHub renders those as
11+
in-repo links; MkDocs's strict mode flags them as broken
12+
because `pkg/` is not part of the documentation tree. Rewriting
13+
them at build time keeps the source markdown GitHub-friendly
14+
while letting strict mode actually enforce docs-internal
15+
correctness.
16+
17+
2. Injects `cmd/hypercache-server/openapi.yaml` into the docs build
18+
as `api/openapi.yaml`, so the Swagger UI page on the docs site
19+
renders the same spec the binary embeds. The spec lives next to
20+
the binary (Go's `embed` cannot traverse `..`) but the docs
21+
build needs it on its virtual filesystem — an `on_files` hook
22+
adds it without requiring a duplicate file checked into `docs/`.
1523
"""
1624

1725
import os
1826
import re
27+
from pathlib import Path
1928
from typing import Any
2029

30+
from mkdocs.structure.files import File
31+
2132
GITHUB_REPO_BASE = "https://github.com/hyp3rd/hypercache/blob/main"
2233

2334
# File extensions that we treat as "source code, not docs" — links
@@ -108,6 +119,48 @@ def _resolve_to_repo_root(page_src_path: str, target: str) -> str:
108119
return docs_anchored
109120

110121

122+
# Path of the canonical OpenAPI spec the server binary embeds.
123+
# Resolved relative to the repo root (one above `_mkdocs/`), so
124+
# this works whether MkDocs is invoked from the repo root or from
125+
# inside `docs/`.
126+
_REPO_ROOT = Path(__file__).resolve().parent.parent
127+
_OPENAPI_SOURCE = _REPO_ROOT / "cmd" / "hypercache-server" / "openapi.yaml"
128+
129+
# Where the spec appears on the rendered site — Swagger UI on
130+
# `docs/api.md` references this URL.
131+
_OPENAPI_DOCS_PATH = "api/openapi.yaml"
132+
133+
134+
def on_files(files: Any, config: Any, **kwargs: Any) -> Any:
135+
"""Inject the embedded OpenAPI spec as a docs-site asset.
136+
137+
Without this, `docs/api.md`'s Swagger UI tag would reference a
138+
file that does not exist in the docs tree (the spec lives
139+
under `cmd/hypercache-server/`). Adding it as a virtual File
140+
keeps a single source of truth — the binary's embedded spec
141+
is what the docs site renders.
142+
"""
143+
if not _OPENAPI_SOURCE.exists():
144+
# Defensive: if the spec was renamed/moved, fail loud
145+
# rather than silently render a stale asset.
146+
raise FileNotFoundError(
147+
f"OpenAPI spec not found at {_OPENAPI_SOURCE}; update "
148+
f"_mkdocs/hooks.py:_OPENAPI_SOURCE if it moved."
149+
)
150+
151+
files.append(
152+
File(
153+
path=_OPENAPI_SOURCE.name,
154+
src_dir=str(_OPENAPI_SOURCE.parent),
155+
dest_dir=config["site_dir"],
156+
use_directory_urls=False,
157+
dest_uri=_OPENAPI_DOCS_PATH,
158+
)
159+
)
160+
161+
return files
162+
163+
111164
def on_page_markdown(markdown: str, page: Any, **kwargs: Any) -> str:
112165
"""Rewrite source-code links on every page before MkDocs renders it."""
113166
page_src = page.file.src_path

0 commit comments

Comments
 (0)