Skip to content
This repository was archived by the owner on May 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion src/bin/folddb_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ use tokio::task::JoinSet;
/// gets 9101..=9199 via run.sh's auto-slot logic.
const DEFAULT_DEV_HTTP_PORT: u16 = 9101;

/// Construct the HTTP bind address from `port`. Always loopback — see the
/// `Trust boundary: loopback owner context` section in CLAUDE.md and the
/// `auth model` decision (D1) for the canonical /api/setup/bootstrap path:
/// the bootstrap handler self-disables on a marker file, with no token
/// authentication, so loopback isolation is the only thing keeping a
/// hostile process on another machine from rotating the node's identity.
/// If you change this to `0.0.0.0` you MUST also add per-request caller
/// authentication and gate the bootstrap handler on it.
fn bind_address_for(port: u16) -> String {
format!("127.0.0.1:{}", port)
}

/// Command line options for the HTTP server binary.
///
/// The HTTP server is now stateless - it accepts any user_hash from the
Expand Down Expand Up @@ -254,7 +266,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
ctx.spawn_workers(&mut tasks);

// Phase 3: bind and serve.
let bind_address = format!("127.0.0.1:{}", http_port);
let bind_address = bind_address_for(http_port);
let http_server = FoldHttpServer::new(ctx, &bind_address);
http_server
.run()
Expand Down Expand Up @@ -311,6 +323,46 @@ mod tests {
assert!(!cli.demo);
}

/// The bootstrap auth model in `src/server/routes/setup.rs` rests on
/// the daemon binding loopback only — there's no token check on
/// `/api/setup/bootstrap`, just a one-shot marker file. If the daemon
/// ever binds 0.0.0.0 (or any non-loopback IP), a hostile process on
/// another machine could POST /api/setup/bootstrap before the user
/// finishes onboarding and rotate the node's identity. This test
/// guards against the bind helper drifting away from that invariant.
#[test]
fn bind_address_is_loopback() {
use std::net::SocketAddr;
let addr = super::bind_address_for(super::DEFAULT_DEV_HTTP_PORT);
let parsed: SocketAddr = addr
.parse()
.expect("bind address must be a valid SocketAddr");
assert!(
parsed.ip().is_loopback(),
"bind address must be loopback, got {} — see the loopback-trust comment on bind_address_for",
parsed
);
assert_eq!(parsed.port(), super::DEFAULT_DEV_HTTP_PORT);
}

/// Same invariant for non-default ports — port choice cannot become
/// a backdoor for binding off-loopback.
#[test]
fn bind_address_is_loopback_for_arbitrary_ports() {
use std::net::SocketAddr;
for port in [80u16, 1024, 9001, 9101, 65535] {
let parsed: SocketAddr = super::bind_address_for(port)
.parse()
.expect("bind address must parse");
assert!(
parsed.ip().is_loopback(),
"bind address must be loopback for port {}, got {}",
port,
parsed
);
}
}

#[test]
fn demo_data_dir() {
let normal = super::default_data_dir(false);
Expand Down
1 change: 1 addition & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod mutation;
pub mod org;
pub mod query;
pub mod response;
pub mod setup;
pub mod sharing;
pub mod snapshot;
pub mod system;
Expand Down
210 changes: 210 additions & 0 deletions src/handlers/setup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! Shared bootstrap helpers used by both `POST /api/setup/bootstrap`
//! (the canonical server-side path) and the CLI wizard wrapper.
//!
//! These helpers do not touch HTTP/Actix types — the route module
//! and the CLI wrap them.

use base64::Engine;
use fold_db::security::{Ed25519KeyPair, KeyUtils};
use serde::Deserialize;

/// Response body returned by the Exemem `POST /api/auth/cli/register`
/// endpoint. Only the fields fold_db_node consumes are deserialized;
/// extras are ignored so a future Exemem revision doesn't break us.
#[derive(Debug, Deserialize)]
pub struct ExememRegisterResponse {
pub ok: bool,
#[serde(default)]
pub user_hash: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub message: Option<String>,
}

/// Hex-encode raw bytes (lower-case, no separator).
pub fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

/// Sign the canonical CLI-register payload `"{public_key_hex}:{timestamp}"`
/// with the node's base64-encoded Ed25519 private key. Returns the base64
/// signature.
///
/// Must stay in sync with:
/// - `fold_db_node/src/server/routes/auth.rs::sign_payload`
/// - `exemem-infra/lambdas/auth_service/src/cli/types.rs::verify_ed25519_signature`
pub fn sign_cli_register_payload(
private_key_b64: &str,
public_key_hex: &str,
timestamp: i64,
) -> Result<String, String> {
let key_bytes = base64::engine::general_purpose::STANDARD
.decode(private_key_b64)
.map_err(|e| format!("Failed to decode private key: {}", e))?;
let key_pair = Ed25519KeyPair::from_secret_key(&key_bytes)
.map_err(|e| format!("Failed to create key pair: {}", e))?;
let payload = format!("{}:{}", public_key_hex, timestamp);
let signature = key_pair.sign(payload.as_bytes());
Ok(KeyUtils::signature_to_base64(&signature))
}

/// Register the node's public key with the Exemem API, optionally passing
/// an invite code.
///
/// Always sends a signed request — the caller must provide the node's
/// base64-encoded Ed25519 private key so the payload can be signed.
///
/// Synchronous (uses `reqwest::blocking`); the route handler wraps this in
/// `tokio::task::spawn_blocking`. Kept blocking so the CLI binary — which
/// has no tokio runtime around the call — can use it directly.
pub fn register_with_exemem_and_invite(
api_url: &str,
public_key_hex: &str,
private_key_b64: &str,
invite_code: Option<&str>,
) -> Result<ExememRegisterResponse, String> {
let url = format!("{}/api/auth/cli/register", api_url.trim_end_matches('/'));
let timestamp = chrono::Utc::now().timestamp();
let signature = sign_cli_register_payload(private_key_b64, public_key_hex, timestamp)?;
let mut body = serde_json::json!({
"public_key": public_key_hex,
"timestamp": timestamp,
"signature": signature,
});
if let Some(code) = invite_code {
body["invite_code"] = serde_json::Value::String(code.to_string());
}

// Run the blocking client on a dedicated thread so a tokio runtime
// (when this is called from inside a `spawn_blocking`) isn't pinned by
// the synchronous reqwest worker.
let result = std::thread::spawn(move || {
// skip-3p: outbound to Exemem cloud API; trace propagation is
// injected at the route layer when we move to async reqwest.
let client = reqwest::blocking::Client::new();
client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(30))
.send()
})
.join()
.map_err(|_| "Registration request thread panicked".to_string())?
.map_err(|e| format!("Failed to reach Exemem API: {}", e))?;

let status = result.status();
let body_text = result
.text()
.map_err(|e| format!("Failed to read response body: {}", e))?;

if !status.is_success() {
let msg = serde_json::from_str::<serde_json::Value>(&body_text)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(body_text);
return Err(format!(
"Exemem registration failed (HTTP {}): {}",
status, msg
));
}

let resp: ExememRegisterResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse registration response: {}", e))?;

if !resp.ok {
let msg = resp.message.unwrap_or_else(|| "Unknown error".to_string());
return Err(format!("Exemem registration failed: {}", msg));
}

Ok(resp)
}

/// Reconstruct an Ed25519 keypair (NodeIdentity) from a 24-word BIP39
/// recovery phrase. Mirrors `handlers::auth::restore_from_phrase`'s
/// derivation so a phrase produced by [`derive_recovery_phrase`] yields
/// the original keypair.
pub fn identity_from_phrase(words: &str) -> Result<crate::identity::NodeIdentity, String> {
let mnemonic = bip39::Mnemonic::parse(words.trim())
.map_err(|e| format!("Invalid recovery phrase: {}", e))?;
let entropy = mnemonic.to_entropy();
if entropy.len() != 32 {
return Err(format!(
"Recovery phrase must encode 32 bytes, got {}",
entropy.len()
));
}
let key_pair = Ed25519KeyPair::from_secret_key(&entropy)
.map_err(|e| format!("Failed to derive keypair from phrase: {}", e))?;
let private_key = base64::engine::general_purpose::STANDARD.encode(&entropy);
let public_key = base64::engine::general_purpose::STANDARD.encode(key_pair.public_key_bytes());
Ok(crate::identity::NodeIdentity {
private_key,
public_key,
})
}

/// Derive a 24-word BIP39 recovery phrase from an Ed25519 private key.
///
/// Uses the first 32 bytes of the secret-key as entropy. Returns
/// `Err` if the decoded private key is shorter than 32 bytes.
pub fn derive_recovery_phrase(private_key_b64: &str) -> Result<Vec<String>, String> {
let key_bytes = base64::engine::general_purpose::STANDARD
.decode(private_key_b64)
.map_err(|e| format!("Failed to decode private key: {}", e))?;

if key_bytes.len() < 32 {
return Err("Private key too short for recovery phrase".to_string());
}
let entropy = &key_bytes[..32];

let mnemonic = bip39::Mnemonic::from_entropy(entropy)
.map_err(|e| format!("Failed to generate mnemonic: {}", e))?;

Ok(mnemonic.words().map(|w| w.to_string()).collect())
}

#[cfg(test)]
mod tests {
use super::*;
use fold_db::security::Ed25519KeyPair;

#[test]
fn sign_cli_register_payload_round_trips() {
let key_pair = Ed25519KeyPair::generate().expect("generate keypair");
let private_key_b64 = key_pair.secret_key_base64();
let public_key_b64 = key_pair.public_key_base64();
let pub_key_bytes = base64::engine::general_purpose::STANDARD
.decode(&public_key_b64)
.expect("decode pub key");
let public_key_hex = hex_encode(&pub_key_bytes);

let timestamp: i64 = 1_700_000_000;
let sig_b64 = sign_cli_register_payload(&private_key_b64, &public_key_hex, timestamp)
.expect("sign payload");
let signature = KeyUtils::signature_from_base64(&sig_b64).expect("sig from b64");
let payload = format!("{}:{}", public_key_hex, timestamp);
assert!(
key_pair.verify(payload.as_bytes(), &signature),
"signature should verify against canonical payload"
);
}

#[test]
fn sign_cli_register_payload_rejects_invalid_private_key() {
assert!(sign_cli_register_payload("not-valid-base64!!!", "deadbeef", 123).is_err());
}

#[test]
fn derive_recovery_phrase_returns_24_words() {
let key_pair = Ed25519KeyPair::generate().expect("generate keypair");
let words = derive_recovery_phrase(&key_pair.secret_key_base64()).expect("derive");
assert_eq!(words.len(), 24, "BIP39 256-bit entropy yields 24 words");
}

#[test]
fn derive_recovery_phrase_rejects_short_key() {
let short_b64 = base64::engine::general_purpose::STANDARD.encode([0u8; 16]);
assert!(derive_recovery_phrase(&short_b64).is_err());
}
}
22 changes: 17 additions & 5 deletions src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ pub fn open(pool: Arc<SledPool>) -> Result<IdentityStore, String> {
/// encrypted (`ENC:` prefixed) blob. Used by [`open`] to decide whether
/// silent master-key creation is safe.
fn peek_encrypted_identity(pool: &Arc<SledPool>) -> Result<bool, String> {
Ok(peek_raw_identity_value(pool)?
.map(|s| s.starts_with(ENCRYPTED_PREFIX))
.unwrap_or(false))
}

/// Read the raw stored UTF-8 value of `KEY_PRIVATE` without decrypting.
///
/// Used by the boot-time legacy-install migration to detect plaintext
/// identities written by older CLI installs and re-encrypt them under
/// the keychain master key. Returns `None` if no identity is persisted.
pub fn peek_raw_identity_value(pool: &Arc<SledPool>) -> Result<Option<String>, String> {
let guard = pool
.acquire_arc()
.map_err(|e| format!("Failed to acquire Sled pool: {e}"))?;
Expand All @@ -111,11 +122,12 @@ fn peek_encrypted_identity(pool: &Arc<SledPool>) -> Result<bool, String> {
let stored = tree
.get(KEY_PRIVATE)
.map_err(|e| format!("Failed to read identity tree: {e}"))?;
Ok(stored
.as_ref()
.and_then(|v| std::str::from_utf8(v).ok())
.map(|s| s.starts_with(ENCRYPTED_PREFIX))
.unwrap_or(false))
let Some(bytes) = stored else {
return Ok(None);
};
let s = std::str::from_utf8(&bytes)
.map_err(|e| format!("Identity private key is not valid UTF-8: {e}"))?;
Ok(Some(s.to_string()))
}

/// Resolve the master key used to encrypt/decrypt the identity tree.
Expand Down
10 changes: 9 additions & 1 deletion src/server/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use super::routes::{
admin as admin_routes, auth as auth_routes, config as config_routes,
discovery as discovery_routes, feed as feed_routes, filesystem as filesystem_routes,
query as query_routes, schema as schema_routes, security as security_routes,
system as system_routes,
setup as setup_routes, system as system_routes,
};
use super::static_assets::Asset;
use crate::fold_node::llm_query;
Expand Down Expand Up @@ -196,6 +196,7 @@ impl FoldHttpServer {
.configure(Self::configure_feed_routes)
.configure(Self::configure_remote_routes)
.configure(Self::configure_auth_routes)
.configure(Self::configure_setup_routes)
.configure(Self::configure_sync_routes)
.configure(Self::configure_snapshot_routes)
.configure(Self::configure_org_routes)
Expand Down Expand Up @@ -988,6 +989,13 @@ impl FoldHttpServer {
);
}

/// Register the canonical first-launch endpoint. The handler self-disables
/// once `.onboarding_complete` exists, so registration is unconditional —
/// keeps the route table identical between fresh and provisioned nodes.
fn configure_setup_routes(cfg: &mut web::ServiceConfig) {
cfg.route("/setup/bootstrap", web::post().to(setup_routes::bootstrap));
}

fn configure_auth_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/auth")
Expand Down
1 change: 1 addition & 0 deletions src/server/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod query;
pub mod remote;
pub mod schema;
pub mod security;
pub mod setup;
pub mod sharing;
pub mod smart_folder;
pub mod snapshot;
Expand Down
Loading
Loading