Very barebones, untrustworthy, barely reviewed auth layer. LGTM. Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com> Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
26 KiB
Datastore Auth: Development History
Branch: swactor-auth
Base commit: af15416 (pre-auth baseline)
5 commits + uncommitted working tree changes
What Was Built
A complete ed25519 authorization layer for the distributed datastore, spanning:
- Auth engine —
AuthzEnginewith ACL, signed request verification, replay protection, nonce GC - GatewayActor — actor-level enforcement point with grant/revoke, access requests, key listing
- Browser auth flow — WASM Ed25519 crypto, device key generation, access request/grant/deny lifecycle
- Admin page — owner key upload, pending request management, manual key grant, authorized key list
- Expanded CLI — full CRUD + auth subcommands (
grant,revoke,requests,keys,deny) with name resolution - Storage persistence — entry/manifest persistence to filesystem, startup bulk-load
- xtask —
node,cli,wasmsubcommands withconfig.tomlsupport - WASM crypto crate —
crates/crypto-wasm/, ano_stdcdylib exportinged25519_sign(),get_public_key(),buffer_ptr()
The auth system enforces binary access control (authorized or not) at the HTTP API boundary. Internal actors remain auth-unaware. Two auth paths: connection-level (iroh QUIC handshake proves NodeId) and per-request signed envelopes (for browser/HTTP API). This branch implements Path 2 end-to-end, including the browser UX.
Architecture
┌──────────────────────────────────────────────────────┐
│ HTTP API (api.rs) │
│ │
│ Ungated: │
│ GET / → browser UI (access page) │
│ GET /admin → admin page │
│ GET /crypto.wasm → WASM Ed25519 module │
│ GET /api/status → node identity │
│ │
│ Auth-gated (X-Signed-Request header): │
│ POST /api/put → check_auth → handle_put │
│ GET /api/get → check_auth → handle_get │
│ GET /api/data → check_auth → handle_data │
│ POST /api/delete → check_auth → handle_delete│
│ GET /api/list → check_auth → handle_list │
│ │
│ Auth management (owner-only): │
│ POST /api/auth/grant → check_auth_identity │
│ POST /api/auth/revoke → check_auth_identity │
│ GET /api/auth/requests→ check_auth_identity │
│ GET /api/auth/keys → check_auth_identity │
│ POST /api/auth/deny → check_auth_identity │
│ │
│ Signature-only (proves key, no ACL check): │
│ POST /api/auth/request → check_auth_sig_only │
└───────────────┬─────────────────────────────────────┘
│
GatewayMsg (various)
│
┌───────────────▼───────────────┐
│ GatewayActor │
│ │
│ AuthzEngine: │
│ 1. verify ed25519 signature │
│ 2. check timestamp ±300s │
│ 3. check nonce uniqueness │
│ 4. check ACL │
│ │
│ Access request management: │
│ pending_requests HashMap │
│ grant resolves label from │
│ pending request name │
│ │
│ ACL persistence: │
│ persist_acl() on grant/ │
│ revoke │
└───────────────┬───────────────┘
│
┌───────────────▼───────────────┐
│ DatastoreNode │
│ │
│ MetadataActor ◄──► BlobStore │
│ (auth-unaware) │
└───────────────────────────────┘
┌───────────────────────────────┐
│ Browser (WASM Ed25519) │
│ │
│ /crypto.wasm → initCrypto() │
│ deviceKeySeed in localStorage │
│ signBytes() per request │
│ Access action for all ops │
│ → X-Signed-Request header │
└───────────────────────────────┘
┌───────────────────────────────┐
│ CLI (store_cli) │
│ │
│ --key owner.key.json │
│ Per-action signing: │
│ Put/Get/Delete/List/Access │
│ Name resolution for │
│ grant/revoke/deny │
└───────────────────────────────┘
Commit-by-Commit
863185e — feat: distributed datastore primitives protocol
Foundation commit establishing the distributed datastore protocol. Defined the protocol messages (GetChunkRequest, FindObjectRequest, StoreObjectRequest, ListObjectsRequest and their responses), all implementing NetworkMessage with stable type_tag() strings. This is the wire protocol for inter-node communication over iroh/QUIC.
Key files: src/messages.rs (inter-node message types)
84c4408 — fix: cli for datastore works
Brought up the store_node and store_cli binaries as [[bin]] targets with feature-gated dependencies (node and cli features). The node binary spawns the actor runtime, wires up BlobStoreActor/MetadataActor/DatastoreNode, and serves the HTTP API via tiny_http. The CLI binary talks to the node over HTTP with ureq. Added clap for arg parsing, ctrlc for graceful shutdown, and runtime-dashboard integration.
Key files: Cargo.toml (features node/cli), src/bin/store_node.rs, src/bin/store_cli.rs, src/api.rs
ebee109 — feat: mvp auth protocol
Core auth implementation:
src/auth.rs—DatastoreActionenum,SignedRequestPayload,SignedRequestenvelope,AccessControlList(with JSON persistence viasave()/load_or_create()),AuthzEngine(4-step verification: signature, timestamp, nonce, ACL),sign_request()/verify_signed_request()helpers,AuthzResult/DeniedReasonenums.src/actors/gateway.rs—GatewayActorwrappingAuthzEngine. HandlesAuthorize(pure auth check),HandleSignedRequest(auth + dispatch viaaction_to_node_msg()),CheckConnection(Path 1),Grant/Revoke(owner-only ACL mutations),NonceGcTick.src/messages.rs—GatewayMsgenum,DatastoreResponse::Deniedvariant.crates/shared-types/— ExtractedContentHashinto its own crate to break dependency cycles betweendistributionanddatastore.
Tests added (18 total):
auth_scenario_tests.rs(10 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.
8ac45e5 — 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.DatastoreAction::Listusesname_filter.GatewayActor::action_to_node_msg()maps actions toDatastoreNodeMsgvariants.- 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.
e549eef — feat: auth MVP with integrated tests
Wired auth into both binaries:
store_node.rs — --auth and --auth-dir <PATH> flags:
- Loads or generates owner keypair from
<auth-dir>/owner.key.json. - Owner keypair's public key becomes the
NodeId(deterministic identity across restarts). - Loads/creates
<auth-dir>/acl.jsonwith owner as sole authorized key. - Spawns
GatewayActorand passesSome(gateway_addr)tostart_api_server. - Sends
GatewayMsg::NonceGcTickon the same cadence as the metadata GC tick.
store_cli.rs — --key <PATH> flag:
- Each command builds the appropriate
DatastoreAction, signs it, sends asX-Signed-Requestheader. statusnever signs (always open by design).
http_auth_integration.rs — Full-stack integration test: spins up the actor runtime with GatewayActor, starts the HTTP server, proves owner is allowed (PUT/GET/LIST/DELETE), stranger gets 403, missing header gets 401.
Uncommitted Working Tree Changes
The uncommitted changes represent the bulk of the user-facing work: browser UI, admin page, WASM crypto, expanded CLI, storage persistence, and xtask.
Browser UI (ui_html.rs — DATASTORE_UI_HTML)
Complete browser access page served at /:
- Upload panel — file input + optional name, PUT via
authFetch() - Object table — list all objects with hash, name, size; download and delete buttons
- Detail modal — click a row to see full metadata, chunks, tags
- Auth detection — on load,
detectAuth()fetches/api/list; if 401, enables auth mode - WASM crypto integration —
initCrypto()fetches/crypto.wasm,initKeys()generates or loads device seed fromlocalStorage, derives public key via WASM - Auth banner — shown when user is not authorized, with access request form (name + optional message)
- Pending state — after submitting request, shows "waiting for operator approval" with 5-second polling; auto-refreshes when granted
- Device key display — shows truncated public key hex in header when auth is active
- JWK migration — handles legacy
localStorage.deviceKey(JWK format) by extracting thedparameter as seed
Admin Page (ui_html.rs — DATASTORE_ADMIN_HTML)
Owner administration page served at /admin:
- Owner key upload — file input for
key.json, loads secret/public key hex, derives via WASM to verify, test call to/api/auth/requeststo confirm ownership - Pending access requests table — name, message, key (truncated), grant/deny buttons
- Authorized keys table — name (label), key (truncated), revoke button
- Manual grant form — input for 64-char hex public key + optional name
- Name disambiguation — when multiple entries share the same name, appends
(key_prefix)suffix ownerAuthFetch()— signs all admin API calls withDatastoreAction::Access
WASM Ed25519 Crypto (crates/crypto-wasm/)
New no_std Rust crate compiled to wasm32-unknown-unknown:
Cargo.toml—swactor-crypto-wasm,cdylibcrate type, depends oned25519-dalek(no default features)src/lib.rs— Three exported functions:buffer_ptr()→ pointer to 8192-byte shared bufferget_public_key()— reads 32-byte seed fromBUF[0..32], writes public key toBUF[32..64]ed25519_sign(msg_len)— reads seed fromBUF[0..32], message fromBUF[128..128+msg_len], writes 64-byte signature toBUF[64..128]
crypto_wasm.wasm— pre-built binary embedded in the datastore viainclude_bytes!("crypto_wasm.wasm")- Served at
/crypto.wasmendpoint (ungated) - Replaces the earlier Web Crypto API approach — Web Crypto's Ed25519 support is inconsistent across browsers; WASM provides deterministic behavior using the same
ed25519-dalekcrate as the Rust backend
Expanded GatewayActor (actors/gateway.rs)
New message handlers beyond the original Authorize/HandleSignedRequest/CheckConnection/Grant/Revoke:
VerifySignature— callscheck_signature_only()(no ACL check). Used for access request submissions where the caller needs to prove key ownership without being in the ACL.SubmitAccessRequest— storesAccessRequestInfo { key, name, message, requested_at }inpending_requests: HashMap<NodeId, AccessRequestInfo>.ListAccessRequests— owner-only; returns all pending requests.DenyAccessRequest— owner-only; removes a pending request.ListAuthorizedKeys— owner-only; returnsVec<AuthorizedKeyInfo>with labels.
Grant now resolves labels: when granting a key that has a pending request, the request's name field becomes the key's label (unless an explicit label is provided).
Expanded Auth Types (auth.rs)
AccessRequestInfo—{ key: NodeId, name: String, message: String, requested_at: u64 }AuthorizedKeyInfo—{ key: NodeId, label: String }DatastoreAction::Access— new variant for browser-originated requests that prove identity without binding to specific content. The browser usesAccessfor all operations (auth is at the HTTP layer).key_labels: HashMap<String, String>added toAccessControlList— maps hex public key to human-readable name. Populated bygrant(), removed byrevoke().check_signature_only()onAuthzEngine— verifies signature, timestamp, and nonce but skips ACL check.authorized_key_list()onAuthzEngine— returns all authorized keys with their labels.
Storage Persistence (storage/mod.rs, storage/in_memory.rs)
Extended StorageBackend trait with entry persistence:
write_entry()/read_entry()/delete_entry()/list_entries()— persistObjectEntryJSON to diskFilesystemBackendlayout extended:{root}/ ├── chunks/{hex[0..2]}/{hex[2..4]}/{full_hex_hash} ├── manifests/{hex[0..2]}/{hex[2..4]}/{full_hex_hash} └── entries/{hex[0..2]}/{hex[2..4]}/{full_hex_hash}BlobStoreMsg::WriteEntry/DeleteEntry— fire-and-forget messages for entry persistenceBlobStoreMsg::LoadAll— startup bulk-load of all entries + their manifestsMetadataMsg::BulkLoad— injects loaded entries into MetadataActor's indexstore_node.rsstartup sequence — sendsLoadAllto BlobStoreActor, polls forLoadedAllresponse, sendsBulkLoadto MetadataActor
Expanded CLI (store_cli.rs)
Full CRUD + auth management subcommands:
| Subcommand | Auth | Description |
|---|---|---|
put <path> [--name] |
--key signs DatastoreAction::Put |
Upload a file |
get <hash> [--output] |
--key signs DatastoreAction::Get |
Metadata or download |
delete <hash> |
--key signs DatastoreAction::Delete |
Delete an object |
list [--name] [--all] |
--key signs DatastoreAction::List |
List objects |
status |
Never signed | Node identity |
grant <key_or_name> [--name] |
--key signs Access |
Authorize a key (owner-only) |
revoke <key_or_name> |
--key signs Access |
Revoke a key (owner-only) |
requests |
--key signs Access |
List pending access requests |
keys |
--key signs Access |
List authorized keys |
deny <key_or_name> |
--key signs Access |
Deny a pending request |
Name resolution: grant, revoke, and deny accept either a 64-char hex key or a human-readable name. When given a name, the CLI fetches the relevant list from the API and resolves the name to a key. Disambiguated names ("alice (c9d0e1f2)") are supported.
xtask (xtask/src/main.rs)
Development task runner with three new subcommands beyond the existing test:
cargo xtask node— builds and runsswactor-store-node. Flags:--port,--storage-path,--auth(default: true),--auth-dir. Builds with--features nodefirst, then runs the binary directly (not viacargo run) to avoid SIGINT issues. Ignores SIGINT in the xtask process so the child handles Ctrl-C.cargo xtask cli— builds and runsswactor-store. Flags:--url,--key. Auto-detects./auth/owner.key.jsonif present. Passes extra args through.cargo xtask wasm— buildsswactor-crypto-wasmforwasm32-unknown-unknown --release, copies the output tocrates/datastore/src/crypto_wasm.wasm, optionally runswasm-strip.config.tomlsupport — readsxtask/config.tomlfor default values (node port, storage path, auth settings, CLI url/key).
xtask/Cargo.toml — added toml, serde, libc dependencies.
HTTP API Expansion (api.rs)
New endpoints:
| Method | Path | Auth | Handler |
|---|---|---|---|
POST |
/api/auth/grant?key=<hex>[&name=<label>] |
Owner (full check) | handle_auth_grant |
POST |
/api/auth/revoke?key=<hex> |
Owner (full check) | handle_auth_revoke |
POST |
/api/auth/request |
Signature-only | handle_auth_request |
GET |
/api/auth/requests |
Owner (full check) | handle_auth_requests_list |
GET |
/api/auth/keys |
Owner (full check) | handle_auth_keys_list |
POST |
/api/auth/deny?key=<hex> |
Owner (full check) | handle_auth_deny |
GET |
/ |
None | Browser UI |
GET |
/admin |
None | Admin page |
GET |
/crypto.wasm |
None | WASM module |
New internal functions:
check_auth_identity()— likecheck_auth()but returns the caller'sNodeId(needed for grant/revoke to identify the requester).check_auth_signature_only()— verifies signature without ACL check (for access request submission).respond_wasm(),respond_admin_html()— serve the new static assets.CRYPTO_WASMconstant —include_bytes!("crypto_wasm.wasm").
DatastoreResponse Expansion (messages.rs)
New response variants:
AccessRequests { requests: Vec<AccessRequestInfo> }— response toListAccessRequestsAuthorizedKeys { keys: Vec<AuthorizedKeyInfo> }— response toListAuthorizedKeysLoadedAll { entries: Vec<(ObjectEntry, ObjectManifest)> }— response toBlobStoreMsg::LoadAll
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"
}
Generated by the node on first --auth run. The CLI reads it via --key. The admin page uploads it for authentication. The browser generates a simpler device seed (32 random bytes stored as hex in localStorage.deviceKeySeed).
Test Summary
| Test File | Count | What |
|---|---|---|
auth_scenario_tests.rs |
10 | 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 PUT/GET/LIST/DELETE, stranger 403, no-header 401 |
| Auth total | 17 |
Pre-existing datastore tests (blob_store, metadata, datastore_node, chunking, gc, storage, transfer, multi_node, api_integration, dashboard_integration) continue to pass.
Design Decisions
-
WASM Ed25519 over Web Crypto — Web Crypto's Ed25519 support varies by browser (Safari lacking, Firefox gated behind flags as of early 2026). A WASM module using
ed25519-dalekwithno_stdgives deterministic, cross-browser behavior and byte-level compatibility with the Rust backend. The compiled module is ~27KB stripped. -
DatastoreAction::Accessfor browser ops — The browser signs a lightweightAccessaction for every API call rather than constructing per-operation payloads. This simplifies the browser JS (no need to compute content hashes client-side) while still proving identity. The actual data operations are auth-gated at the HTTP layer. -
Signature-only check for access requests —
POST /api/auth/requestusescheck_auth_signature_only()which verifies the signature/timestamp/nonce but skips the ACL check. This allows an unauthorized user to prove key ownership when requesting access, without being in the ACL yet. -
Key labels in ACL —
key_labels: HashMap<String, String>maps hex public key to human-readable name. Labels are set on grant (from the access request'snamefield or an explicit--nameflag) and removed on revoke. This enables the admin page and CLI to show meaningful names instead of raw hex keys. -
Access request flow — Instead of requiring out-of-band key exchange, browser users can submit an access request with their name and a message. The request is stored in-memory in the GatewayActor's
pending_requests. The owner can grant or deny from the admin page or CLI. On grant, the pending request is removed and its name becomes the key label. -
Entry persistence —
StorageBackendtrait extended withwrite_entry()/read_entry()/delete_entry()/list_entries(). TheFilesystemBackendstores entries as JSON files in aentries/directory with the same 2-level hex sharding as chunks. On startup,BlobStoreMsg::LoadAllreads all entries and their manifests, thenMetadataMsg::BulkLoadinjects them into the MetadataActor's index. This means stored objects survive node restarts. -
xtask builds then execs —
cargo xtask nodeandcargo xtask clibuild the binary first, then exec it directly (not viacargo run). This avoids cargo sitting in the process chain and dying from SIGINT before the node finishes its shutdown sequence. -
Status endpoint stays open —
/api/status,/,/admin, and/crypto.wasmare never auth-gated. Status enables health checks; the UI/admin pages need to be loadable before authentication; the WASM module is needed to perform authentication. -
ACL persisted to auth-dir — The ACL is stored at
<auth-dir>/acl.json(default:./auth/acl.json), not inside the storage path. This separates auth config from data storage. -
CLI name resolution —
grant,revoke, anddenyaccept human-readable names in addition to hex keys. When given a name, the CLI fetches the pending requests or authorized keys list from the API and resolves the name. If multiple entries match, it prints disambiguated names (e.g.,"alice (c9d0e1f2)") and asks the user to re-run.
File Inventory
| File | What |
|---|---|
crates/shared-types/ |
ContentHash crate (breaks dependency cycles) |
crates/crypto-wasm/Cargo.toml |
WASM crypto crate config |
crates/crypto-wasm/src/lib.rs |
no_std Ed25519 sign/derive/buffer exports |
crates/datastore/src/crypto_wasm.wasm |
Pre-built WASM binary (embedded via include_bytes!) |
crates/datastore/Cargo.toml |
Feature flags (node/cli), dependencies |
crates/datastore/src/auth.rs |
Auth engine, ACL, signing, verification, access request types |
crates/datastore/src/actors/gateway.rs |
GatewayActor — auth enforcement + access request management |
crates/datastore/src/actors/blob_store.rs |
BlobStoreActor — entry persistence, LoadAll |
crates/datastore/src/actors/metadata.rs |
MetadataActor — entry persistence writes, BulkLoad |
crates/datastore/src/messages.rs |
GatewayMsg, BlobStoreMsg (WriteEntry/DeleteEntry/LoadAll), DatastoreResponse extensions |
crates/datastore/src/api.rs |
HTTP API — auth endpoints, WASM/admin serving, auth checking functions |
crates/datastore/src/ui_html.rs |
Browser UI (access page) + Admin page HTML/CSS/JS |
crates/datastore/src/storage/mod.rs |
StorageBackend trait (entry methods), FilesystemBackend |
crates/datastore/src/storage/in_memory.rs |
InMemoryBackend (entry methods) |
crates/datastore/src/bin/store_node.rs |
Node binary — --auth, --auth-dir, keypair mgmt, gateway spawn, bulk-load |
crates/datastore/src/bin/store_cli.rs |
CLI binary — --key, all subcommands, name resolution |
xtask/Cargo.toml |
xtask dependencies (toml, serde, libc) |
xtask/src/main.rs |
node, cli, wasm subcommands, config.toml support |
docs/datastore/DATASTORE_AUTH.md |
Auth specification document |
tests/auth_scenario_tests.rs |
10 AuthzEngine scenario tests |
tests/acl_persistence_tests.rs |
2 ACL persistence tests |
tests/gateway_tests.rs |
4 GatewayActor tests |
tests/http_auth_integration.rs |
1 full-stack HTTP auth integration test |