9.9 KiB
Datastore Auth: Development History
Branch: swactor-auth
Base commit: 3d5a539 (feat: distributed datastore primitives protocol)
Companion spec: DATASTORE_AUTH.md (root)
What Was Built
An ed25519 authorization layer for the datastore, spanning the full stack from crypto primitives through actor enforcement to CLI/binary wiring. Three commits of protocol work, plus uncommitted binary integration.
The auth system enforces binary access control (authorized or not) at the HTTP API boundary. Internal actors remain auth-unaware. Two auth paths exist: connection-level (iroh QUIC handshake proves NodeId) and per-request signed envelopes (for browser relay and HTTP API). This work implements Path 2 end-to-end.
Commit-by-Commit
ebf778f — fix: cli for datastore works
Brought up the store_node and store_cli binaries as [[bin]] targets with feature-gated dependencies. The node binary spawns the actor runtime, wires up BlobStoreActor/MetadataActor/DatastoreNode, and serves the HTTP API. The CLI binary talks to the node over HTTP with ureq. Added tiny_http for the API server, clap for arg parsing, ctrlc for graceful shutdown, and the runtime-dashboard integration.
Key files: Cargo.toml (features node/cli), src/bin/store_node.rs, src/bin/store_cli.rs, src/api.rs
7c1c2c1 — feat: mvp auth protocol
Core auth implementation:
src/auth.rs—DatastoreAction,SignedRequestPayload,SignedRequest,AccessControlList(with JSON persistence),AuthzEngine(signature verification, timestamp window, nonce replay detection, ACL check),sign_request()/verify_signed_request()helpers,DeniedReasonenum.src/actors/gateway.rs—GatewayActorwrappingAuthzEngine. HandlesAuthorize(pure auth check),HandleSignedRequest(auth + dispatch),CheckConnection(Path 1),Grant/Revoke(owner-only ACL mutations),NonceGcTick. TranslatesDatastoreActiontoDatastoreNodeMsgviaaction_to_node_msg().src/messages.rs—GatewayMsgenum,DatastoreResponse::Deniedvariant.shared-typescrate — ExtractedContentHashinto its own crate so bothdistributionanddatastorecan depend on it without cycles.
Tests added:
auth_scenario_tests.rs(12 tests) — owner access, stranger denial, grant/revoke lifecycle, signed request happy path, tampered signature, stale timestamp, replayed nonce, nonce GC, non-owner grant/revoke rejection.acl_persistence_tests.rs(2 tests) — save/load round-trip, create-on-missing.gateway_tests.rs(4 tests) — connection allow/deny, signed request flow-through, unauthorized signed request denial.
6366c6b — fix: adjust auth protocol to datastore protocol
Aligned the auth types with the content-hash-first datastore protocol:
DatastoreAction::Putcarriescontent_hash,size_bytes, andtags(not raw data).DatastoreAction::Get/Deleteusecontent_hash(not name).DatastoreAction::Listusesname_filter.GatewayActor::action_to_node_msgmaps actions to the existingDatastoreNodeMsgvariants.- Wired
check_auth()into the HTTP API handlers (put, get, data, delete, list) — readsX-Signed-Requestheader, sendsGatewayMsg::Authorizeto the gateway actor, denies with 401/403/504 on failure. handle_statusintentionally left ungated.
Uncommitted — Wire auth into node & CLI binaries
The auth engine and HTTP gate existed but neither binary used them. This change connects them:
store_node.rs — --auth and --auth-dir <PATH> flags:
- When
--auth: loads or generates owner keypair from<auth-dir>/owner.key.json(JSON with hex-encoded keys, version field, public_key for inspection, ISO-8601 created_at). - Owner keypair's public key becomes the
NodeId(deterministic identity across restarts). - Loads/creates
<auth-dir>/acl.jsonwith owner as sole authorized key. - Spawns
GatewayActorwith theAuthzEngineand passesSome(gateway_addr)tostart_api_server. - Sends
GatewayMsg::NonceGcTickon the same cadence as the metadata GC tick. - Without
--auth, behavior is unchanged (random NodeId, no gateway,Nonepassed to API).
store_cli.rs — --key <PATH> flag:
- Loads keypair from the same JSON key file format.
- Each command (put/get/delete/list) builds the appropriate
DatastoreAction, creates aSignedRequestPayloadwith current timestamp +getrandomnonce, signs it, and sends the JSON asX-Signed-Requestheader. statuscommand never signs (always open per design).- Without
--key, no header is sent (backward compatible with non-auth nodes).
Cargo.toml — Added getrandom = { version = "0.2", optional = true } to the cli feature.
Architecture
┌──────────────────────────────────┐
│ HTTP API (api.rs) │
│ │
│ /api/status ──► handle_status │ (no auth)
│ /api/put ──► check_auth ──► │
│ /api/get ──► check_auth ──► │
│ /api/data ──► check_auth ──► │ X-Signed-Request
│ /api/delete ──► check_auth ──► │ header required
│ /api/list ──► check_auth ──► │ when gateway_addr
│ │ is Some
└───────────┬───────────────────────┘
│
GatewayMsg::Authorize
│
┌───────────▼───────────┐
│ GatewayActor │
│ │
│ 1. verify signature │
│ 2. check timestamp │
│ 3. check nonce │
│ 4. check ACL │
│ │
│ DatastoreResponse:: │
│ Bool(true) or │
│ Denied { reason } │
└───────────────────────┘
┌───────────────────────┐
│ CLI (store_cli) │
│ │
│ --key owner.key.json │
│ │
│ sign_action(): │
│ timestamp + nonce │
│ + DatastoreAction │
│ → ed25519 sign │
│ → JSON header │
└───────────────────────┘
Key File Format
owner.key.json / any client key.json:
{
"version": 1,
"secret_key": "...64 hex chars (32 bytes)...",
"public_key": "...64 hex chars (32 bytes)...",
"created_at": "2026-02-15T12:00:00Z"
}
Shared between node and CLI. The node generates it on first --auth run; the CLI reads it with --key.
Test Summary
| Test File | Count | What |
|---|---|---|
auth_scenario_tests.rs |
12 | AuthzEngine: signing, verification, timestamp, nonce, ACL, grant/revoke |
acl_persistence_tests.rs |
2 | ACL JSON round-trip, create-on-missing |
gateway_tests.rs |
4 | GatewayActor: connection check, signed request flow, denial |
http_auth_integration.rs |
1 | Full HTTP stack: owner allowed, stranger gets 401/403 |
| Auth total | 19 | |
| Overall total | 107 | (93 pre-auth + 14 new auth + inherited datastore tests) |
Design Decisions
-
Key file format is JSON with hex encoding — human-readable, inspectable with
cat, foundation for future keystore without needing a binary format parser. -
Status endpoint stays open —
/api/statusis not gated even when auth is enabled. This lets monitoring tools and health checks work without credentials. -
Node identity = owner keypair's public key — when
--authis enabled, the keypair'snode_id()replaces random generation. The node has a stable, cryptographic identity across restarts. -
Nonce source is
getrandom— cryptographically secure 16-byte random nonces. Already a transitive dependency viaed25519-dalek/rand_core. -
Backward compatible — without
--auth(node) or--key(CLI), everything works exactly as before. No breaking changes. -
Auth is opt-in per binary — the auth engine, ACL, and gateway actor are always compiled (they're in the lib), but only activated when the binary flags are set. This keeps the default experience frictionless.
Files Changed (Full Branch)
| File | What |
|---|---|
crates/shared-types/ |
New crate — extracted ContentHash to break dependency cycles |
crates/datastore/src/auth.rs |
Auth engine, ACL, signing, verification |
crates/datastore/src/actors/gateway.rs |
GatewayActor — auth enforcement point |
crates/datastore/src/messages.rs |
GatewayMsg, DatastoreResponse::Denied |
crates/datastore/src/api.rs |
HTTP API with check_auth gate |
crates/datastore/src/bin/store_node.rs |
--auth, --auth-dir, keypair management, gateway spawn |
crates/datastore/src/bin/store_cli.rs |
--key, per-request signing |
crates/datastore/Cargo.toml |
getrandom dep, feature updates |
DATASTORE_AUTH.md |
Auth specification document |
tests/auth_scenario_tests.rs |
12 auth engine tests |
tests/acl_persistence_tests.rs |
2 ACL persistence tests |
tests/gateway_tests.rs |
4 gateway actor tests |
tests/http_auth_integration.rs |
1 full-stack HTTP auth test |