swactor/crates/datastore/src/auth.rs
Zachery Aaron Shores-Chmielewski 3cf0ae5947 refactor: consolidate crate functions (#50)
Remove co-dependencies for different modules found in `crates` and migrate the development history to a new repository. The docs were stale, and largely not getting used, so simply deleted for now. When code stabilizes more, they will become useful again.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-24 09:12:28 +00:00

328 lines
12 KiB
Rust

//! 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 crate::crypto;
use swactor::transport::NodeId;
use crate::crypto::Signature;
use crate::content_hash::ContentHash;
// ─── Access Request / Authorized Key Info ──────────────────────────────────
/// A pending access request from a browser user.
#[derive(Debug, Clone)]
pub struct AccessRequestInfo {
pub key: NodeId,
pub name: String,
pub message: String,
pub requested_at: u64,
}
/// An authorized key with its human-readable label.
#[derive(Debug, Clone)]
pub struct AuthorizedKeyInfo {
pub key: NodeId,
pub label: String,
}
// ─── 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>,
},
/// Browser-originated request — proves identity without binding to specific content.
Access,
}
// ─── 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>,
/// Human-readable labels for authorized keys (hex → name).
#[serde(default)]
pub key_labels: HashMap<String, String>,
}
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(),
key_labels: HashMap::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::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 signature, timestamp, and nonce — but skip the ACL check.
///
/// Used for endpoints where the caller proves key ownership without
/// needing to be in the ACL (e.g. submitting an access request).
pub fn check_signature_only(&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 = now.abs_diff(ts);
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);
AuthzResult::Allowed
}
/// 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 = now.abs_diff(ts);
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.
/// If `label` is provided, it's stored as a human-readable name for the key.
pub fn grant(&mut self, requester: &NodeId, key: NodeId, label: Option<String>) -> Result<(), DeniedReason> {
if *requester != self.acl.owner {
return Err(DeniedReason::NotAuthorized);
}
if key == self.acl.owner {
return Ok(()); // Owner has implicit access — no-op
}
self.acl.authorized_keys.insert(key);
if let Some(name) = label {
let hex: String = key.0.iter().map(|b| format!("{b:02x}")).collect();
self.acl.key_labels.insert(hex, name);
}
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);
let hex: String = key.0.iter().map(|b| format!("{b:02x}")).collect();
self.acl.key_labels.remove(&hex);
}
Ok(())
}
/// List all authorized keys with their labels.
pub fn authorized_key_list(&self) -> Vec<AuthorizedKeyInfo> {
self.acl
.authorized_keys
.iter()
.map(|key| {
let hex: String = key.0.iter().map(|b| format!("{b:02x}")).collect();
let label = self.acl.key_labels.get(&hex).cloned().unwrap_or_default();
AuthorizedKeyInfo { key: *key, label }
})
.collect()
}
/// 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 = now.abs_diff(*ts);
diff <= self.timestamp_window
});
}
}