feat(api): scoped bearer token auth and diagnostic redaction #65

Merged
yuris merged 1 commit from feat/issue-35-auth into main 2026-09-23 00:38:58 +00:00
Owner

Motivation

The daemon had no authentication at all: AppState carried a hard-coded ACTOR and every route was reachable by anyone who could reach the port, while the security contract (docs/technical/operations-security.md, #authentication-and-untrusted-inputs) requires scoped bearer credentials, constant-time verification of derived digests, and diagnostics that never carry credentials or private text. [server].token_files was already parsed into { identity, path, scopes } and validated, but nothing consumed it. This lands issue #35 (verification scenarios D03 and D04) and, because it introduces the subtle dependency, also lands the deferred Cargo.toml authors chore.

Changes

Authentication (src/api/auth.rs, new). Authenticator reads each configured token file once, keeps only its SHA-256 digest, and drops the secret (Zeroizing). Verification hashes the presented credential and compares it with every configured digest using subtle::ConstantTimeEq, with no early exit, so neither the match position nor a near-miss is observable. Malformed presentations — missing header, non-Bearer scheme, empty value, over 256 bytes, non-printable bytes — are refused before anything is hashed or looked up, and neither a credential nor a digest is ever logged. AuthenticatedActor { actor_id, scopes } is the verified identity.

Route scopes. route_access(method, path) is the single table, derived from the Command-to-route table in docs/technical/cli-api.md:

Route Scope
GET /health/live public (process answers; reveals nothing)
GET /health/ready read
GET /v1/agenda, GET /v1/tasks, GET /v1/items/{id}, GET /v1/timeline, GET /v1/proposals/{id}, GET /v1/operations/{id} read
POST /v1/resolve, POST /v1/next, POST /v1/reviews, POST /v1/context/search read (read-only POSTs: searches and judgments)
POST /v1/tasks, PATCH /v1/tasks/{id}, POST /v1/tasks/{id}/complete|reopen|cancel write
POST /v1/events, PATCH /v1/events/{id}, POST /v1/events/{id}/cancel write
POST /v1/captures, POST /v1/context, POST /v1/plans, POST /v1/replans, POST /v1/proposals/{id}/apply|reject write
POST /v1/sync admin
any other path, and any unlisted /v1 route admin (fails closed)

Authentication is one middleware layer in front of the router, not per-handler code, so a route cannot forget it and a refusal happens before a body is parsed — an unauthenticated flood never reaches the journal or a calendar backend. Scopes are an explicit set with no implied hierarchy: a write credential is not an admin credential, and an admin-only credential cannot read. Operators list what a client needs (scopes = ["read", "write"]).

Identity, audit and replay. The verified identity — never a request field — is the audit actor threaded into every journaling route (operations.actor, life_events.actor); audit_actor() is the only source of it, and the contract has no caller-supplied actor field (a body that names one is rejected as unknown input). operation_belongs_to() refuses a replay of an operation another authenticated identity recorded (HTTP 403); operations recorded before credentials existed keep the single-user default actor and stay replayable. Idempotency remains keyed by operation id and fingerprinted over the semantic request, which excludes credentials, so rotation is transparent.

Metering. Failed authentication is counted over a fixed 60-second window; the 31st failure in a window is answered 429 (code unauthenticated, retryable: true) instead of 401. The check runs after verification, so a valid credential is always served and an unauthenticated flood can never lock the legitimate client out. Degradation is bounded: a failed attempt costs a header parse and one SHA-256 over at most 256 bytes, and it never reaches a handler, the journal or a backend. Expensive queries stay bounded by that ordering plus the existing body-size and timeout limits.

Redaction (src/logging/, new). life::logging::init() installs the daemon's subscriber with a redacting writer in front of stderr, so redaction is a property of the subscriber rather than of individual call sites: sensitive field values (authorization, proxy-authorization, cookie, set-cookie, password, token, api_key, and the private-text fields title, description, summary, text, query, body, payload, content, ics) and Bearer/Basic credential arguments are replaced with [REDACTED] before any byte reaches a file, terminal or collector. Redaction is by shape, never by value, so the module holds no secret; a scheme in prose ("bearer authentication enabled") is left alone. The request-completion line logs exactly the permitted correlators — request_id, actor_id, method, path (no query string), operation_id, status, duration_ms — and refusals log their sanitized code.

src/api/pagination.rs. The cursor's filter binding was compared with a short-circuiting slice comparison; it now uses subtle::ConstantTimeEq, matching the constant-time requirement. (Note: this tree had no ring::constant_time::verify_slices_are_equal call to replace — cargo check is warning-free before and after — so the only non-constant-time comparison in the cursor path was this one.)

Deferred chore. Cargo.toml now declares authors = ["Yuris Cakranegara <hello@yuris.dev>"] in the same commit that adds subtle = "2"; Cargo.lock is committed.

Decisions worth a reviewer's attention

  • /health/live is public (a container probe must answer without a credential and reveals only {"status":"ok"}); /health/ready requires read, per the scope column of the CLI/API table. Consequence: life doctor's remote readiness probe is unauthenticated, so against a token-configured daemon it now reports readiness 401 rather than details. Making doctor present a credential needs a credential-selection policy (which identity a local operator command should act as) that this issue does not define, so it is left to the operator to probe with a scoped credential.
  • With no token_files configured there is no credential that could ever be accepted, so the daemon keeps its single-user local behavior and serve logs a warning at startup. A deployment always configures token_files; the test harnesses that serve routes without credentials are unaffected.
  • router_with_auth is the daemon's entry point; router_with_state builds the same routes with authentication off, which is what in-process route tests use. serve always builds the authenticator its configuration describes and fails startup if a configured secret file is unreadable.
  • No description field exists in any implemented mutation body (TaskCreateRequest, the patches), so "task descriptions never logged" is defended by the redaction layer's description rule (unit-tested) rather than by an end-to-end request; the end-to-end redaction test carries private titles, query text and resolve text instead.

Verification

  • nix develop --command cargo test — all suites green: 519 tests across every binary, 0 failures (was 450 before this branch plus the merged siblings).
  • nix develop --command cargo clippy --all-targets -- -D warnings — clean; cargo fmt --check — clean.
  • New tests/auth_redaction.rs (6 tests): the D03 scope matrix over the real router with a real journal and a mock Radicale (read→POST /v1/tasks 403 forbidden, write→POST /v1/sync 403 forbidden, admin-only credential refused a read, admin credential reaching the admin route, absent/unknown/one-byte-different credentials 401 unauthenticated, refused requests leaving zero operations and zero dispatched writes); the actor non-spoofability case (a caller-named actor is rejected and the recorded operations.actor/life_events.actor is usr_alice); the D04 rotation sequence (new credential replays op_rot_1 with changed: false, the recorded item, exactly one PUT and one operations row in total, then the revoked credential 401 and a removed credential 401 while the remaining credential still works); cross-identity replay refused; the redaction proof (real subscriber captured with a non-empty-log guard, correlators asserted present, token/Bearer/Authorization/title/query/resolve text/BEGIN:VTODO asserted absent, plus a direct call-site log of a credential and a title that the subscriber redacts); and the metering bound (30×401 then 429, a valid credential still served, meter reset on success, nothing unauthenticated reaching the journal or backend).
  • Unit tests in src/api/auth.rs (constant-time verification behaviour: off-by-one rejected; the route table checked against every row of the CLI/API contract) and src/logging/redaction.rs/src/logging/mod.rs (header/credential/private-field redaction, prose not redacted, idempotency, line-split writes, unterminated tail, and the daemon's own subscriber redacting before the writer).
  • Manual end-to-end check against the real binary (life serve --config … over TCP with three token files): /health/live 200 without a credential; /health/ready 401 without and 200 with a read credential; GET /v1/tasks 401 unauthenticated; POST /v1/sync 403 with a write credential and 501 with an admin credential; POST /v1/tasks 403 with a read credential; startup log bearer authentication enabled: every route but /health/live requires a scoped credential credentials=3; grepping the daemon's stderr for all three credentials, the private query text and Authorization returned nothing.

Closes #35

## Motivation The daemon had no authentication at all: `AppState` carried a hard-coded `ACTOR` and every route was reachable by anyone who could reach the port, while the security contract (`docs/technical/operations-security.md`, `#authentication-and-untrusted-inputs`) requires scoped bearer credentials, constant-time verification of derived digests, and diagnostics that never carry credentials or private text. `[server].token_files` was already parsed into `{ identity, path, scopes }` and validated, but nothing consumed it. This lands issue #35 (verification scenarios D03 and D04) and, because it introduces the `subtle` dependency, also lands the deferred `Cargo.toml` authors chore. ## Changes **Authentication (`src/api/auth.rs`, new).** `Authenticator` reads each configured token file once, keeps only its SHA-256 digest, and drops the secret (`Zeroizing`). Verification hashes the presented credential and compares it with every configured digest using `subtle::ConstantTimeEq`, with no early exit, so neither the match position nor a near-miss is observable. Malformed presentations — missing header, non-`Bearer` scheme, empty value, over 256 bytes, non-printable bytes — are refused before anything is hashed or looked up, and neither a credential nor a digest is ever logged. `AuthenticatedActor { actor_id, scopes }` is the verified identity. **Route scopes.** `route_access(method, path)` is the single table, derived from the Command-to-route table in `docs/technical/cli-api.md`: | Route | Scope | |---|---| | `GET /health/live` | public (process answers; reveals nothing) | | `GET /health/ready` | read | | `GET /v1/agenda`, `GET /v1/tasks`, `GET /v1/items/{id}`, `GET /v1/timeline`, `GET /v1/proposals/{id}`, `GET /v1/operations/{id}` | read | | `POST /v1/resolve`, `POST /v1/next`, `POST /v1/reviews`, `POST /v1/context/search` | read (read-only POSTs: searches and judgments) | | `POST /v1/tasks`, `PATCH /v1/tasks/{id}`, `POST /v1/tasks/{id}/complete\|reopen\|cancel` | write | | `POST /v1/events`, `PATCH /v1/events/{id}`, `POST /v1/events/{id}/cancel` | write | | `POST /v1/captures`, `POST /v1/context`, `POST /v1/plans`, `POST /v1/replans`, `POST /v1/proposals/{id}/apply\|reject` | write | | `POST /v1/sync` | admin | | any other path, and any unlisted `/v1` route | admin (fails closed) | Authentication is one middleware layer in front of the router, not per-handler code, so a route cannot forget it and a refusal happens before a body is parsed — an unauthenticated flood never reaches the journal or a calendar backend. Scopes are an explicit set with no implied hierarchy: a write credential is not an admin credential, and an admin-only credential cannot read. Operators list what a client needs (`scopes = ["read", "write"]`). **Identity, audit and replay.** The verified identity — never a request field — is the audit actor threaded into every journaling route (`operations.actor`, `life_events.actor`); `audit_actor()` is the only source of it, and the contract has no caller-supplied actor field (a body that names one is rejected as unknown input). `operation_belongs_to()` refuses a replay of an operation another *authenticated* identity recorded (HTTP 403); operations recorded before credentials existed keep the single-user default actor and stay replayable. Idempotency remains keyed by operation id and fingerprinted over the semantic request, which excludes credentials, so rotation is transparent. **Metering.** Failed authentication is counted over a fixed 60-second window; the 31st failure in a window is answered `429` (code `unauthenticated`, `retryable: true`) instead of `401`. The check runs *after* verification, so a valid credential is always served and an unauthenticated flood can never lock the legitimate client out. Degradation is bounded: a failed attempt costs a header parse and one SHA-256 over at most 256 bytes, and it never reaches a handler, the journal or a backend. Expensive queries stay bounded by that ordering plus the existing body-size and timeout limits. **Redaction (`src/logging/`, new).** `life::logging::init()` installs the daemon's subscriber with a redacting writer in front of stderr, so redaction is a property of the subscriber rather than of individual call sites: sensitive field values (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `token`, `api_key`, and the private-text fields `title`, `description`, `summary`, `text`, `query`, `body`, `payload`, `content`, `ics`) and `Bearer`/`Basic` credential arguments are replaced with `[REDACTED]` before any byte reaches a file, terminal or collector. Redaction is by shape, never by value, so the module holds no secret; a scheme in prose ("bearer authentication enabled") is left alone. The request-completion line logs exactly the permitted correlators — `request_id`, `actor_id`, `method`, `path` (no query string), `operation_id`, `status`, `duration_ms` — and refusals log their sanitized `code`. **`src/api/pagination.rs`.** The cursor's filter binding was compared with a short-circuiting slice comparison; it now uses `subtle::ConstantTimeEq`, matching the constant-time requirement. (Note: this tree had no `ring::constant_time::verify_slices_are_equal` call to replace — `cargo check` is warning-free before and after — so the only non-constant-time comparison in the cursor path was this one.) **Deferred chore.** `Cargo.toml` now declares `authors = ["Yuris Cakranegara <hello@yuris.dev>"]` in the same commit that adds `subtle = "2"`; `Cargo.lock` is committed. ### Decisions worth a reviewer's attention - **`/health/live` is public** (a container probe must answer without a credential and reveals only `{"status":"ok"}`); **`/health/ready` requires `read`**, per the scope column of the CLI/API table. Consequence: `life doctor`'s remote readiness probe is unauthenticated, so against a token-configured daemon it now reports readiness `401` rather than details. Making doctor present a credential needs a credential-selection policy (which identity a local operator command should act as) that this issue does not define, so it is left to the operator to probe with a scoped credential. - **With no `token_files` configured** there is no credential that could ever be accepted, so the daemon keeps its single-user local behavior and `serve` logs a warning at startup. A deployment always configures `token_files`; the test harnesses that serve routes without credentials are unaffected. - **`router_with_auth` is the daemon's entry point**; `router_with_state` builds the same routes with authentication off, which is what in-process route tests use. `serve` always builds the authenticator its configuration describes and fails startup if a configured secret file is unreadable. - **No description field exists in any implemented mutation body** (`TaskCreateRequest`, the patches), so "task descriptions never logged" is defended by the redaction layer's `description` rule (unit-tested) rather than by an end-to-end request; the end-to-end redaction test carries private titles, query text and resolve text instead. ## Verification - `nix develop --command cargo test` — all suites green: 519 tests across every binary, 0 failures (was 450 before this branch plus the merged siblings). - `nix develop --command cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt --check` — clean. - New `tests/auth_redaction.rs` (6 tests): the D03 scope matrix over the real router with a real journal and a mock Radicale (read→`POST /v1/tasks` 403 `forbidden`, write→`POST /v1/sync` 403 `forbidden`, admin-only credential refused a read, admin credential reaching the admin route, absent/unknown/one-byte-different credentials 401 `unauthenticated`, refused requests leaving zero operations and zero dispatched writes); the actor non-spoofability case (a caller-named `actor` is rejected and the recorded `operations.actor`/`life_events.actor` is `usr_alice`); the D04 rotation sequence (new credential replays `op_rot_1` with `changed: false`, the recorded item, exactly one `PUT` and one `operations` row in total, then the revoked credential 401 and a removed credential 401 while the remaining credential still works); cross-identity replay refused; the redaction proof (real subscriber captured with a non-empty-log guard, correlators asserted present, token/`Bearer`/`Authorization`/title/query/resolve text/`BEGIN:VTODO` asserted absent, plus a direct call-site log of a credential and a title that the subscriber redacts); and the metering bound (30×401 then 429, a valid credential still served, meter reset on success, nothing unauthenticated reaching the journal or backend). - Unit tests in `src/api/auth.rs` (constant-time verification behaviour: off-by-one rejected; the route table checked against every row of the CLI/API contract) and `src/logging/redaction.rs`/`src/logging/mod.rs` (header/credential/private-field redaction, prose not redacted, idempotency, line-split writes, unterminated tail, and the daemon's own subscriber redacting before the writer). - Manual end-to-end check against the real binary (`life serve --config …` over TCP with three token files): `/health/live` 200 without a credential; `/health/ready` 401 without and 200 with a read credential; `GET /v1/tasks` 401 unauthenticated; `POST /v1/sync` 403 with a write credential and 501 with an admin credential; `POST /v1/tasks` 403 with a read credential; startup log `bearer authentication enabled: every route but /health/live requires a scoped credential credentials=3`; grepping the daemon's stderr for all three credentials, the private query text and `Authorization` returned nothing. ## Related Issues Closes #35
Adds scoped bearer-token authentication in front of every route and moves
credentials and private text out of diagnostics (docs/issues/035, D03/D04).

* `src/api/auth.rs` verifies credentials as SHA-256 digests compared with
  `subtle::ConstantTimeEq` against every configured token file, refuses
  malformed credentials before any lookup, and never logs a token or digest.
  The route-to-scope table is derived from the Command-to-route table in
  `docs/technical/cli-api.md`; unlisted routes fail closed at `admin`.
* Authentication is one middleware layer, so a route cannot forget it, and
  refusals happen before a body is parsed: an unauthenticated flood never
  reaches the journal or a calendar backend. Failed attempts are metered
  (30 per 60s window, HTTP 429 beyond that) while a valid credential is
  always served.
* The authenticated identity — never a request field — is the audit actor
  recorded on operations and facts, and an operation is replayable only by
  the identity that recorded it. Idempotency stays scoped to the identity, so
  a rotated token replays a recorded operation with no duplicate write.
* `src/logging/redaction.rs` puts a redacting writer in front of the
  subscriber `main` installs, so credentials, authorization values and
  private fields cannot leave the process through diagnostics, and the
  request completion line carries request id, actor, route, operation key,
  status and duration only.
* `src/api/pagination.rs` compares the cursor's filter binding with
  `subtle::ConstantTimeEq` instead of a short-circuiting slice comparison.

Also lands the deferred `Cargo.toml` authors chore in the commit that adds
`subtle`: `Yuris Cakranegara <hello@yuris.dev>`.
Author
Owner

Coordinator review — APPROVED

Verified b8ba0a1 myself: cargo test 519 passed / 0 failed (6 scenario-named tests in tests/auth_redaction.rs), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • Auth is one layer no route can forget. auth_middleware is layered on the whole router after the routes and the fallback, and route_access(method, path) is a single table — so a new route cannot drift into a weaker scope. Unlisted /v1 routes and every non-/v1 path fail closed at admin; only /health/live is public, with the reason stated (a process probe that reveals nothing else). That is the correct default direction for an authorization table.
  • The comparison really is constant-time in shape: digests are compared with subtle::ConstantTimeEq and the loop deliberately examines every credential (found.is_none() rather than an early return), so a match costs the same as a mismatch. Credentials are wrapped in Zeroizing, malformed ones are refused before any lookup, and neither a token nor a digest is logged.
  • Actor spoofing is closed at both ends: audit_actor() comes only from the verified extension, and a body naming actor is rejected outright by deny_unknown_fields — so the recorded operations.actor/life_events.actor is the credential identity, and a cross-identity replay is refused.
  • D04 is tested as a sequence, not a snapshot: the new credential replays the earlier operation with changed:false and exactly one PUT and one operations row in total, while the revoked credential 401s immediately and the removed credential 401s while the remaining one still works. That is the evidence the criterion actually needs.
  • Redaction is proven on real output: the tests capture the daemon's own subscriber output with a non-empty-log guard, assert the correlators that should be there (request id, actor id, method, path, status, duration, operation id, refusal code) and the private strings that must not be (token, Bearer, Authorization, title, query text, BEGIN:VTODO), plus a direct call-site log of a credential and a title being redacted by the subscriber — and you re-verified by grepping the real binary's stderr.
  • You corrected my brief where it was stale: ring::constant_time::verify_slices_are_equal was not in this tree and the build was already warning-free, so the pagination change was a straight extension to ConstantTimeEq rather than a deprecation fix. Noted and appreciated; the deviation is disclosed in the PR.

Deferred chore complete: Cargo.toml now reads authors = ["Yuris Cakranegara <hello@yuris.dev>"] alongside subtle = "2", with Cargo.lock committed. That was the last outstanding chore from the #11 review round, and it landed in exactly the PR I predicted it would.

Merging.

## Coordinator review — APPROVED Verified `b8ba0a1` myself: `cargo test` **519 passed / 0 failed** (6 scenario-named tests in `tests/auth_redaction.rs`), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **Auth is one layer no route can forget.** `auth_middleware` is layered on the whole router *after* the routes and the fallback, and `route_access(method, path)` is a single table — so a new route cannot drift into a weaker scope. Unlisted `/v1` routes and every non-`/v1` path fail closed at `admin`; only `/health/live` is public, with the reason stated (a process probe that reveals nothing else). That is the correct default direction for an authorization table. - **The comparison really is constant-time in shape:** digests are compared with `subtle::ConstantTimeEq` and the loop deliberately examines every credential (`found.is_none()` rather than an early return), so a match costs the same as a mismatch. Credentials are wrapped in `Zeroizing`, malformed ones are refused before any lookup, and neither a token nor a digest is logged. - **Actor spoofing is closed at both ends:** `audit_actor()` comes only from the verified extension, and a body naming `actor` is rejected outright by `deny_unknown_fields` — so the recorded `operations.actor`/`life_events.actor` is the credential identity, and a cross-identity replay is refused. - **D04 is tested as a sequence, not a snapshot:** the new credential replays the earlier operation with `changed:false` and exactly one PUT and one `operations` row in total, while the revoked credential 401s immediately and the removed credential 401s while the remaining one still works. That is the evidence the criterion actually needs. - **Redaction is proven on real output:** the tests capture the daemon's own subscriber output with a non-empty-log guard, assert the correlators that *should* be there (request id, actor id, method, path, status, duration, operation id, refusal code) and the private strings that must not be (token, `Bearer`, `Authorization`, title, query text, `BEGIN:VTODO`), plus a direct call-site log of a credential and a title being redacted by the subscriber — and you re-verified by grepping the real binary's stderr. - **You corrected my brief where it was stale:** `ring::constant_time::verify_slices_are_equal` was not in this tree and the build was already warning-free, so the pagination change was a straight extension to `ConstantTimeEq` rather than a deprecation fix. Noted and appreciated; the deviation is disclosed in the PR. **Deferred chore complete:** `Cargo.toml` now reads `authors = ["Yuris Cakranegara <hello@yuris.dev>"]` alongside `subtle = "2"`, with `Cargo.lock` committed. That was the last outstanding chore from the #11 review round, and it landed in exactly the PR I predicted it would. Merging.
yuris merged commit 772f0f27f0 into main 2026-09-23 00:38:58 +00:00
yuris changed title from feat(api): scoped bearer token auth and diagnostic redaction (#35) to feat(api): scoped bearer token auth and diagnostic redaction 2026-09-23 05:12:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!65
No description provided.