Compare commits
3 commits
ebf778fc3f
...
080adbcf3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 080adbcf3f | |||
| 6366c6bab7 | |||
| 7c1c2c167d |
22 changed files with 2556 additions and 136 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -1129,6 +1129,7 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-types",
|
||||
"swactor",
|
||||
"tokio",
|
||||
]
|
||||
|
|
@ -4094,6 +4095,14 @@ dependencies = [
|
|||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
|
|
@ -4383,10 +4392,12 @@ dependencies = [
|
|||
"clap",
|
||||
"ctrlc",
|
||||
"distribution",
|
||||
"getrandom 0.2.17",
|
||||
"proptest",
|
||||
"runtime-dashboard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-types",
|
||||
"swactor",
|
||||
"swactor-std",
|
||||
"tempfile",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = [".", "crates/python", "crates/wasm", "crates/bin-runner", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/std", "crates/datastore", "tests/docker"]
|
||||
members = [".", "crates/python", "crates/wasm", "crates/bin-runner", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/std", "crates/datastore", "crates/shared-types", "tests/docker"]
|
||||
exclude = ["tools/depgraph"]
|
||||
|
||||
[package]
|
||||
|
|
|
|||
305
DATASTORE_AUTH.md
Normal file
305
DATASTORE_AUTH.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# Swactor Datastore Auth Specification
|
||||
|
||||
**Version:** 0.1.0 (MVP)
|
||||
**Status:** Draft
|
||||
**Companion to:** `DATASTORE_PROTOCOL.md`
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the authorization layer for the Swactor Datastore. It defines how access is controlled for external clients connecting to a datastore node.
|
||||
|
||||
### Principles
|
||||
|
||||
- **Cryptographic identity** — keys, not passwords. Every participant is identified by an ed25519 public key (`NodeId`).
|
||||
- **Binary access** — a client is either authorized or not. No permission tiers for MVP.
|
||||
- **Owner-only administration** — only the datastore owner can grant or revoke access.
|
||||
- **Transport-layer authentication** — iroh's QUIC handshake cryptographically proves a peer's `NodeId`. This spec builds authorization on top of that.
|
||||
|
||||
### Non-Goals (MVP)
|
||||
|
||||
- Per-path permission scoping.
|
||||
- Permission tiers (read-only, read-write, admin).
|
||||
- Capability tokens or time-limited delegated access.
|
||||
- Multi-level delegation chains.
|
||||
|
||||
## 2. Trust Boundaries
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Cluster (SWIM mesh) │
|
||||
│ │
|
||||
│ Node A ◄──────────────► Node B │
|
||||
│ implicitly trusted │
|
||||
│ (no auth checks) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│
|
||||
│ auth boundary
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ External Clients │
|
||||
│ │
|
||||
│ CLI tool │
|
||||
│ Browser user │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
- **Cluster-internal** (node-to-node via SWIM): implicitly trusted. Nodes that are members of the SWIM cluster communicate freely — no per-request auth checks.
|
||||
- **External clients** (CLI, browser): must be authorized. Every external request is checked against the Access Control List before being dispatched to the actor system.
|
||||
|
||||
## 3. Identity Model
|
||||
|
||||
The auth layer reuses the existing ed25519 identity model from the distribution layer:
|
||||
|
||||
- Every client (CLI tool, browser user, node) has an ed25519 keypair.
|
||||
- Identity is the 32-byte public key, represented as `NodeId`.
|
||||
- The same `NodeId` type from `distribution::types` is used throughout.
|
||||
|
||||
There is no separate "user" concept — a keypair *is* an identity.
|
||||
|
||||
## 4. Access Control List
|
||||
|
||||
### 4.1 Structure
|
||||
|
||||
```
|
||||
AccessControlList {
|
||||
owner: NodeId, // The datastore owner's public key
|
||||
authorized_keys: Set<NodeId>, // Explicitly authorized client keys
|
||||
}
|
||||
```
|
||||
|
||||
- The **owner** always has full access (implicit; never needs to be in `authorized_keys`).
|
||||
- An empty `authorized_keys` set means only the owner can access the datastore.
|
||||
|
||||
### 4.2 Persistence
|
||||
|
||||
The ACL is persisted as a JSON file alongside the datastore's `storage_path`:
|
||||
|
||||
```
|
||||
{storage_path}/
|
||||
├── chunks/
|
||||
├── manifests/
|
||||
└── acl.json # AccessControlList
|
||||
```
|
||||
|
||||
### 4.3 Mutations
|
||||
|
||||
| Operation | Signature | Who |
|
||||
|-----------|-----------|-----|
|
||||
| Grant access | `grant(key: NodeId)` | Owner only |
|
||||
| Revoke access | `revoke(key: NodeId)` | Owner only |
|
||||
|
||||
- `grant` adds a `NodeId` to `authorized_keys`. Idempotent — granting an already-authorized key is a no-op.
|
||||
- `revoke` removes a `NodeId` from `authorized_keys`. Idempotent — revoking a non-existent key is a no-op.
|
||||
- Revoking the owner is a no-op (the owner's implicit access cannot be removed).
|
||||
- Both operations persist the updated ACL to disk immediately.
|
||||
|
||||
## 5. Auth Path 1 — Direct iroh Connection
|
||||
|
||||
For clients that connect directly to the datastore node over iroh (QUIC):
|
||||
|
||||
```
|
||||
Client (ed25519 keypair) Datastore Node
|
||||
│ │
|
||||
│──── iroh QUIC handshake ──────────>│
|
||||
│ (proves client's NodeId) │
|
||||
│ │
|
||||
│ check NodeId
|
||||
│ against ACL
|
||||
│ │
|
||||
│<─── accept / reject ──────────────│
|
||||
│ │
|
||||
│ (if accepted, all ops on │
|
||||
│ this connection are allowed) │
|
||||
```
|
||||
|
||||
1. The iroh QUIC handshake cryptographically proves the peer's `NodeId` (ed25519 public key).
|
||||
2. On connection establishment, the node checks the peer's `NodeId` against the ACL.
|
||||
3. If authorized → connection accepted. All operations on that connection are allowed with no per-message overhead.
|
||||
4. If not authorized → connection rejected immediately.
|
||||
|
||||
This is the preferred auth path — zero overhead after the initial handshake.
|
||||
|
||||
## 6. Auth Path 2 — Signed Requests (Browser Relay)
|
||||
|
||||
For browser users who cannot establish direct iroh connections (e.g., because the browser communicates via a website backend that relays requests):
|
||||
|
||||
### 6.1 Threat Model
|
||||
|
||||
The website backend acts as an **untrusted relay**. It forwards requests between the browser and the datastore node but never sees private keys. The relay cannot forge, modify, or replay requests.
|
||||
|
||||
### 6.2 Signed Envelope
|
||||
|
||||
Each request is wrapped in a signed envelope:
|
||||
|
||||
```
|
||||
SignedRequest {
|
||||
payload: SignedRequestPayload, // The request details
|
||||
public_key: NodeId, // Client's public key
|
||||
signature: Signature, // ed25519 signature over serialized payload
|
||||
}
|
||||
|
||||
SignedRequestPayload {
|
||||
action: DatastoreAction, // What the client wants to do
|
||||
timestamp: u64, // Unix timestamp (seconds)
|
||||
nonce: [u8; 16], // 16 random bytes
|
||||
}
|
||||
|
||||
DatastoreAction = enum {
|
||||
Put { name, content_hash, size_bytes, tags },
|
||||
Get { content_hash },
|
||||
Delete { content_hash },
|
||||
List { name_filter },
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Verification Steps
|
||||
|
||||
The datastore node verifies a signed request in strict order:
|
||||
|
||||
1. **Signature validity** — verify the ed25519 signature over the canonical serialization of `SignedRequestPayload` using the provided `public_key`.
|
||||
2. **Timestamp freshness** — reject if `|now - payload.timestamp| > 300` seconds.
|
||||
3. **Nonce uniqueness** — reject if `payload.nonce` has been seen before within the time window.
|
||||
4. **ACL check** — reject if `public_key` is not in the ACL.
|
||||
|
||||
If any step fails, the request is denied with the corresponding `DeniedReason`.
|
||||
|
||||
### 6.4 Put Payload Note
|
||||
|
||||
`DatastoreAction::Put` references a `content_hash` rather than embedding raw file data. The bulk data is uploaded separately, and its integrity is guaranteed by blake3 content addressing. The signed envelope authorizes the *operation*, not the data transfer.
|
||||
|
||||
## 7. Replay Protection
|
||||
|
||||
### 7.1 Timestamp Window
|
||||
|
||||
- Requests must have a `timestamp` within ±300 seconds of the node's wall clock.
|
||||
- This bounds the maximum clock drift between client and server.
|
||||
- Requests outside this window are rejected with `DeniedReason::RequestExpired`.
|
||||
|
||||
### 7.2 Nonce
|
||||
|
||||
- Each request includes a 16-byte random nonce.
|
||||
- The node maintains a set of recently seen nonces.
|
||||
- Duplicate nonces within the time window are rejected with `DeniedReason::ReplayDetected`.
|
||||
|
||||
### 7.3 Nonce Garbage Collection
|
||||
|
||||
- Nonces are stored alongside their timestamps.
|
||||
- When a nonce's timestamp falls outside the ±300 second window, it is eligible for GC.
|
||||
- GC runs periodically (piggy-backed on request processing or a background sweep).
|
||||
|
||||
## 8. Enforcement Point
|
||||
|
||||
Auth is enforced at the **edge** of the actor system — between external clients and the internal actors:
|
||||
|
||||
```
|
||||
External Client
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Auth Gate │◄── ACL check happens here
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌─────────────────┐ ┌────────────────┐
|
||||
│ MetadataActor│◄──►│ BlobStoreActor │ │ TransferActor │
|
||||
│ │ │ │ │ │
|
||||
│ (auth- │ │ (auth- │ │ (auth- │
|
||||
│ unaware) │ │ unaware) │ │ unaware) │
|
||||
└──────────────┘ └─────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
### 8.1 Direct iroh Connections
|
||||
|
||||
- Auth check at connection acceptance time.
|
||||
- Once accepted, the connection is fully trusted for all operations.
|
||||
- No per-message overhead.
|
||||
|
||||
### 8.2 Signed Requests (Browser Relay)
|
||||
|
||||
- A `GatewayActor` receives signed request envelopes.
|
||||
- The GatewayActor verifies the envelope (signature, timestamp, nonce, ACL).
|
||||
- If valid, the GatewayActor dispatches the inner action to the `MetadataActor`.
|
||||
- If invalid, the GatewayActor returns the denial reason to the relay.
|
||||
|
||||
### 8.3 Internal Actors
|
||||
|
||||
`MetadataActor`, `BlobStoreActor`, and `TransferActor` remain **auth-unaware**. They process messages from any source within the actor system. The auth boundary is strictly external.
|
||||
|
||||
## 9. Key Management
|
||||
|
||||
### 9.1 Key Generation
|
||||
|
||||
- Uses `ed25519_dalek` keypairs (same as node identity).
|
||||
- CLI: `swactor-store auth keygen` generates a new keypair and prints both the secret key (for the client to store) and the public key (to share with the owner).
|
||||
- Browser: keypair generated client-side using WebCrypto Ed25519 or wasm-compiled ed25519. The private key never leaves the browser.
|
||||
|
||||
### 9.2 Grant Flow
|
||||
|
||||
```
|
||||
1. Client generates an ed25519 keypair.
|
||||
2. Client shares their public key with the datastore owner (out-of-band).
|
||||
3. Owner runs: swactor-store auth grant <pubkey>
|
||||
4. Client can now access the datastore.
|
||||
```
|
||||
|
||||
The out-of-band exchange is intentional — it keeps the trust model simple. The owner explicitly decides who gets access.
|
||||
|
||||
### 9.3 Revocation
|
||||
|
||||
```
|
||||
1. Owner runs: swactor-store auth revoke <pubkey>
|
||||
2. Client's access is immediately revoked.
|
||||
3. Existing direct iroh connections from that client remain open until disconnected.
|
||||
4. Signed requests from the revoked key are rejected immediately.
|
||||
```
|
||||
|
||||
Note: revoking a key does not forcibly disconnect an active iroh session. The revocation takes effect on the next connection attempt. For immediate disconnection, the owner should also restart the node or implement connection tracking (future extension).
|
||||
|
||||
## 10. CLI Extensions
|
||||
|
||||
The following subcommands are added under `swactor-store auth`:
|
||||
|
||||
```
|
||||
swactor-store auth keygen
|
||||
Generate a new ed25519 keypair.
|
||||
Prints the public key (hex) and secret key (hex) to stdout.
|
||||
|
||||
swactor-store auth grant <pubkey>
|
||||
Add a public key to the ACL's authorized_keys set.
|
||||
Requires running on the owner's node.
|
||||
|
||||
swactor-store auth revoke <pubkey>
|
||||
Remove a public key from the ACL's authorized_keys set.
|
||||
Requires running on the owner's node.
|
||||
|
||||
swactor-store auth list
|
||||
Show all authorized keys (including the owner).
|
||||
|
||||
swactor-store auth whoami
|
||||
Show this node's public key (NodeId).
|
||||
```
|
||||
|
||||
## 11. Integration with Datastore Protocol
|
||||
|
||||
Each protocol flow from `DATASTORE_PROTOCOL.md` §6 has a clear auth integration point:
|
||||
|
||||
| Protocol Flow | Auth Path 1 (Direct) | Auth Path 2 (Signed Request) |
|
||||
|---------------|----------------------|------------------------------|
|
||||
| §6.1 PUT | Connection-level ACL check | `SignedRequest { action: Put { name, content_hash, size_bytes, tags }, .. }` |
|
||||
| §6.2 GET (Local) | Connection-level ACL check | `SignedRequest { action: Get { content_hash }, .. }` |
|
||||
| §6.3 GET (Remote) | Connection-level ACL check | `SignedRequest { action: Get { content_hash }, .. }` → node handles remote fetch internally |
|
||||
| §6.4 DELETE | Connection-level ACL check | `SignedRequest { action: Delete { content_hash }, .. }` |
|
||||
| §6.5 LIST (Local) | Connection-level ACL check | `SignedRequest { action: List { name_filter }, .. }` |
|
||||
| §6.6 LIST (Swarm-Wide) | Connection-level ACL check | `SignedRequest { action: List { name_filter }, .. }` → node handles fan-out internally |
|
||||
|
||||
In all cases, auth is enforced *before* the request reaches the actor system. Internal inter-node communication (DHT replication, chunk transfers between cluster members) is not subject to auth checks.
|
||||
|
||||
## 12. Future Extensions
|
||||
|
||||
These are explicitly **out of scope** for MVP but inform the design:
|
||||
|
||||
- **Per-path permission scoping** — restrict a key to specific path prefixes (e.g., read-only access to `photos/`).
|
||||
- **Permission tiers** — read-only, read-write, admin roles.
|
||||
- **Capability tokens** — time-limited, scope-limited bearer tokens for delegated access without sharing long-lived keys.
|
||||
- **Multi-level delegation** — allow authorized users to grant limited access to others.
|
||||
- **Connection tracking** — forcibly disconnect revoked keys from active iroh sessions.
|
||||
|
|
@ -6,12 +6,14 @@ edition = "2024"
|
|||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||
distribution = { path = "../distribution" }
|
||||
shared-types = { path = "../shared-types" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
blake3 = "1"
|
||||
tiny_http = { version = "0.12", optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
ureq = { version = "2", features = ["json"], optional = true }
|
||||
getrandom = { version = "0.2", optional = true }
|
||||
ctrlc = { version = "3", optional = true }
|
||||
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
|
||||
|
||||
|
|
@ -21,10 +23,12 @@ proptest = "1"
|
|||
tempfile = "3"
|
||||
swactor = { path = "../.." }
|
||||
swactor-std = { path = "../std" }
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
tiny_http = "0.12"
|
||||
|
||||
[features]
|
||||
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard"]
|
||||
cli = ["dep:clap", "dep:ureq"]
|
||||
cli = ["dep:clap", "dep:ureq", "dep:getrandom"]
|
||||
|
||||
[[bin]]
|
||||
name = "swactor-store-node"
|
||||
|
|
|
|||
185
crates/datastore/src/actors/gateway.rs
Normal file
185
crates/datastore/src/actors/gateway.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! GatewayActor — auth enforcement point for the datastore.
|
||||
//!
|
||||
//! Sits in front of the `DatastoreNode` coordinator. All external requests
|
||||
//! pass through the gateway, which checks authorization before forwarding
|
||||
//! to the internal actors.
|
||||
//!
|
||||
//! ```text
|
||||
//! External Client → GatewayActor → DatastoreNode → MetadataActor/BlobStoreActor
|
||||
//! (auth check) (dispatch) (auth-unaware)
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::auth::{AuthzEngine, AuthzResult, DatastoreAction};
|
||||
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg};
|
||||
|
||||
/// The auth gateway actor wrapping an `AuthzEngine`.
|
||||
pub struct GatewayActor {
|
||||
engine: AuthzEngine,
|
||||
datastore_node: ActorAddress,
|
||||
acl_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl GatewayActor {
|
||||
pub fn new(
|
||||
engine: AuthzEngine,
|
||||
datastore_node: ActorAddress,
|
||||
acl_path: Option<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
datastore_node,
|
||||
acl_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn persist_acl(&self) {
|
||||
if let Some(ref path) = self.acl_path {
|
||||
let _ = self.engine.acl.save(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_authorize(&mut self, ctx: &Ctx, request: crate::auth::SignedRequest, reply_to: ActorAddress) {
|
||||
let now = Self::now_secs();
|
||||
match self.engine.check_signed_request(&request, now) {
|
||||
AuthzResult::Allowed => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
AuthzResult::Denied(reason) => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Denied { reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_signed_request(&mut self, ctx: &Ctx, request: crate::auth::SignedRequest, reply_to: ActorAddress) {
|
||||
let now = Self::now_secs();
|
||||
match self.engine.check_signed_request(&request, now) {
|
||||
AuthzResult::Allowed => {
|
||||
let msg = action_to_node_msg(request.payload.action, reply_to);
|
||||
let _ = ctx.send(self.datastore_node, msg);
|
||||
}
|
||||
AuthzResult::Denied(reason) => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Denied { reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_check_connection(&self, ctx: &Ctx, node_id: distribution::types::NodeId, reply_to: ActorAddress) {
|
||||
match self.engine.check_node(&node_id) {
|
||||
AuthzResult::Allowed => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
AuthzResult::Denied(reason) => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Denied { reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_grant(&mut self, ctx: &Ctx, requester: distribution::types::NodeId, key: distribution::types::NodeId, reply_to: ActorAddress) {
|
||||
match self.engine.grant(&requester, key) {
|
||||
Ok(()) => {
|
||||
self.persist_acl();
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
Err(reason) => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Denied { reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_revoke(&mut self, ctx: &Ctx, requester: distribution::types::NodeId, key: distribution::types::NodeId, reply_to: ActorAddress) {
|
||||
match self.engine.revoke(&requester, key) {
|
||||
Ok(()) => {
|
||||
self.persist_acl();
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
|
||||
}
|
||||
Err(reason) => {
|
||||
let _ = ctx.send(reply_to, DatastoreResponse::Denied { reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for GatewayActor {
|
||||
type Incoming = GatewayMsg;
|
||||
type Response = DatastoreResponse;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: GatewayMsg) {
|
||||
match msg {
|
||||
GatewayMsg::HandleSignedRequest { request, reply_to } => {
|
||||
self.handle_signed_request(ctx, request, reply_to);
|
||||
}
|
||||
GatewayMsg::CheckConnection { node_id, reply_to } => {
|
||||
self.handle_check_connection(ctx, node_id, reply_to);
|
||||
}
|
||||
GatewayMsg::Grant {
|
||||
requester,
|
||||
key,
|
||||
reply_to,
|
||||
} => {
|
||||
self.handle_grant(ctx, requester, key, reply_to);
|
||||
}
|
||||
GatewayMsg::Revoke {
|
||||
requester,
|
||||
key,
|
||||
reply_to,
|
||||
} => {
|
||||
self.handle_revoke(ctx, requester, key, reply_to);
|
||||
}
|
||||
GatewayMsg::Authorize { request, reply_to } => {
|
||||
self.handle_authorize(ctx, request, reply_to);
|
||||
}
|
||||
GatewayMsg::NonceGcTick => {
|
||||
self.engine.gc_nonces(Self::now_secs());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a `DatastoreAction` into the corresponding `DatastoreNodeMsg`.
|
||||
fn action_to_node_msg(action: DatastoreAction, reply_to: ActorAddress) -> DatastoreNodeMsg {
|
||||
match action {
|
||||
DatastoreAction::Get { content_hash } => DatastoreNodeMsg::Get {
|
||||
content_hash,
|
||||
reply_to,
|
||||
},
|
||||
DatastoreAction::Delete { content_hash } => DatastoreNodeMsg::Delete {
|
||||
content_hash,
|
||||
reply_to,
|
||||
},
|
||||
DatastoreAction::List { name_filter } => DatastoreNodeMsg::List {
|
||||
name_filter,
|
||||
all: false,
|
||||
reply_to,
|
||||
},
|
||||
DatastoreAction::Put {
|
||||
name,
|
||||
content_hash: _,
|
||||
size_bytes: _,
|
||||
tags,
|
||||
} => {
|
||||
// Put via signed request is an authorization of the operation.
|
||||
// The actual data upload happens separately. We forward as a
|
||||
// zero-data Put — the DatastoreNode will handle the metadata.
|
||||
// In the full flow, the data is uploaded separately and the
|
||||
// signed request only authorizes it.
|
||||
DatastoreNodeMsg::Put {
|
||||
data: Vec::new(),
|
||||
name,
|
||||
tags,
|
||||
reply_to,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
pub mod blob_store;
|
||||
pub mod datastore_node;
|
||||
pub mod gateway;
|
||||
pub mod metadata;
|
||||
pub mod transfer;
|
||||
|
||||
pub use blob_store::BlobStoreActor;
|
||||
pub use datastore_node::DatastoreNode;
|
||||
pub use gateway::GatewayActor;
|
||||
pub use metadata::MetadataActor;
|
||||
pub use transfer::TransferActor;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ use std::time::{Duration, Instant};
|
|||
use swactor::actor::ActorAddress;
|
||||
use swactor::runtime::{Inbox, Runtime};
|
||||
|
||||
use crate::auth::SignedRequest;
|
||||
use crate::chunking::reassemble_blob;
|
||||
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMsg};
|
||||
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
|
||||
use crate::types::ContentHash;
|
||||
|
||||
/// Per-peer actor addresses needed for remote operations.
|
||||
|
|
@ -29,6 +30,7 @@ struct ApiState {
|
|||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
blob_store_addr: ActorAddress,
|
||||
gateway_addr: Option<ActorAddress>,
|
||||
peers: Arc<Mutex<Vec<PeerInfo>>>,
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +51,51 @@ fn poll_response(inbox: &Inbox<DatastoreResponse>, timeout: Duration) -> Option<
|
|||
}
|
||||
}
|
||||
|
||||
/// Check auth by sending a GatewayMsg::Authorize to the gateway actor.
|
||||
/// Returns Ok(()) if no gateway is configured or if authorized.
|
||||
/// Returns Err((status_code, message)) if denied.
|
||||
fn check_auth(request: &tiny_http::Request, state: &ApiState) -> Result<(), (u16, String)> {
|
||||
let gateway_addr = match state.gateway_addr {
|
||||
Some(addr) => addr,
|
||||
None => return Ok(()), // no auth configured
|
||||
};
|
||||
|
||||
let header_value = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("x-signed-request"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
|
||||
let header_value = match header_value {
|
||||
Some(v) => v,
|
||||
None => return Err((401, "missing X-Signed-Request header".to_string())),
|
||||
};
|
||||
|
||||
let signed_request: SignedRequest = serde_json::from_str(&header_value)
|
||||
.map_err(|e| (400, format!("invalid X-Signed-Request: {e}")))?;
|
||||
|
||||
let inbox = state
|
||||
.runtime
|
||||
.new_inbox::<DatastoreResponse>()
|
||||
.map_err(|_| (500, "failed to create inbox".to_string()))?;
|
||||
|
||||
let _ = state.runtime.send_to(
|
||||
gateway_addr,
|
||||
GatewayMsg::Authorize {
|
||||
request: signed_request,
|
||||
reply_to: *inbox.addr(),
|
||||
},
|
||||
);
|
||||
|
||||
match poll_response(&inbox, POLL_TIMEOUT) {
|
||||
Some(DatastoreResponse::Bool(true)) => Ok(()),
|
||||
Some(DatastoreResponse::Denied { reason }) => {
|
||||
Err((403, format!("{reason:?}")))
|
||||
}
|
||||
_ => Err((504, "auth timeout".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_json(request: tiny_http::Request, json: &str) {
|
||||
let response = tiny_http::Response::from_string(json).with_header(
|
||||
"Content-Type: application/json"
|
||||
|
|
@ -169,6 +216,10 @@ fn entries_to_json(entries: &[crate::types::ObjectEntry]) -> Vec<serde_json::Val
|
|||
// ── PUT handler ─────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_put(mut request: tiny_http::Request, url: &str, state: &ApiState) {
|
||||
if let Err((status, msg)) = check_auth(&request, state) {
|
||||
respond_error(request, status, &msg);
|
||||
return;
|
||||
}
|
||||
let params = parse_query_string(url);
|
||||
let name = params.get("name").cloned();
|
||||
|
||||
|
|
@ -222,6 +273,10 @@ fn handle_put(mut request: tiny_http::Request, url: &str, state: &ApiState) {
|
|||
// ── GET handler (metadata) ──────────────────────────────────────────────
|
||||
|
||||
fn handle_get(request: tiny_http::Request, url: &str, state: &ApiState) {
|
||||
if let Err((status, msg)) = check_auth(&request, state) {
|
||||
respond_error(request, status, &msg);
|
||||
return;
|
||||
}
|
||||
let params = parse_query_string(url);
|
||||
let hash_hex = match params.get("hash") {
|
||||
Some(h) => h,
|
||||
|
|
@ -279,6 +334,10 @@ fn handle_get(request: tiny_http::Request, url: &str, state: &ApiState) {
|
|||
// ── DATA handler (reassembled binary) ───────────────────────────────────
|
||||
|
||||
fn handle_data(request: tiny_http::Request, url: &str, state: &ApiState) {
|
||||
if let Err((status, msg)) = check_auth(&request, state) {
|
||||
respond_error(request, status, &msg);
|
||||
return;
|
||||
}
|
||||
let params = parse_query_string(url);
|
||||
let hash_hex = match params.get("hash") {
|
||||
Some(h) => h,
|
||||
|
|
@ -376,6 +435,10 @@ fn handle_data(request: tiny_http::Request, url: &str, state: &ApiState) {
|
|||
// ── DELETE handler ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_delete(request: tiny_http::Request, url: &str, state: &ApiState) {
|
||||
if let Err((status, msg)) = check_auth(&request, state) {
|
||||
respond_error(request, status, &msg);
|
||||
return;
|
||||
}
|
||||
let params = parse_query_string(url);
|
||||
let hash_hex = match params.get("hash") {
|
||||
Some(h) => h,
|
||||
|
|
@ -429,6 +492,10 @@ fn handle_delete(request: tiny_http::Request, url: &str, state: &ApiState) {
|
|||
// ── LIST handler ────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_list(request: tiny_http::Request, url: &str, state: &ApiState) {
|
||||
if let Err((status, msg)) = check_auth(&request, state) {
|
||||
respond_error(request, status, &msg);
|
||||
return;
|
||||
}
|
||||
let params = parse_query_string(url);
|
||||
let name_filter = params.get("name").cloned();
|
||||
let all = params.get("all").map_or(false, |v| v == "true" || v == "1");
|
||||
|
|
@ -706,6 +773,7 @@ pub fn start_api_server(
|
|||
datastore_addr: ActorAddress,
|
||||
metadata_addr: ActorAddress,
|
||||
blob_store_addr: ActorAddress,
|
||||
gateway_addr: Option<ActorAddress>,
|
||||
port: u16,
|
||||
) -> (Arc<AtomicBool>, Arc<Mutex<Vec<PeerInfo>>>) {
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
|
|
@ -716,6 +784,7 @@ pub fn start_api_server(
|
|||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
gateway_addr,
|
||||
peers: Arc::clone(&peers),
|
||||
});
|
||||
|
||||
|
|
|
|||
254
crates/datastore/src/auth.rs
Normal file
254
crates/datastore/src/auth.rs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
//! Authorization types and engine for the distributed datastore.
|
||||
//!
|
||||
//! Enforces binary access control (authorized or not) using ed25519 identities.
|
||||
//! Two auth paths:
|
||||
//! - **Path 1 (Direct iroh):** connection-level `check_node` against the ACL.
|
||||
//! - **Path 2 (Browser relay):** per-request `check_signed_request` with
|
||||
//! signature, timestamp, nonce, and ACL verification.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use distribution::crypto;
|
||||
use distribution::types::{NodeId, Signature};
|
||||
use shared_types::ContentHash;
|
||||
|
||||
// ─── DatastoreAction ────────────────────────────────────────────────────────
|
||||
|
||||
/// An action a client wants to perform on the datastore.
|
||||
///
|
||||
/// Carried inside a `SignedRequestPayload` for browser-relay auth (Auth Path 2).
|
||||
/// Aligned to match `DatastoreNodeMsg` variants — content-hash-first addressing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DatastoreAction {
|
||||
Put {
|
||||
name: Option<String>,
|
||||
content_hash: ContentHash,
|
||||
size_bytes: u64,
|
||||
tags: BTreeMap<String, String>,
|
||||
},
|
||||
Get {
|
||||
content_hash: ContentHash,
|
||||
},
|
||||
Delete {
|
||||
content_hash: ContentHash,
|
||||
},
|
||||
List {
|
||||
name_filter: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ─── SignedRequestPayload ───────────────────────────────────────────────────
|
||||
|
||||
/// The signable payload of a client request.
|
||||
///
|
||||
/// Serialized canonically (serde_json) and signed by the client's ed25519 key.
|
||||
/// Includes timestamp and nonce for replay protection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedRequestPayload {
|
||||
pub action: DatastoreAction,
|
||||
/// Unix timestamp in seconds.
|
||||
pub timestamp: u64,
|
||||
/// 16 random bytes — prevents replay within the timestamp window.
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
// ─── SignedRequest ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A signed request envelope for browser-relay auth (Auth Path 2).
|
||||
///
|
||||
/// The relay forwards this opaquely — it cannot forge, modify, or replay it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedRequest {
|
||||
pub payload: SignedRequestPayload,
|
||||
/// The client's ed25519 public key.
|
||||
pub public_key: NodeId,
|
||||
/// ed25519 signature over the canonical serialization of `payload`.
|
||||
pub signature: Signature,
|
||||
}
|
||||
|
||||
// ─── AccessControlList ──────────────────────────────────────────────────────
|
||||
|
||||
/// The datastore's access control list.
|
||||
///
|
||||
/// Persisted as `acl.json` alongside the datastore's `storage_path`.
|
||||
/// The owner always has implicit full access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccessControlList {
|
||||
/// The datastore owner's public key — always has full access.
|
||||
pub owner: NodeId,
|
||||
/// Explicitly authorized client keys.
|
||||
pub authorized_keys: HashSet<NodeId>,
|
||||
}
|
||||
|
||||
impl AccessControlList {
|
||||
/// Load an ACL from disk, or create a default one with the given owner.
|
||||
pub fn load_or_create(path: &Path, owner: NodeId) -> io::Result<Self> {
|
||||
if path.exists() {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let acl: AccessControlList = serde_json::from_str(&data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
Ok(acl)
|
||||
} else {
|
||||
let acl = AccessControlList {
|
||||
owner,
|
||||
authorized_keys: HashSet::new(),
|
||||
};
|
||||
acl.save(path)?;
|
||||
Ok(acl)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the ACL to disk as JSON.
|
||||
pub fn save(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AuthzResult ────────────────────────────────────────────────────────────
|
||||
|
||||
/// The outcome of an authorization check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthzResult {
|
||||
Allowed,
|
||||
Denied(DeniedReason),
|
||||
}
|
||||
|
||||
/// Why a request was denied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DeniedReason {
|
||||
/// The key is not in the ACL.
|
||||
NotAuthorized,
|
||||
/// The ed25519 signature is invalid.
|
||||
InvalidSignature,
|
||||
/// The request timestamp is outside the ±300s window.
|
||||
RequestExpired,
|
||||
/// The nonce has already been seen within the time window.
|
||||
ReplayDetected,
|
||||
}
|
||||
|
||||
// ─── Signing / Verification ─────────────────────────────────────────────────
|
||||
|
||||
/// Sign a request payload, returning a complete `SignedRequest` envelope.
|
||||
pub fn sign_request(keypair: &crypto::Keypair, payload: SignedRequestPayload) -> SignedRequest {
|
||||
let bytes = serde_json::to_vec(&payload).expect("SignedRequestPayload is always serializable");
|
||||
let signature = keypair.sign(&bytes);
|
||||
SignedRequest {
|
||||
payload,
|
||||
public_key: keypair.node_id(),
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a `SignedRequest`'s signature against its embedded `public_key`.
|
||||
///
|
||||
/// Checks only signature validity — does NOT check timestamp, nonce, or ACL.
|
||||
pub fn verify_signed_request(request: &SignedRequest) -> bool {
|
||||
let Ok(bytes) = serde_json::to_vec(&request.payload) else {
|
||||
return false;
|
||||
};
|
||||
crypto::verify(&request.public_key, &bytes, &request.signature)
|
||||
}
|
||||
|
||||
// ─── AuthzEngine ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Authorization engine — checks requests against the ACL and replay state.
|
||||
///
|
||||
/// Sits at the edge of the actor system (Auth Gate / GatewayActor) and decides
|
||||
/// whether to accept or reject external requests before they reach internal actors.
|
||||
#[derive(Debug)]
|
||||
pub struct AuthzEngine {
|
||||
pub acl: AccessControlList,
|
||||
seen_nonces: HashMap<[u8; 16], u64>,
|
||||
timestamp_window: u64,
|
||||
}
|
||||
|
||||
impl AuthzEngine {
|
||||
/// Create a new engine with the given ACL and a default 300-second window.
|
||||
pub fn new(acl: AccessControlList) -> Self {
|
||||
Self {
|
||||
acl,
|
||||
seen_nonces: HashMap::new(),
|
||||
timestamp_window: 300,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a `NodeId` is authorized (connection-level, Auth Path 1).
|
||||
///
|
||||
/// The owner always has implicit access. Other keys must be in `authorized_keys`.
|
||||
pub fn check_node(&self, node_id: &NodeId) -> AuthzResult {
|
||||
if *node_id == self.acl.owner || self.acl.authorized_keys.contains(node_id) {
|
||||
AuthzResult::Allowed
|
||||
} else {
|
||||
AuthzResult::Denied(DeniedReason::NotAuthorized)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify and authorize a signed request (Auth Path 2).
|
||||
///
|
||||
/// Four-step verification in strict order:
|
||||
/// 1. Signature validity
|
||||
/// 2. Timestamp freshness (±window)
|
||||
/// 3. Nonce uniqueness
|
||||
/// 4. ACL check
|
||||
pub fn check_signed_request(&mut self, request: &SignedRequest, now: u64) -> AuthzResult {
|
||||
// 1. Signature
|
||||
if !verify_signed_request(request) {
|
||||
return AuthzResult::Denied(DeniedReason::InvalidSignature);
|
||||
}
|
||||
|
||||
// 2. Timestamp freshness
|
||||
let ts = request.payload.timestamp;
|
||||
let diff = if now >= ts { now - ts } else { ts - now };
|
||||
if diff > self.timestamp_window {
|
||||
return AuthzResult::Denied(DeniedReason::RequestExpired);
|
||||
}
|
||||
|
||||
// 3. Nonce uniqueness
|
||||
if self.seen_nonces.contains_key(&request.payload.nonce) {
|
||||
return AuthzResult::Denied(DeniedReason::ReplayDetected);
|
||||
}
|
||||
self.seen_nonces.insert(request.payload.nonce, ts);
|
||||
|
||||
// 4. ACL check
|
||||
self.check_node(&request.public_key)
|
||||
}
|
||||
|
||||
/// Grant access to a `NodeId`. Owner-only, idempotent.
|
||||
pub fn grant(&mut self, requester: &NodeId, key: NodeId) -> Result<(), DeniedReason> {
|
||||
if *requester != self.acl.owner {
|
||||
return Err(DeniedReason::NotAuthorized);
|
||||
}
|
||||
self.acl.authorized_keys.insert(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke access from a `NodeId`. Owner-only, idempotent.
|
||||
/// Revoking the owner is a no-op (owner's implicit access cannot be removed).
|
||||
pub fn revoke(&mut self, requester: &NodeId, key: NodeId) -> Result<(), DeniedReason> {
|
||||
if *requester != self.acl.owner {
|
||||
return Err(DeniedReason::NotAuthorized);
|
||||
}
|
||||
// Owner's implicit access cannot be removed.
|
||||
if key != self.acl.owner {
|
||||
self.acl.authorized_keys.remove(&key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evict nonces whose timestamps fall outside the current window.
|
||||
pub fn gc_nonces(&mut self, now: u64) {
|
||||
self.seen_nonces.retain(|_nonce, ts| {
|
||||
let diff = if now >= *ts { now - *ts } else { *ts - now };
|
||||
diff <= self.timestamp_window
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,18 @@
|
|||
//!
|
||||
//! Talks to a running `swactor-store-node` over its HTTP API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use shared_types::ContentHash;
|
||||
use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "swactor-store", about = "Swactor datastore CLI")]
|
||||
struct Args {
|
||||
|
|
@ -15,6 +21,10 @@ struct Args {
|
|||
#[arg(long, default_value = "http://localhost:9091")]
|
||||
url: String,
|
||||
|
||||
/// Path to key.json file for auth signing
|
||||
#[arg(long)]
|
||||
key: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
|
@ -55,20 +65,93 @@ enum Command {
|
|||
Status,
|
||||
}
|
||||
|
||||
// ── Key file helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(hex.len() / 2);
|
||||
for chunk in hex.as_bytes().chunks(2) {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_keypair(path: &std::path::Path) -> Keypair {
|
||||
let data = fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading key file {}: {e}", path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
let json: serde_json::Value = serde_json::from_str(&data).unwrap_or_else(|e| {
|
||||
eprintln!("Error parsing key file: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret_hex = json
|
||||
.get("secret_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Key file missing secret_key field");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret_bytes = hex_decode(secret_hex).unwrap_or_else(|| {
|
||||
eprintln!("Invalid secret_key hex in key file");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let secret: [u8; 32] = secret_bytes.try_into().unwrap_or_else(|_| {
|
||||
eprintln!("secret_key must be exactly 32 bytes");
|
||||
std::process::exit(1);
|
||||
});
|
||||
Keypair::from_bytes(&secret)
|
||||
}
|
||||
|
||||
// ── Auth signing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn sign_action(keypair: &Keypair, action: DatastoreAction) -> String {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let mut nonce = [0u8; 16];
|
||||
getrandom::getrandom(&mut nonce).expect("failed to generate random nonce");
|
||||
let payload = SignedRequestPayload {
|
||||
action,
|
||||
timestamp,
|
||||
nonce,
|
||||
};
|
||||
let signed = sign_request(keypair, payload);
|
||||
serde_json::to_string(&signed).expect("SignedRequest is always serializable")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
let base = args.url.trim_end_matches('/');
|
||||
|
||||
let keypair = args.key.as_deref().map(load_keypair);
|
||||
|
||||
match args.command {
|
||||
Command::Put { path, name } => cmd_put(base, &path, name.as_deref()),
|
||||
Command::Get { hash, output } => cmd_get(base, &hash, output.as_deref()),
|
||||
Command::Delete { hash } => cmd_delete(base, &hash),
|
||||
Command::List { name, all } => cmd_list(base, name.as_deref(), all),
|
||||
Command::Put { path, name } => cmd_put(base, &path, name.as_deref(), keypair.as_ref()),
|
||||
Command::Get { hash, output } => {
|
||||
cmd_get(base, &hash, output.as_deref(), keypair.as_ref())
|
||||
}
|
||||
Command::Delete { hash } => cmd_delete(base, &hash, keypair.as_ref()),
|
||||
Command::List { name, all } => cmd_list(base, name.as_deref(), all, keypair.as_ref()),
|
||||
Command::Status => cmd_status(base),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_put(base: &str, path: &PathBuf, name: Option<&str>) {
|
||||
fn cmd_put(base: &str, path: &PathBuf, name: Option<&str>, keypair: Option<&Keypair>) {
|
||||
let data = match fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
|
|
@ -90,7 +173,19 @@ fn cmd_put(base: &str, path: &PathBuf, name: Option<&str>) {
|
|||
url.push_str(&format!("?name={}", url_encode(n)));
|
||||
}
|
||||
|
||||
let resp = match ureq::post(&url).send_bytes(&data) {
|
||||
let mut req = ureq::post(&url);
|
||||
if let Some(kp) = keypair {
|
||||
let content_hash = ContentHash::of(&data);
|
||||
let action = DatastoreAction::Put {
|
||||
name: label.clone(),
|
||||
content_hash,
|
||||
size_bytes: data.len() as u64,
|
||||
tags: BTreeMap::new(),
|
||||
};
|
||||
req = req.set("X-Signed-Request", &sign_action(kp, action));
|
||||
}
|
||||
|
||||
let resp = match req.send_bytes(&data) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
|
|
@ -114,11 +209,19 @@ fn cmd_put(base: &str, path: &PathBuf, name: Option<&str>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_get(base: &str, hash: &str, output: Option<&std::path::Path>) {
|
||||
fn cmd_get(base: &str, hash: &str, output: Option<&std::path::Path>, keypair: Option<&Keypair>) {
|
||||
if let Some(out_path) = output {
|
||||
// Download raw data
|
||||
let url = format!("{base}/api/data?hash={hash}");
|
||||
let resp = match ureq::get(&url).call() {
|
||||
let mut req = ureq::get(&url);
|
||||
if let Some(kp) = keypair {
|
||||
if let Some(ch) = ContentHash::from_hex(hash) {
|
||||
let action = DatastoreAction::Get { content_hash: ch };
|
||||
req = req.set("X-Signed-Request", &sign_action(kp, action));
|
||||
}
|
||||
}
|
||||
|
||||
let resp = match req.call() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
|
|
@ -146,7 +249,15 @@ fn cmd_get(base: &str, hash: &str, output: Option<&std::path::Path>) {
|
|||
} else {
|
||||
// Metadata only
|
||||
let url = format!("{base}/api/get?hash={hash}");
|
||||
let resp = match ureq::get(&url).call() {
|
||||
let mut req = ureq::get(&url);
|
||||
if let Some(kp) = keypair {
|
||||
if let Some(ch) = ContentHash::from_hex(hash) {
|
||||
let action = DatastoreAction::Get { content_hash: ch };
|
||||
req = req.set("X-Signed-Request", &sign_action(kp, action));
|
||||
}
|
||||
}
|
||||
|
||||
let resp = match req.call() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
|
|
@ -203,9 +314,17 @@ fn cmd_get(base: &str, hash: &str, output: Option<&std::path::Path>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_delete(base: &str, hash: &str) {
|
||||
fn cmd_delete(base: &str, hash: &str, keypair: Option<&Keypair>) {
|
||||
let url = format!("{base}/api/delete?hash={hash}");
|
||||
let resp = match ureq::post(&url).send_bytes(&[]) {
|
||||
let mut req = ureq::post(&url);
|
||||
if let Some(kp) = keypair {
|
||||
if let Some(ch) = ContentHash::from_hex(hash) {
|
||||
let action = DatastoreAction::Delete { content_hash: ch };
|
||||
req = req.set("X-Signed-Request", &sign_action(kp, action));
|
||||
}
|
||||
}
|
||||
|
||||
let resp = match req.send_bytes(&[]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
|
|
@ -229,7 +348,7 @@ fn cmd_delete(base: &str, hash: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_list(base: &str, name: Option<&str>, all: bool) {
|
||||
fn cmd_list(base: &str, name: Option<&str>, all: bool, keypair: Option<&Keypair>) {
|
||||
let mut url = format!("{base}/api/list");
|
||||
let mut sep = '?';
|
||||
if let Some(n) = name {
|
||||
|
|
@ -240,7 +359,15 @@ fn cmd_list(base: &str, name: Option<&str>, all: bool) {
|
|||
url.push_str(&format!("{sep}all=true"));
|
||||
}
|
||||
|
||||
let resp = match ureq::get(&url).call() {
|
||||
let mut req = ureq::get(&url);
|
||||
if let Some(kp) = keypair {
|
||||
let action = DatastoreAction::List {
|
||||
name_filter: name.map(|s| s.to_string()),
|
||||
};
|
||||
req = req.set("X-Signed-Request", &sign_action(kp, action));
|
||||
}
|
||||
|
||||
let resp = match req.call() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ use clap::Parser;
|
|||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, MetadataActor};
|
||||
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||
use swactor_datastore::api::start_api_server;
|
||||
use swactor_datastore::messages::MetadataMsg;
|
||||
use swactor_datastore::auth::{AccessControlList, AuthzEngine};
|
||||
use swactor_datastore::messages::{GatewayMsg, MetadataMsg};
|
||||
use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend};
|
||||
use swactor_datastore::DatastoreConfig;
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use distribution::types::NodeId;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -47,6 +49,102 @@ struct Args {
|
|||
/// Dissemination interval in ticks
|
||||
#[arg(long, default_value = "50")]
|
||||
disseminate_interval: u64,
|
||||
|
||||
/// Enable auth (generates owner keypair if needed)
|
||||
#[arg(long)]
|
||||
auth: bool,
|
||||
|
||||
/// Directory for owner.key.json + acl.json (default: "auth")
|
||||
#[arg(long, default_value = "auth")]
|
||||
auth_dir: String,
|
||||
}
|
||||
|
||||
// ── Key file helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
||||
if hex.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(hex.len() / 2);
|
||||
for chunk in hex.as_bytes().chunks(2) {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes.push((hi << 4) | lo);
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_generate_keypair(path: &std::path::Path) -> Keypair {
|
||||
if path.exists() {
|
||||
let data = std::fs::read_to_string(path).expect("failed to read key file");
|
||||
let json: serde_json::Value = serde_json::from_str(&data).expect("invalid key file JSON");
|
||||
let secret_hex = json
|
||||
.get("secret_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("key file missing secret_key");
|
||||
let secret_bytes = hex_decode(secret_hex).expect("invalid secret_key hex");
|
||||
let secret: [u8; 32] = secret_bytes
|
||||
.try_into()
|
||||
.expect("secret_key must be 32 bytes");
|
||||
Keypair::from_bytes(&secret)
|
||||
} else {
|
||||
let keypair = Keypair::generate();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let json = serde_json::json!({
|
||||
"version": 1,
|
||||
"secret_key": hex_encode(&keypair.secret_bytes()),
|
||||
"public_key": hex_encode(&keypair.node_id().0),
|
||||
"created_at": format_timestamp(now),
|
||||
});
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("failed to create key file directory");
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&json).unwrap())
|
||||
.expect("failed to write key file");
|
||||
keypair
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(secs: u64) -> String {
|
||||
// Simple ISO-8601 UTC timestamp
|
||||
let s = secs % 60;
|
||||
let m = (secs / 60) % 60;
|
||||
let h = (secs / 3600) % 24;
|
||||
let days = secs / 86400;
|
||||
// Days since epoch to Y-M-D (simplified)
|
||||
let (y, mo, d) = days_to_ymd(days);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||
days += 719468;
|
||||
let era = days / 146097;
|
||||
let doe = days - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -83,25 +181,40 @@ fn main() {
|
|||
});
|
||||
rt.set_stats_hook(collector.clone());
|
||||
|
||||
// Generate node ID from random bytes
|
||||
let node_id = {
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, b) in std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[i % 32] ^= *b;
|
||||
}
|
||||
// Mix in process id for uniqueness
|
||||
let pid = std::process::id();
|
||||
for (i, b) in pid.to_le_bytes().iter().enumerate() {
|
||||
bytes[i + 16] ^= *b;
|
||||
}
|
||||
NodeId(bytes)
|
||||
// Generate or load node identity
|
||||
let (node_id, owner_keypair) = if args.auth {
|
||||
let auth_dir = std::path::PathBuf::from(&args.auth_dir);
|
||||
std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory");
|
||||
let key_path = auth_dir.join("owner.key.json");
|
||||
let keypair = load_or_generate_keypair(&key_path);
|
||||
let nid = keypair.node_id();
|
||||
eprintln!(
|
||||
"Auth enabled — owner key: {}",
|
||||
hex_encode(&nid.0)
|
||||
);
|
||||
eprintln!("Key file: {}", key_path.display());
|
||||
(nid, Some((keypair, auth_dir)))
|
||||
} else {
|
||||
let node_id = {
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, b) in std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[i % 32] ^= *b;
|
||||
}
|
||||
// Mix in process id for uniqueness
|
||||
let pid = std::process::id();
|
||||
for (i, b) in pid.to_le_bytes().iter().enumerate() {
|
||||
bytes[i + 16] ^= *b;
|
||||
}
|
||||
NodeId(bytes)
|
||||
};
|
||||
(node_id, None)
|
||||
};
|
||||
|
||||
// Datastore config
|
||||
|
|
@ -142,6 +255,21 @@ fn main() {
|
|||
.spawn(datastore_node)
|
||||
.expect("failed to spawn DatastoreNode");
|
||||
|
||||
// Spawn GatewayActor if auth is enabled
|
||||
let gateway_addr = if let Some((_, ref auth_dir)) = owner_keypair {
|
||||
let acl_path = auth_dir.join("acl.json");
|
||||
let acl = AccessControlList::load_or_create(&acl_path, node_id)
|
||||
.expect("failed to load/create ACL");
|
||||
let engine = AuthzEngine::new(acl);
|
||||
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
|
||||
let addr = rt
|
||||
.spawn(gateway)
|
||||
.expect("failed to spawn GatewayActor");
|
||||
Some(addr)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start runtime
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
|
||||
|
|
@ -155,6 +283,7 @@ fn main() {
|
|||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
gateway_addr,
|
||||
args.port,
|
||||
);
|
||||
|
||||
|
|
@ -179,6 +308,10 @@ fn main() {
|
|||
let _ = handle
|
||||
.runtime
|
||||
.send_to(metadata_addr, MetadataMsg::GcTick);
|
||||
|
||||
if let Some(gw) = gateway_addr {
|
||||
let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick);
|
||||
}
|
||||
}
|
||||
|
||||
if round % args.disseminate_interval == 0 {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod messages;
|
|||
pub mod chunking;
|
||||
pub mod storage;
|
||||
pub mod actors;
|
||||
pub mod auth;
|
||||
pub mod cli;
|
||||
#[cfg(feature = "node")]
|
||||
pub mod api;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use swactor::transport::NetworkMessage;
|
|||
|
||||
use distribution::types::NodeId;
|
||||
|
||||
use crate::auth::{DeniedReason, SignedRequest};
|
||||
use crate::types::{ContentHash, ObjectEntry, ObjectManifest};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -365,8 +366,46 @@ pub enum DatastoreResponse {
|
|||
NotFound,
|
||||
/// An error occurred.
|
||||
Error { reason: String },
|
||||
/// Request was denied by the auth layer.
|
||||
Denied { reason: DeniedReason },
|
||||
/// Boolean response (e.g. HasChunk).
|
||||
Bool(bool),
|
||||
/// List of chunk hashes.
|
||||
ChunkList { hashes: Vec<ContentHash> },
|
||||
}
|
||||
|
||||
// ─── GatewayMsg ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Messages handled by the `GatewayActor` — the auth enforcement point.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GatewayMsg {
|
||||
/// Auth Path 2: verify a signed request and dispatch if allowed.
|
||||
HandleSignedRequest {
|
||||
request: SignedRequest,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Auth Path 1: check whether a node is authorized for connection.
|
||||
CheckConnection {
|
||||
node_id: NodeId,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Owner-only: grant access to a key.
|
||||
Grant {
|
||||
requester: NodeId,
|
||||
key: NodeId,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Owner-only: revoke access from a key.
|
||||
Revoke {
|
||||
requester: NodeId,
|
||||
key: NodeId,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Auth-only check: verify a signed request without forwarding the action.
|
||||
Authorize {
|
||||
request: SignedRequest,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Periodic nonce garbage collection tick.
|
||||
NonceGcTick,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,111 +4,13 @@
|
|||
//! `blake3(blob_bytes)`. Names are optional metadata, not keys.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use distribution::types::NodeId;
|
||||
|
||||
// ─── ContentHash ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A blake3 content hash (32 bytes).
|
||||
///
|
||||
/// The primary identifier for blobs and the DHT key. Mirrors the `NodeId`
|
||||
/// pattern from `distribution::types` — XOR distance for DHT routing, compact
|
||||
/// Debug/Display for logging.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ContentHash(pub [u8; 32]);
|
||||
|
||||
impl ContentHash {
|
||||
/// Compute the blake3 hash of the given data.
|
||||
pub fn of(data: &[u8]) -> Self {
|
||||
let hash = blake3::hash(data);
|
||||
ContentHash(*hash.as_bytes())
|
||||
}
|
||||
|
||||
/// XOR distance between two content hashes (Kademlia metric).
|
||||
pub fn xor_distance(&self, other: &ContentHash) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = self.0[i] ^ other.0[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of leading zero bits in the XOR distance to `other`.
|
||||
/// Returns 0..=256. Used to select the k-bucket index in the metadata DHT.
|
||||
pub fn xor_leading_zeros(&self, other: &ContentHash) -> u32 {
|
||||
let dist = self.xor_distance(other);
|
||||
let mut zeros = 0u32;
|
||||
for byte in dist {
|
||||
if byte == 0 {
|
||||
zeros += 8;
|
||||
} else {
|
||||
zeros += byte.leading_zeros();
|
||||
break;
|
||||
}
|
||||
}
|
||||
zeros
|
||||
}
|
||||
|
||||
/// Parse a 64-character hex string into a ContentHash.
|
||||
/// Returns `None` if the string is not exactly 64 hex characters.
|
||||
pub fn from_hex(hex: &str) -> Option<Self> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes[i] = (hi << 4) | lo;
|
||||
}
|
||||
Some(ContentHash(bytes))
|
||||
}
|
||||
|
||||
/// Encode as lowercase hex string.
|
||||
pub fn to_hex(&self) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in &self.0 {
|
||||
use fmt::Write;
|
||||
write!(s, "{:02x}", b).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The zero hash (all zeroes). Used as a sentinel.
|
||||
pub const ZERO: ContentHash = ContentHash([0u8; 32]);
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContentHash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Hash(")?;
|
||||
for b in &self.0[..4] {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
write!(f, "\u{2026})")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContentHash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for b in &self.0[..8] {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
write!(f, "\u{2026}")
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub use shared_types::ContentHash;
|
||||
|
||||
// ─── ObjectEntry ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
58
crates/datastore/tests/acl_persistence_tests.rs
Normal file
58
crates/datastore/tests/acl_persistence_tests.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! ACL file persistence tests — roundtrip save/load.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use swactor_datastore::auth::AccessControlList;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Save + load preserves owner and authorized_keys
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("acl.json");
|
||||
|
||||
let owner = Keypair::generate().node_id();
|
||||
let client_a = Keypair::generate().node_id();
|
||||
let client_b = Keypair::generate().node_id();
|
||||
|
||||
let mut keys = HashSet::new();
|
||||
keys.insert(client_a);
|
||||
keys.insert(client_b);
|
||||
|
||||
let acl = AccessControlList {
|
||||
owner,
|
||||
authorized_keys: keys.clone(),
|
||||
};
|
||||
acl.save(&path).unwrap();
|
||||
|
||||
let loaded = AccessControlList::load_or_create(&path, owner).unwrap();
|
||||
assert_eq!(loaded.owner, owner);
|
||||
assert_eq!(loaded.authorized_keys, keys);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. load_or_create on missing file creates default
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn load_or_create_on_missing_file_creates_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nonexistent/acl.json");
|
||||
|
||||
let owner = Keypair::generate().node_id();
|
||||
let acl = AccessControlList::load_or_create(&path, owner).unwrap();
|
||||
|
||||
assert_eq!(acl.owner, owner);
|
||||
assert!(acl.authorized_keys.is_empty());
|
||||
|
||||
// File should now exist
|
||||
assert!(path.exists());
|
||||
|
||||
// Loading again should give same result
|
||||
let acl2 = AccessControlList::load_or_create(&path, owner).unwrap();
|
||||
assert_eq!(acl2.owner, owner);
|
||||
assert!(acl2.authorized_keys.is_empty());
|
||||
}
|
||||
291
crates/datastore/tests/auth_scenario_tests.rs
Normal file
291
crates/datastore/tests/auth_scenario_tests.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
//! Scenario tests for AuthzEngine — no actor system, pure auth logic.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use shared_types::ContentHash;
|
||||
use swactor_datastore::auth::{
|
||||
sign_request, AccessControlList, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason,
|
||||
SignedRequestPayload,
|
||||
};
|
||||
|
||||
fn owner_engine() -> (Keypair, AuthzEngine) {
|
||||
let owner_kp = Keypair::generate();
|
||||
let acl = AccessControlList {
|
||||
owner: owner_kp.node_id(),
|
||||
authorized_keys: HashSet::new(),
|
||||
};
|
||||
(owner_kp, AuthzEngine::new(acl))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Owner always allowed; random key denied
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn owner_is_always_allowed() {
|
||||
let (owner_kp, engine) = owner_engine();
|
||||
assert_eq!(engine.check_node(&owner_kp.node_id()), AuthzResult::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_denied() {
|
||||
let (_owner_kp, engine) = owner_engine();
|
||||
let stranger = Keypair::generate().node_id();
|
||||
assert_eq!(
|
||||
engine.check_node(&stranger),
|
||||
AuthzResult::Denied(DeniedReason::NotAuthorized)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Grant → access → revoke → denied (lifecycle story)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn grant_then_revoke_lifecycle() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let client = Keypair::generate().node_id();
|
||||
|
||||
// Initially denied
|
||||
assert_eq!(
|
||||
engine.check_node(&client),
|
||||
AuthzResult::Denied(DeniedReason::NotAuthorized)
|
||||
);
|
||||
|
||||
// Grant
|
||||
engine.grant(&owner_kp.node_id(), client).unwrap();
|
||||
assert_eq!(engine.check_node(&client), AuthzResult::Allowed);
|
||||
|
||||
// Revoke
|
||||
engine.revoke(&owner_kp.node_id(), client).unwrap();
|
||||
assert_eq!(
|
||||
engine.check_node(&client),
|
||||
AuthzResult::Denied(DeniedReason::NotAuthorized)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Only owner can grant/revoke; non-owner gets NotAuthorized
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn non_owner_cannot_grant() {
|
||||
let (_owner_kp, mut engine) = owner_engine();
|
||||
let impostor = Keypair::generate().node_id();
|
||||
let target = Keypair::generate().node_id();
|
||||
|
||||
assert_eq!(
|
||||
engine.grant(&impostor, target),
|
||||
Err(DeniedReason::NotAuthorized)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_owner_cannot_revoke() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let client = Keypair::generate().node_id();
|
||||
engine.grant(&owner_kp.node_id(), client).unwrap();
|
||||
|
||||
let impostor = Keypair::generate().node_id();
|
||||
assert_eq!(
|
||||
engine.revoke(&impostor, client),
|
||||
Err(DeniedReason::NotAuthorized)
|
||||
);
|
||||
|
||||
// Client still authorized
|
||||
assert_eq!(engine.check_node(&client), AuthzResult::Allowed);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Cannot revoke owner's implicit access
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn revoking_owner_is_noop() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let owner_id = owner_kp.node_id();
|
||||
|
||||
// Attempt to revoke owner — should succeed (idempotent no-op) but owner remains allowed
|
||||
engine.revoke(&owner_id, owner_id).unwrap();
|
||||
assert_eq!(engine.check_node(&owner_id), AuthzResult::Allowed);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Signed request happy path (sign → verify → allowed)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn signed_request_happy_path() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let now = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"hello"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [1; 16],
|
||||
};
|
||||
let request = sign_request(&owner_kp, payload);
|
||||
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, now),
|
||||
AuthzResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. Tampered signature → InvalidSignature
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_is_rejected() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let now = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"hello"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [2; 16],
|
||||
};
|
||||
let mut request = sign_request(&owner_kp, payload);
|
||||
// Tamper with signature
|
||||
request.signature.0[0] ^= 0xFF;
|
||||
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, now),
|
||||
AuthzResult::Denied(DeniedReason::InvalidSignature)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Stale timestamp → RequestExpired
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn stale_timestamp_is_rejected() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let now = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"stale"),
|
||||
},
|
||||
timestamp: now - 400, // 400s ago, outside 300s window
|
||||
nonce: [3; 16],
|
||||
};
|
||||
let request = sign_request(&owner_kp, payload);
|
||||
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, now),
|
||||
AuthzResult::Denied(DeniedReason::RequestExpired)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Replayed nonce → ReplayDetected
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn replayed_nonce_is_rejected() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let now = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"first"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [4; 16],
|
||||
};
|
||||
let request = sign_request(&owner_kp, payload);
|
||||
|
||||
// First time — allowed
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, now),
|
||||
AuthzResult::Allowed
|
||||
);
|
||||
|
||||
// Replay — same nonce
|
||||
let payload2 = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"first"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [4; 16],
|
||||
};
|
||||
let request2 = sign_request(&owner_kp, payload2);
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request2, now),
|
||||
AuthzResult::Denied(DeniedReason::ReplayDetected)
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 9. Nonce GC frees old nonces for reuse
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn nonce_gc_frees_old_nonces() {
|
||||
let (owner_kp, mut engine) = owner_engine();
|
||||
let t0 = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"gc-test"),
|
||||
},
|
||||
timestamp: t0,
|
||||
nonce: [5; 16],
|
||||
};
|
||||
let request = sign_request(&owner_kp, payload);
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, t0),
|
||||
AuthzResult::Allowed
|
||||
);
|
||||
|
||||
// Advance time past the window and GC
|
||||
let t1 = t0 + 400;
|
||||
engine.gc_nonces(t1);
|
||||
|
||||
// Same nonce but with current timestamp — no longer flagged as replay
|
||||
let payload2 = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"gc-test"),
|
||||
},
|
||||
timestamp: t1,
|
||||
nonce: [5; 16],
|
||||
};
|
||||
let request2 = sign_request(&owner_kp, payload2);
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request2, t1),
|
||||
AuthzResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 10. Unauthorized key with valid signature → NotAuthorized
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn unauthorized_key_with_valid_signature_is_denied() {
|
||||
let (_owner_kp, mut engine) = owner_engine();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let now = 1_000_000u64;
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"intrusion"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [6; 16],
|
||||
};
|
||||
let request = sign_request(&stranger_kp, payload);
|
||||
|
||||
assert_eq!(
|
||||
engine.check_signed_request(&request, now),
|
||||
AuthzResult::Denied(DeniedReason::NotAuthorized)
|
||||
);
|
||||
}
|
||||
202
crates/datastore/tests/gateway_tests.rs
Normal file
202
crates/datastore/tests/gateway_tests.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Actor-level tests for the GatewayActor.
|
||||
//!
|
||||
//! Uses the swactor runtime to spawn GatewayActor + DatastoreNode and verify
|
||||
//! that authorized requests flow through while unauthorized ones are denied.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::{spawn_blob_store, spawn_metadata, test_runtime, tick_until_recv};
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use shared_types::ContentHash;
|
||||
use swactor_datastore::actors::{DatastoreNode, GatewayActor};
|
||||
use swactor_datastore::auth::{
|
||||
sign_request, AccessControlList, AuthzEngine, DatastoreAction, DeniedReason,
|
||||
SignedRequestPayload,
|
||||
};
|
||||
use swactor_datastore::messages::{DatastoreResponse, GatewayMsg};
|
||||
use swactor_datastore::types::DatastoreConfig;
|
||||
|
||||
struct GatewayHarness {
|
||||
rt: swactor::runtime::Runtime,
|
||||
gateway: swactor::actor::ActorAddress,
|
||||
inbox: swactor::runtime::Inbox<DatastoreResponse>,
|
||||
owner_kp: Keypair,
|
||||
}
|
||||
|
||||
impl GatewayHarness {
|
||||
fn new() -> Self {
|
||||
let owner_kp = Keypair::generate();
|
||||
let rt = test_runtime();
|
||||
|
||||
let blob_store = spawn_blob_store(&rt);
|
||||
let node_id = owner_kp.node_id();
|
||||
let metadata = spawn_metadata(&rt, node_id);
|
||||
|
||||
let mut config = DatastoreConfig::default();
|
||||
config.chunk_size = 64;
|
||||
let datastore_node = rt
|
||||
.spawn(DatastoreNode::new(node_id, blob_store, metadata, config))
|
||||
.unwrap();
|
||||
|
||||
let acl = AccessControlList {
|
||||
owner: owner_kp.node_id(),
|
||||
authorized_keys: HashSet::new(),
|
||||
};
|
||||
let engine = AuthzEngine::new(acl);
|
||||
let gateway = rt
|
||||
.spawn(GatewayActor::new(engine, datastore_node, None))
|
||||
.unwrap();
|
||||
|
||||
let inbox = rt.new_inbox::<DatastoreResponse>().unwrap();
|
||||
|
||||
// Let actors initialize
|
||||
for _ in 0..3 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
Self {
|
||||
rt,
|
||||
gateway,
|
||||
inbox,
|
||||
owner_kp,
|
||||
}
|
||||
}
|
||||
|
||||
fn reply_addr(&self) -> swactor::actor::ActorAddress {
|
||||
*self.inbox.addr()
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Authorized signed GET dispatches and returns result
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn authorized_signed_get_flows_through_to_datastore() {
|
||||
let h = GatewayHarness::new();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
|
||||
// PUT some data first via signed request
|
||||
let data = b"gateway test data";
|
||||
let content_hash = ContentHash::of(data);
|
||||
|
||||
// Store data by sending directly to datastore through a Put via gateway
|
||||
// (For simplicity, store via DatastoreNode first, then GET through gateway)
|
||||
// Actually, let's just do a GET for a nonexistent hash — we should get NotFound (not Denied)
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get { content_hash },
|
||||
timestamp: now,
|
||||
nonce: [10; 16],
|
||||
};
|
||||
let request = sign_request(&h.owner_kp, payload);
|
||||
|
||||
h.rt.send_to(
|
||||
h.gateway,
|
||||
GatewayMsg::HandleSignedRequest {
|
||||
request,
|
||||
reply_to: h.reply_addr(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = tick_until_recv(&h.rt, &h.inbox, 30).unwrap();
|
||||
// Should get NotFound (authorized, but object doesn't exist) — NOT Denied
|
||||
assert!(
|
||||
matches!(resp, DatastoreResponse::NotFound),
|
||||
"expected NotFound (authorized but missing), got {resp:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Unauthorized signed GET returns Denied
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn unauthorized_signed_get_returns_denied() {
|
||||
let h = GatewayHarness::new();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
|
||||
let payload = SignedRequestPayload {
|
||||
action: DatastoreAction::Get {
|
||||
content_hash: ContentHash::of(b"unauthorized"),
|
||||
},
|
||||
timestamp: now,
|
||||
nonce: [11; 16],
|
||||
};
|
||||
let request = sign_request(&stranger_kp, payload);
|
||||
|
||||
h.rt.send_to(
|
||||
h.gateway,
|
||||
GatewayMsg::HandleSignedRequest {
|
||||
request,
|
||||
reply_to: h.reply_addr(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = tick_until_recv(&h.rt, &h.inbox, 30).unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
resp,
|
||||
DatastoreResponse::Denied {
|
||||
reason: DeniedReason::NotAuthorized
|
||||
}
|
||||
),
|
||||
"expected Denied(NotAuthorized), got {resp:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Connection check allows/denies correctly
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn check_connection_allows_owner() {
|
||||
let h = GatewayHarness::new();
|
||||
|
||||
h.rt.send_to(
|
||||
h.gateway,
|
||||
GatewayMsg::CheckConnection {
|
||||
node_id: h.owner_kp.node_id(),
|
||||
reply_to: h.reply_addr(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = tick_until_recv(&h.rt, &h.inbox, 20).unwrap();
|
||||
assert!(
|
||||
matches!(resp, DatastoreResponse::Bool(true)),
|
||||
"expected Bool(true), got {resp:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_connection_denies_stranger() {
|
||||
let h = GatewayHarness::new();
|
||||
let stranger = Keypair::generate().node_id();
|
||||
|
||||
h.rt.send_to(
|
||||
h.gateway,
|
||||
GatewayMsg::CheckConnection {
|
||||
node_id: stranger,
|
||||
reply_to: h.reply_addr(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = tick_until_recv(&h.rt, &h.inbox, 20).unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
resp,
|
||||
DatastoreResponse::Denied {
|
||||
reason: DeniedReason::NotAuthorized
|
||||
}
|
||||
),
|
||||
"expected Denied(NotAuthorized), got {resp:?}"
|
||||
);
|
||||
}
|
||||
239
crates/datastore/tests/http_auth_integration.rs
Normal file
239
crates/datastore/tests/http_auth_integration.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! Integration test: HTTP API endpoints gated behind auth.
|
||||
//!
|
||||
//! Spins up a full actor runtime with GatewayActor, starts the HTTP API server,
|
||||
//! and uses ureq to prove that authorized requests succeed while unauthorized
|
||||
//! ones get 403 and missing-auth requests get 401.
|
||||
|
||||
#![cfg(feature = "node")]
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use distribution::crypto::Keypair;
|
||||
use shared_types::ContentHash;
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
|
||||
use swactor_datastore::api::start_api_server;
|
||||
use swactor_datastore::auth::{
|
||||
sign_request, AccessControlList, AuthzEngine, DatastoreAction, SignedRequestPayload,
|
||||
};
|
||||
use swactor_datastore::storage::InMemoryBackend;
|
||||
use swactor_datastore::types::DatastoreConfig;
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn random_nonce() -> [u8; 16] {
|
||||
let t = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let mut nonce = [0u8; 16];
|
||||
nonce.copy_from_slice(&t.to_le_bytes());
|
||||
nonce
|
||||
}
|
||||
|
||||
fn sign_header(keypair: &Keypair, action: DatastoreAction) -> String {
|
||||
let payload = SignedRequestPayload {
|
||||
action,
|
||||
timestamp: now_secs(),
|
||||
nonce: random_nonce(),
|
||||
};
|
||||
let request = sign_request(keypair, payload);
|
||||
serde_json::to_string(&request).unwrap()
|
||||
}
|
||||
|
||||
/// Find an available TCP port by binding to :0.
|
||||
fn available_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Scenario: Owner operates over HTTP; stranger is denied
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn http_auth_owner_allowed_stranger_denied() {
|
||||
let owner_kp = Keypair::generate();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let owner_id = owner_kp.node_id();
|
||||
|
||||
// ── Build runtime & actors ───────────────────────────────────────────
|
||||
let rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 2,
|
||||
max_actors: 256,
|
||||
channel_buffer_size: 1024,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let blob_store_addr = rt
|
||||
.spawn(BlobStoreActor::new(Box::new(InMemoryBackend::new())))
|
||||
.unwrap();
|
||||
|
||||
let mut metadata = MetadataActor::new(owner_id, &DatastoreConfig::default());
|
||||
metadata.set_blob_store(blob_store_addr);
|
||||
let metadata_addr = rt.spawn(metadata).unwrap();
|
||||
|
||||
let config = DatastoreConfig {
|
||||
chunk_size: 1_048_576,
|
||||
..Default::default()
|
||||
};
|
||||
let datastore_node = DatastoreNode::new(owner_id, blob_store_addr, metadata_addr, config);
|
||||
let datastore_addr = rt.spawn(datastore_node).unwrap();
|
||||
|
||||
let acl = AccessControlList {
|
||||
owner: owner_id,
|
||||
authorized_keys: HashSet::new(),
|
||||
};
|
||||
let engine = AuthzEngine::new(acl);
|
||||
let gateway_addr = rt
|
||||
.spawn(GatewayActor::new(engine, datastore_addr, None))
|
||||
.unwrap();
|
||||
|
||||
let handle = rt.run().expect("failed to start runtime");
|
||||
|
||||
// ── Start HTTP server ────────────────────────────────────────────────
|
||||
let port = available_port();
|
||||
let (shutdown, _peers) = start_api_server(
|
||||
handle.runtime.clone(),
|
||||
datastore_addr,
|
||||
metadata_addr,
|
||||
blob_store_addr,
|
||||
Some(gateway_addr),
|
||||
port,
|
||||
);
|
||||
|
||||
// Give the HTTP server threads a moment to start accepting connections.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// ── 1. Owner PUTs data ───────────────────────────────────────────────
|
||||
let test_data = b"hello from the integration test";
|
||||
let expected_hash = ContentHash::of(test_data);
|
||||
|
||||
let put_header = sign_header(
|
||||
&owner_kp,
|
||||
DatastoreAction::Put {
|
||||
name: Some("test.txt".to_string()),
|
||||
content_hash: expected_hash,
|
||||
size_bytes: test_data.len() as u64,
|
||||
tags: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let put_resp = ureq::post(&format!("{base}/api/put?name=test.txt"))
|
||||
.set("X-Signed-Request", &put_header)
|
||||
.send_bytes(test_data)
|
||||
.expect("PUT request failed");
|
||||
|
||||
assert_eq!(put_resp.status(), 200);
|
||||
let put_body: serde_json::Value = put_resp.into_json().unwrap();
|
||||
let returned_hash = put_body["content_hash"].as_str().unwrap();
|
||||
assert_eq!(returned_hash, expected_hash.to_hex());
|
||||
|
||||
// ── 2. Owner GETs it back ────────────────────────────────────────────
|
||||
let get_header = sign_header(
|
||||
&owner_kp,
|
||||
DatastoreAction::Get {
|
||||
content_hash: expected_hash,
|
||||
},
|
||||
);
|
||||
|
||||
let get_resp = ureq::get(&format!("{base}/api/get?hash={}", expected_hash.to_hex()))
|
||||
.set("X-Signed-Request", &get_header)
|
||||
.call()
|
||||
.expect("GET request failed");
|
||||
|
||||
assert_eq!(get_resp.status(), 200);
|
||||
let get_body: serde_json::Value = get_resp.into_json().unwrap();
|
||||
assert_eq!(
|
||||
get_body["entry"]["content_hash"].as_str().unwrap(),
|
||||
expected_hash.to_hex()
|
||||
);
|
||||
|
||||
// ── 3. Owner LISTs ──────────────────────────────────────────────────
|
||||
let list_header = sign_header(
|
||||
&owner_kp,
|
||||
DatastoreAction::List { name_filter: None },
|
||||
);
|
||||
|
||||
let list_resp = ureq::get(&format!("{base}/api/list"))
|
||||
.set("X-Signed-Request", &list_header)
|
||||
.call()
|
||||
.expect("LIST request failed");
|
||||
|
||||
assert_eq!(list_resp.status(), 200);
|
||||
let list_body: serde_json::Value = list_resp.into_json().unwrap();
|
||||
let entries = list_body["entries"].as_array().unwrap();
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e["content_hash"].as_str() == Some(&expected_hash.to_hex())),
|
||||
"expected hash in list results"
|
||||
);
|
||||
|
||||
// ── 4. Stranger tries GET → 403 ─────────────────────────────────────
|
||||
let stranger_header = sign_header(
|
||||
&stranger_kp,
|
||||
DatastoreAction::Get {
|
||||
content_hash: expected_hash,
|
||||
},
|
||||
);
|
||||
|
||||
let stranger_resp = ureq::get(&format!(
|
||||
"{base}/api/get?hash={}",
|
||||
expected_hash.to_hex()
|
||||
))
|
||||
.set("X-Signed-Request", &stranger_header)
|
||||
.call();
|
||||
|
||||
match stranger_resp {
|
||||
Err(ureq::Error::Status(403, _)) => {} // expected
|
||||
Err(e) => panic!("expected 403, got error: {e}"),
|
||||
Ok(r) => panic!("expected 403, got {}", r.status()),
|
||||
}
|
||||
|
||||
// ── 5. No auth header → 401 ─────────────────────────────────────────
|
||||
let no_auth_resp = ureq::get(&format!(
|
||||
"{base}/api/get?hash={}",
|
||||
expected_hash.to_hex()
|
||||
))
|
||||
.call();
|
||||
|
||||
match no_auth_resp {
|
||||
Err(ureq::Error::Status(401, _)) => {} // expected
|
||||
Err(e) => panic!("expected 401, got error: {e}"),
|
||||
Ok(r) => panic!("expected 401, got {}", r.status()),
|
||||
}
|
||||
|
||||
// ── 6. Owner DELETEs ─────────────────────────────────────────────────
|
||||
let delete_header = sign_header(
|
||||
&owner_kp,
|
||||
DatastoreAction::Delete {
|
||||
content_hash: expected_hash,
|
||||
},
|
||||
);
|
||||
|
||||
let delete_resp = ureq::post(&format!(
|
||||
"{base}/api/delete?hash={}",
|
||||
expected_hash.to_hex()
|
||||
))
|
||||
.set("X-Signed-Request", &delete_header)
|
||||
.send_bytes(&[])
|
||||
.expect("DELETE request failed");
|
||||
|
||||
assert_eq!(delete_resp.status(), 200);
|
||||
|
||||
// ── Teardown ─────────────────────────────────────────────────────────
|
||||
shutdown.store(true, Ordering::Relaxed);
|
||||
handle.shutdown();
|
||||
handle.join();
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ iroh = ["dep:iroh", "dep:tokio"]
|
|||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", features = ["serde", "transport"] }
|
||||
shared-types = { path = "../shared-types" }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
|
|||
8
crates/shared-types/Cargo.toml
Normal file
8
crates/shared-types/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "shared-types"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
blake3 = "1"
|
||||
106
crates/shared-types/src/lib.rs
Normal file
106
crates/shared-types/src/lib.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Shared types used across the swactor crate ecosystem.
|
||||
//!
|
||||
//! Contains `ContentHash` — the blake3-based content address used by
|
||||
//! both the datastore and distribution layers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── ContentHash ────────────────────────────────────────────────────────────
|
||||
|
||||
/// A blake3 content hash (32 bytes).
|
||||
///
|
||||
/// The primary identifier for blobs and the DHT key. XOR distance for DHT
|
||||
/// routing, compact Debug/Display for logging.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ContentHash(pub [u8; 32]);
|
||||
|
||||
impl ContentHash {
|
||||
/// Compute the blake3 hash of the given data.
|
||||
pub fn of(data: &[u8]) -> Self {
|
||||
let hash = blake3::hash(data);
|
||||
ContentHash(*hash.as_bytes())
|
||||
}
|
||||
|
||||
/// XOR distance between two content hashes (Kademlia metric).
|
||||
pub fn xor_distance(&self, other: &ContentHash) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = self.0[i] ^ other.0[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of leading zero bits in the XOR distance to `other`.
|
||||
/// Returns 0..=256. Used to select the k-bucket index in the metadata DHT.
|
||||
pub fn xor_leading_zeros(&self, other: &ContentHash) -> u32 {
|
||||
let dist = self.xor_distance(other);
|
||||
let mut zeros = 0u32;
|
||||
for byte in dist {
|
||||
if byte == 0 {
|
||||
zeros += 8;
|
||||
} else {
|
||||
zeros += byte.leading_zeros();
|
||||
break;
|
||||
}
|
||||
}
|
||||
zeros
|
||||
}
|
||||
|
||||
/// Parse a 64-character hex string into a ContentHash.
|
||||
/// Returns `None` if the string is not exactly 64 hex characters.
|
||||
pub fn from_hex(hex: &str) -> Option<Self> {
|
||||
if hex.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
let hi = hex_digit(chunk[0])?;
|
||||
let lo = hex_digit(chunk[1])?;
|
||||
bytes[i] = (hi << 4) | lo;
|
||||
}
|
||||
Some(ContentHash(bytes))
|
||||
}
|
||||
|
||||
/// Encode as lowercase hex string.
|
||||
pub fn to_hex(&self) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in &self.0 {
|
||||
use fmt::Write;
|
||||
write!(s, "{:02x}", b).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The zero hash (all zeroes). Used as a sentinel.
|
||||
pub const ZERO: ContentHash = ContentHash([0u8; 32]);
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContentHash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Hash(")?;
|
||||
for b in &self.0[..4] {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
write!(f, "\u{2026})")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContentHash {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for b in &self.0[..8] {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
write!(f, "\u{2026}")
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
305
docs/development_history/datastore-auth/DATASTORE_AUTH.md
Normal file
305
docs/development_history/datastore-auth/DATASTORE_AUTH.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# Swactor Datastore Auth Specification
|
||||
|
||||
**Version:** 0.1.0 (MVP)
|
||||
**Status:** Draft
|
||||
**Companion to:** `DATASTORE_PROTOCOL.md`
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document specifies the authorization layer for the Swactor Datastore. It defines how access is controlled for external clients connecting to a datastore node.
|
||||
|
||||
### Principles
|
||||
|
||||
- **Cryptographic identity** — keys, not passwords. Every participant is identified by an ed25519 public key (`NodeId`).
|
||||
- **Binary access** — a client is either authorized or not. No permission tiers for MVP.
|
||||
- **Owner-only administration** — only the datastore owner can grant or revoke access.
|
||||
- **Transport-layer authentication** — iroh's QUIC handshake cryptographically proves a peer's `NodeId`. This spec builds authorization on top of that.
|
||||
|
||||
### Non-Goals (MVP)
|
||||
|
||||
- Per-path permission scoping.
|
||||
- Permission tiers (read-only, read-write, admin).
|
||||
- Capability tokens or time-limited delegated access.
|
||||
- Multi-level delegation chains.
|
||||
|
||||
## 2. Trust Boundaries
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Cluster (SWIM mesh) │
|
||||
│ │
|
||||
│ Node A ◄──────────────► Node B │
|
||||
│ implicitly trusted │
|
||||
│ (no auth checks) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│
|
||||
│ auth boundary
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ External Clients │
|
||||
│ │
|
||||
│ CLI tool │
|
||||
│ Browser user │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
- **Cluster-internal** (node-to-node via SWIM): implicitly trusted. Nodes that are members of the SWIM cluster communicate freely — no per-request auth checks.
|
||||
- **External clients** (CLI, browser): must be authorized. Every external request is checked against the Access Control List before being dispatched to the actor system.
|
||||
|
||||
## 3. Identity Model
|
||||
|
||||
The auth layer reuses the existing ed25519 identity model from the distribution layer:
|
||||
|
||||
- Every client (CLI tool, browser user, node) has an ed25519 keypair.
|
||||
- Identity is the 32-byte public key, represented as `NodeId`.
|
||||
- The same `NodeId` type from `distribution::types` is used throughout.
|
||||
|
||||
There is no separate "user" concept — a keypair *is* an identity.
|
||||
|
||||
## 4. Access Control List
|
||||
|
||||
### 4.1 Structure
|
||||
|
||||
```
|
||||
AccessControlList {
|
||||
owner: NodeId, // The datastore owner's public key
|
||||
authorized_keys: Set<NodeId>, // Explicitly authorized client keys
|
||||
}
|
||||
```
|
||||
|
||||
- The **owner** always has full access (implicit; never needs to be in `authorized_keys`).
|
||||
- An empty `authorized_keys` set means only the owner can access the datastore.
|
||||
|
||||
### 4.2 Persistence
|
||||
|
||||
The ACL is persisted as a JSON file alongside the datastore's `storage_path`:
|
||||
|
||||
```
|
||||
{storage_path}/
|
||||
├── chunks/
|
||||
├── manifests/
|
||||
└── acl.json # AccessControlList
|
||||
```
|
||||
|
||||
### 4.3 Mutations
|
||||
|
||||
| Operation | Signature | Who |
|
||||
|-----------|-----------|-----|
|
||||
| Grant access | `grant(key: NodeId)` | Owner only |
|
||||
| Revoke access | `revoke(key: NodeId)` | Owner only |
|
||||
|
||||
- `grant` adds a `NodeId` to `authorized_keys`. Idempotent — granting an already-authorized key is a no-op.
|
||||
- `revoke` removes a `NodeId` from `authorized_keys`. Idempotent — revoking a non-existent key is a no-op.
|
||||
- Revoking the owner is a no-op (the owner's implicit access cannot be removed).
|
||||
- Both operations persist the updated ACL to disk immediately.
|
||||
|
||||
## 5. Auth Path 1 — Direct iroh Connection
|
||||
|
||||
For clients that connect directly to the datastore node over iroh (QUIC):
|
||||
|
||||
```
|
||||
Client (ed25519 keypair) Datastore Node
|
||||
│ │
|
||||
│──── iroh QUIC handshake ──────────>│
|
||||
│ (proves client's NodeId) │
|
||||
│ │
|
||||
│ check NodeId
|
||||
│ against ACL
|
||||
│ │
|
||||
│<─── accept / reject ──────────────│
|
||||
│ │
|
||||
│ (if accepted, all ops on │
|
||||
│ this connection are allowed) │
|
||||
```
|
||||
|
||||
1. The iroh QUIC handshake cryptographically proves the peer's `NodeId` (ed25519 public key).
|
||||
2. On connection establishment, the node checks the peer's `NodeId` against the ACL.
|
||||
3. If authorized → connection accepted. All operations on that connection are allowed with no per-message overhead.
|
||||
4. If not authorized → connection rejected immediately.
|
||||
|
||||
This is the preferred auth path — zero overhead after the initial handshake.
|
||||
|
||||
## 6. Auth Path 2 — Signed Requests (Browser Relay)
|
||||
|
||||
For browser users who cannot establish direct iroh connections (e.g., because the browser communicates via a website backend that relays requests):
|
||||
|
||||
### 6.1 Threat Model
|
||||
|
||||
The website backend acts as an **untrusted relay**. It forwards requests between the browser and the datastore node but never sees private keys. The relay cannot forge, modify, or replay requests.
|
||||
|
||||
### 6.2 Signed Envelope
|
||||
|
||||
Each request is wrapped in a signed envelope:
|
||||
|
||||
```
|
||||
SignedRequest {
|
||||
payload: SignedRequestPayload, // The request details
|
||||
public_key: NodeId, // Client's public key
|
||||
signature: Signature, // ed25519 signature over serialized payload
|
||||
}
|
||||
|
||||
SignedRequestPayload {
|
||||
action: DatastoreAction, // What the client wants to do
|
||||
timestamp: u64, // Unix timestamp (seconds)
|
||||
nonce: [u8; 16], // 16 random bytes
|
||||
}
|
||||
|
||||
DatastoreAction = enum {
|
||||
Put { name, content_hash, size_bytes, tags },
|
||||
Get { content_hash },
|
||||
Delete { content_hash },
|
||||
List { name_filter },
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Verification Steps
|
||||
|
||||
The datastore node verifies a signed request in strict order:
|
||||
|
||||
1. **Signature validity** — verify the ed25519 signature over the canonical serialization of `SignedRequestPayload` using the provided `public_key`.
|
||||
2. **Timestamp freshness** — reject if `|now - payload.timestamp| > 300` seconds.
|
||||
3. **Nonce uniqueness** — reject if `payload.nonce` has been seen before within the time window.
|
||||
4. **ACL check** — reject if `public_key` is not in the ACL.
|
||||
|
||||
If any step fails, the request is denied with the corresponding `DeniedReason`.
|
||||
|
||||
### 6.4 Put Payload Note
|
||||
|
||||
`DatastoreAction::Put` references a `content_hash` rather than embedding raw file data. The bulk data is uploaded separately, and its integrity is guaranteed by blake3 content addressing. The signed envelope authorizes the *operation*, not the data transfer.
|
||||
|
||||
## 7. Replay Protection
|
||||
|
||||
### 7.1 Timestamp Window
|
||||
|
||||
- Requests must have a `timestamp` within ±300 seconds of the node's wall clock.
|
||||
- This bounds the maximum clock drift between client and server.
|
||||
- Requests outside this window are rejected with `DeniedReason::RequestExpired`.
|
||||
|
||||
### 7.2 Nonce
|
||||
|
||||
- Each request includes a 16-byte random nonce.
|
||||
- The node maintains a set of recently seen nonces.
|
||||
- Duplicate nonces within the time window are rejected with `DeniedReason::ReplayDetected`.
|
||||
|
||||
### 7.3 Nonce Garbage Collection
|
||||
|
||||
- Nonces are stored alongside their timestamps.
|
||||
- When a nonce's timestamp falls outside the ±300 second window, it is eligible for GC.
|
||||
- GC runs periodically (piggy-backed on request processing or a background sweep).
|
||||
|
||||
## 8. Enforcement Point
|
||||
|
||||
Auth is enforced at the **edge** of the actor system — between external clients and the internal actors:
|
||||
|
||||
```
|
||||
External Client
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Auth Gate │◄── ACL check happens here
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌─────────────────┐ ┌────────────────┐
|
||||
│ MetadataActor│◄──►│ BlobStoreActor │ │ TransferActor │
|
||||
│ │ │ │ │ │
|
||||
│ (auth- │ │ (auth- │ │ (auth- │
|
||||
│ unaware) │ │ unaware) │ │ unaware) │
|
||||
└──────────────┘ └─────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
### 8.1 Direct iroh Connections
|
||||
|
||||
- Auth check at connection acceptance time.
|
||||
- Once accepted, the connection is fully trusted for all operations.
|
||||
- No per-message overhead.
|
||||
|
||||
### 8.2 Signed Requests (Browser Relay)
|
||||
|
||||
- A `GatewayActor` receives signed request envelopes.
|
||||
- The GatewayActor verifies the envelope (signature, timestamp, nonce, ACL).
|
||||
- If valid, the GatewayActor dispatches the inner action to the `MetadataActor`.
|
||||
- If invalid, the GatewayActor returns the denial reason to the relay.
|
||||
|
||||
### 8.3 Internal Actors
|
||||
|
||||
`MetadataActor`, `BlobStoreActor`, and `TransferActor` remain **auth-unaware**. They process messages from any source within the actor system. The auth boundary is strictly external.
|
||||
|
||||
## 9. Key Management
|
||||
|
||||
### 9.1 Key Generation
|
||||
|
||||
- Uses `ed25519_dalek` keypairs (same as node identity).
|
||||
- CLI: `swactor-store auth keygen` generates a new keypair and prints both the secret key (for the client to store) and the public key (to share with the owner).
|
||||
- Browser: keypair generated client-side using WebCrypto Ed25519 or wasm-compiled ed25519. The private key never leaves the browser.
|
||||
|
||||
### 9.2 Grant Flow
|
||||
|
||||
```
|
||||
1. Client generates an ed25519 keypair.
|
||||
2. Client shares their public key with the datastore owner (out-of-band).
|
||||
3. Owner runs: swactor-store auth grant <pubkey>
|
||||
4. Client can now access the datastore.
|
||||
```
|
||||
|
||||
The out-of-band exchange is intentional — it keeps the trust model simple. The owner explicitly decides who gets access.
|
||||
|
||||
### 9.3 Revocation
|
||||
|
||||
```
|
||||
1. Owner runs: swactor-store auth revoke <pubkey>
|
||||
2. Client's access is immediately revoked.
|
||||
3. Existing direct iroh connections from that client remain open until disconnected.
|
||||
4. Signed requests from the revoked key are rejected immediately.
|
||||
```
|
||||
|
||||
Note: revoking a key does not forcibly disconnect an active iroh session. The revocation takes effect on the next connection attempt. For immediate disconnection, the owner should also restart the node or implement connection tracking (future extension).
|
||||
|
||||
## 10. CLI Extensions
|
||||
|
||||
The following subcommands are added under `swactor-store auth`:
|
||||
|
||||
```
|
||||
swactor-store auth keygen
|
||||
Generate a new ed25519 keypair.
|
||||
Prints the public key (hex) and secret key (hex) to stdout.
|
||||
|
||||
swactor-store auth grant <pubkey>
|
||||
Add a public key to the ACL's authorized_keys set.
|
||||
Requires running on the owner's node.
|
||||
|
||||
swactor-store auth revoke <pubkey>
|
||||
Remove a public key from the ACL's authorized_keys set.
|
||||
Requires running on the owner's node.
|
||||
|
||||
swactor-store auth list
|
||||
Show all authorized keys (including the owner).
|
||||
|
||||
swactor-store auth whoami
|
||||
Show this node's public key (NodeId).
|
||||
```
|
||||
|
||||
## 11. Integration with Datastore Protocol
|
||||
|
||||
Each protocol flow from `DATASTORE_PROTOCOL.md` §6 has a clear auth integration point:
|
||||
|
||||
| Protocol Flow | Auth Path 1 (Direct) | Auth Path 2 (Signed Request) |
|
||||
|---------------|----------------------|------------------------------|
|
||||
| §6.1 PUT | Connection-level ACL check | `SignedRequest { action: Put { name, content_hash, size_bytes, tags }, .. }` |
|
||||
| §6.2 GET (Local) | Connection-level ACL check | `SignedRequest { action: Get { content_hash }, .. }` |
|
||||
| §6.3 GET (Remote) | Connection-level ACL check | `SignedRequest { action: Get { content_hash }, .. }` → node handles remote fetch internally |
|
||||
| §6.4 DELETE | Connection-level ACL check | `SignedRequest { action: Delete { content_hash }, .. }` |
|
||||
| §6.5 LIST (Local) | Connection-level ACL check | `SignedRequest { action: List { name_filter }, .. }` |
|
||||
| §6.6 LIST (Swarm-Wide) | Connection-level ACL check | `SignedRequest { action: List { name_filter }, .. }` → node handles fan-out internally |
|
||||
|
||||
In all cases, auth is enforced *before* the request reaches the actor system. Internal inter-node communication (DHT replication, chunk transfers between cluster members) is not subject to auth checks.
|
||||
|
||||
## 12. Future Extensions
|
||||
|
||||
These are explicitly **out of scope** for MVP but inform the design:
|
||||
|
||||
- **Per-path permission scoping** — restrict a key to specific path prefixes (e.g., read-only access to `photos/`).
|
||||
- **Permission tiers** — read-only, read-write, admin roles.
|
||||
- **Capability tokens** — time-limited, scope-limited bearer tokens for delegated access without sharing long-lived keys.
|
||||
- **Multi-level delegation** — allow authorized users to grant limited access to others.
|
||||
- **Connection tracking** — forcibly disconnect revoked keys from active iroh sessions.
|
||||
178
docs/development_history/datastore-auth/SUMMARY.md
Normal file
178
docs/development_history/datastore-auth/SUMMARY.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# 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, `DeniedReason` enum.
|
||||
- **`src/actors/gateway.rs`** — `GatewayActor` wrapping `AuthzEngine`. Handles `Authorize` (pure auth check), `HandleSignedRequest` (auth + dispatch), `CheckConnection` (Path 1), `Grant`/`Revoke` (owner-only ACL mutations), `NonceGcTick`. Translates `DatastoreAction` to `DatastoreNodeMsg` via `action_to_node_msg()`.
|
||||
- **`src/messages.rs`** — `GatewayMsg` enum, `DatastoreResponse::Denied` variant.
|
||||
- **`shared-types` crate** — Extracted `ContentHash` into its own crate so both `distribution` and `datastore` can 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::Put` carries `content_hash`, `size_bytes`, and `tags` (not raw data).
|
||||
- `DatastoreAction::Get`/`Delete` use `content_hash` (not name).
|
||||
- `DatastoreAction::List` uses `name_filter`.
|
||||
- `GatewayActor::action_to_node_msg` maps actions to the existing `DatastoreNodeMsg` variants.
|
||||
- Wired `check_auth()` into the HTTP API handlers (put, get, data, delete, list) — reads `X-Signed-Request` header, sends `GatewayMsg::Authorize` to the gateway actor, denies with 401/403/504 on failure.
|
||||
- `handle_status` intentionally 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.json` with owner as sole authorized key.
|
||||
- Spawns `GatewayActor` with the `AuthzEngine` and passes `Some(gateway_addr)` to `start_api_server`.
|
||||
- Sends `GatewayMsg::NonceGcTick` on the same cadence as the metadata GC tick.
|
||||
- Without `--auth`, behavior is unchanged (random NodeId, no gateway, `None` passed 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 a `SignedRequestPayload` with current timestamp + `getrandom` nonce, signs it, and sends the JSON as `X-Signed-Request` header.
|
||||
- `status` command 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`:
|
||||
|
||||
```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
|
||||
|
||||
1. **Key file format is JSON with hex encoding** — human-readable, inspectable with `cat`, foundation for future keystore without needing a binary format parser.
|
||||
|
||||
2. **Status endpoint stays open** — `/api/status` is not gated even when auth is enabled. This lets monitoring tools and health checks work without credentials.
|
||||
|
||||
3. **Node identity = owner keypair's public key** — when `--auth` is enabled, the keypair's `node_id()` replaces random generation. The node has a stable, cryptographic identity across restarts.
|
||||
|
||||
4. **Nonce source is `getrandom`** — cryptographically secure 16-byte random nonces. Already a transitive dependency via `ed25519-dalek` / `rand_core`.
|
||||
|
||||
5. **Backward compatible** — without `--auth` (node) or `--key` (CLI), everything works exactly as before. No breaking changes.
|
||||
|
||||
6. **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 |
|
||||
Loading…
Reference in a new issue