feat(api): scoped bearer token auth and diagnostic redaction #65
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-35-auth"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
The daemon had no authentication at all:
AppStatecarried a hard-codedACTORand 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_fileswas 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 thesubtledependency, also lands the deferredCargo.tomlauthors chore.Changes
Authentication (
src/api/auth.rs, new).Authenticatorreads 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 usingsubtle::ConstantTimeEq, with no early exit, so neither the match position nor a near-miss is observable. Malformed presentations — missing header, non-Bearerscheme, 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 indocs/technical/cli-api.md:GET /health/liveGET /health/readyGET /v1/agenda,GET /v1/tasks,GET /v1/items/{id},GET /v1/timeline,GET /v1/proposals/{id},GET /v1/operations/{id}POST /v1/resolve,POST /v1/next,POST /v1/reviews,POST /v1/context/searchPOST /v1/tasks,PATCH /v1/tasks/{id},POST /v1/tasks/{id}/complete|reopen|cancelPOST /v1/events,PATCH /v1/events/{id},POST /v1/events/{id}/cancelPOST /v1/captures,POST /v1/context,POST /v1/plans,POST /v1/replans,POST /v1/proposals/{id}/apply|rejectPOST /v1/sync/v1routeAuthentication 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(codeunauthenticated,retryable: true) instead of401. 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 fieldstitle,description,summary,text,query,body,payload,content,ics) andBearer/Basiccredential 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 sanitizedcode.src/api/pagination.rs. The cursor's filter binding was compared with a short-circuiting slice comparison; it now usessubtle::ConstantTimeEq, matching the constant-time requirement. (Note: this tree had noring::constant_time::verify_slices_are_equalcall to replace —cargo checkis warning-free before and after — so the only non-constant-time comparison in the cursor path was this one.)Deferred chore.
Cargo.tomlnow declaresauthors = ["Yuris Cakranegara <hello@yuris.dev>"]in the same commit that addssubtle = "2";Cargo.lockis committed.Decisions worth a reviewer's attention
/health/liveis public (a container probe must answer without a credential and reveals only{"status":"ok"});/health/readyrequiresread, 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 readiness401rather 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.token_filesconfigured there is no credential that could ever be accepted, so the daemon keeps its single-user local behavior andservelogs a warning at startup. A deployment always configurestoken_files; the test harnesses that serve routes without credentials are unaffected.router_with_authis the daemon's entry point;router_with_statebuilds the same routes with authentication off, which is what in-process route tests use.servealways builds the authenticator its configuration describes and fails startup if a configured secret file is unreadable.TaskCreateRequest, the patches), so "task descriptions never logged" is defended by the redaction layer'sdescriptionrule (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.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/tasks403forbidden, write→POST /v1/sync403forbidden, admin-only credential refused a read, admin credential reaching the admin route, absent/unknown/one-byte-different credentials 401unauthenticated, refused requests leaving zero operations and zero dispatched writes); the actor non-spoofability case (a caller-namedactoris rejected and the recordedoperations.actor/life_events.actorisusr_alice); the D04 rotation sequence (new credential replaysop_rot_1withchanged: false, the recorded item, exactly onePUTand oneoperationsrow 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:VTODOasserted 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).src/api/auth.rs(constant-time verification behaviour: off-by-one rejected; the route table checked against every row of the CLI/API contract) andsrc/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).life serve --config …over TCP with three token files):/health/live200 without a credential;/health/ready401 without and 200 with a read credential;GET /v1/tasks401 unauthenticated;POST /v1/sync403 with a write credential and 501 with an admin credential;POST /v1/tasks403 with a read credential; startup logbearer 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 andAuthorizationreturned nothing.Related Issues
Closes #35
Coordinator review — APPROVED
Verified
b8ba0a1myself:cargo test519 passed / 0 failed (6 scenario-named tests intests/auth_redaction.rs),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
auth_middlewareis layered on the whole router after the routes and the fallback, androute_access(method, path)is a single table — so a new route cannot drift into a weaker scope. Unlisted/v1routes and every non-/v1path fail closed atadmin; only/health/liveis public, with the reason stated (a process probe that reveals nothing else). That is the correct default direction for an authorization table.subtle::ConstantTimeEqand 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 inZeroizing, malformed ones are refused before any lookup, and neither a token nor a digest is logged.audit_actor()comes only from the verified extension, and a body namingactoris rejected outright bydeny_unknown_fields— so the recordedoperations.actor/life_events.actoris the credential identity, and a cross-identity replay is refused.changed:falseand exactly one PUT and oneoperationsrow 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.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.ring::constant_time::verify_slices_are_equalwas not in this tree and the build was already warning-free, so the pagination change was a straight extension toConstantTimeEqrather than a deprecation fix. Noted and appreciated; the deviation is disclosed in the PR.Deferred chore complete:
Cargo.tomlnow readsauthors = ["Yuris Cakranegara <hello@yuris.dev>"]alongsidesubtle = "2", withCargo.lockcommitted. That was the last outstanding chore from the #11 review round, and it landed in exactly the PR I predicted it would.Merging.
feat(api): scoped bearer token auth and diagnostic redaction (#35)to feat(api): scoped bearer token auth and diagnostic redaction