Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 15 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,21 @@ under the account root. A configured handle is only a local candidate. No
global uniqueness, registry freshness, recovery, revocation, or
key-transparency claim exists until their complete verification paths ship.

IPC is versioned, length bounded, and carried over a 0600 Unix socket. The
systemd user service restricts writable paths and privileges, but Bluetooth,
network, Unix-socket, and user-session D-Bus access remain necessary.
IPC is versioned, length bounded, and carried over a Unix socket created 0600
under a restrictive umask. The daemon checks SO_PEERCRED on every accepted
connection and drops any peer whose uid differs from its own effective uid,
root included. Destructive commands (panic erase, registry handle claims)
additionally require a single-use confirmation token with a 120-second
lifetime, minted on request into a 0600 file inside the sealed state
directory and echoed back in a second step. These are deliberate-two-step
and freshness guarantees, not an authorization boundary: same-uid isolation
is not a boundary this daemon can enforce alone. A process running as the
same user can ultimately ptrace the daemon, read its memory, or read the
state directory that holds the confirmation tokens. Protecting the account
from hostile same-user code — including coding agents — requires OS-level
sandboxing of that code, not daemon-side checks. The systemd user service
restricts writable paths and privileges, but Bluetooth, network,
Unix-socket, and user-session D-Bus access remain necessary.

## Metadata and network limits

Expand Down
3 changes: 3 additions & 0 deletions crates/omachat-ctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }

[dev-dependencies]
tempfile = "=3.27.0"

[lints]
workspace = true
91 changes: 90 additions & 1 deletion crates/omachat-ctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,82 @@ impl Client {
}
}

/// Restated at every panic invocation: erasure is local-only.
pub const PANIC_ERASE_WARNING: &str = "panic erase destroys the local master key and sealed \
state; it cannot retract messages, keys, or metadata already replicated to relays, peers, or \
backups";

/// Two-phase orchestration for destructive commands. The typed intent
/// (`ERASE`, or the handle echoed to `--confirm`) is checked locally; the
/// daemon-minted single-use token is then fetched out of band from the
/// daemon state directory and echoed back. Non-destructive commands pass
/// straight through.
pub async fn request_with_confirmation(
client: &mut Client,
command: Command,
) -> Result<Response, ClientError> {
match command {
Command::Panic { confirmation } => {
if confirmation != "ERASE" {
return Err(ClientError::ConfirmationRefused(
"panic requires --confirm ERASE".into(),
));
}
let issued = client.request(Command::RequestPanicConfirmation).await?;
let ResponseOutcome::Ok { ref result } = issued.outcome else {
return Ok(issued);
};
let token = read_confirmation_token(result)?;
client
.request(Command::Panic {
confirmation: token,
})
.await
}
Command::ClaimRegistryHandle {
handle,
confirmation,
} => {
if confirmation != handle {
return Err(ClientError::ConfirmationRefused(
"claim-handle requires --confirm HANDLE to echo the handle exactly".into(),
));
}
let issued = client
.request(Command::RequestRegistryClaimConfirmation {
handle: handle.clone(),
})
.await?;
let ResponseOutcome::Ok { ref result } = issued.outcome else {
return Ok(issued);
};
let token = read_confirmation_token(result)?;
client
.request(Command::ClaimRegistryHandle {
handle,
confirmation: token,
})
.await
}
other => client.request(other).await,
}
}

fn read_confirmation_token(result: &serde_json::Value) -> Result<String, ClientError> {
let path = result
.get("token_path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
ClientError::ConfirmationProtocol("daemon response lacked token_path".into())
})?;
// Token files are tens of bytes; a blocking read keeps the client free
// of a tokio fs feature dependency.
let token = std::fs::read_to_string(path).map_err(|error| {
ClientError::ConfirmationProtocol(format!("token file unreadable: {error}"))
})?;
Ok(token.trim().to_owned())
}

async fn read_line<T: DeserializeOwned>(stream: &mut UnixStream) -> Result<T, ClientError> {
let mut line = Vec::new();
let mut byte = [0_u8; 1];
Expand Down Expand Up @@ -103,7 +179,16 @@ pub enum ClientError {
MalformedResponse,
VersionMismatch(u16),
CorrelationMismatch,
Remote { code: String, message: String },
Remote {
code: String,
message: String,
},
/// The locally typed intent (`--confirm` value) did not match; nothing
/// was sent to the daemon.
ConfirmationRefused(String),
/// The daemon's confirmation-token response was malformed or the token
/// file could not be read.
ConfirmationProtocol(String),
}

impl fmt::Display for ClientError {
Expand All @@ -120,6 +205,10 @@ impl fmt::Display for ClientError {
}
Self::CorrelationMismatch => formatter.write_str("daemon response ID does not match"),
Self::Remote { code, message } => write!(formatter, "daemon error {code}: {message}"),
Self::ConfirmationRefused(message) => write!(formatter, "refused: {message}"),
Self::ConfirmationProtocol(message) => {
write!(formatter, "confirmation protocol failed: {message}")
}
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion crates/omachat-ctl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ async fn run(mut arguments: Vec<std::ffi::OsString>) -> Result<(), CliError> {
let mut client = Client::connect(socket, DEFAULT_TIMEOUT)
.await
.map_err(CliError::Client)?;
let response = client.request(command).await.map_err(CliError::Client)?;
if matches!(command, Command::Panic { .. }) {
eprintln!("{}", omachat_ctl::PANIC_ERASE_WARNING);
}
let response = omachat_ctl::request_with_confirmation(&mut client, command)
.await
.map_err(CliError::Client)?;
match response.outcome {
ResponseOutcome::Ok { result } => {
if output_mode == OutputMode::Json {
Expand Down
120 changes: 120 additions & 0 deletions crates/omachat-ctl/tests/confirmation_flow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Two-phase destructive-command orchestration against a scripted stub daemon.

use omachat_ctl::{Client, ClientError, DEFAULT_TIMEOUT, request_with_confirmation};
use omachat_proto::ipc::{
Command, RequestDecoder, Response, ResponseOutcome, VERSION, encode_line, negotiate,
};
use serde_json::json;
use std::path::Path;
use tempfile::tempdir;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener,
};

/// Serves one client: hello, then request-panic-confirmation (mints a token
/// file), then panic (accepted only with the minted token).
async fn stub_daemon(listener: UnixListener, token_directory: std::path::PathBuf) {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut decoder = RequestDecoder::default();
let mut buffer = [0_u8; 4096];
let mut minted: Option<String> = None;
loop {
let count = match stream.read(&mut buffer).await {
Ok(0) | Err(_) => return,
Ok(count) => count,
};
for request in decoder.push(&buffer[..count]).expect("decode request") {
let outcome = match &request.command {
Command::Hello {
minimum_version,
maximum_version,
} => ResponseOutcome::Ok {
result: serde_json::to_value(
negotiate(*minimum_version, *maximum_version).expect("negotiate"),
)
.expect("hello result"),
},
Command::RequestPanicConfirmation => {
let token = "a".repeat(64);
let token_path = token_directory.join("panic.token");
std::fs::write(&token_path, &token).expect("write token file");
minted = Some(token);
ResponseOutcome::Ok {
result: json!({
"token_path": token_path.display().to_string(),
"expires_at": 1_u64,
"ttl_seconds": 120_u64,
}),
}
}
Command::Panic { confirmation } => {
assert_eq!(
Some(confirmation.as_str()),
minted.as_deref(),
"client must echo the minted token, not the typed intent"
);
ResponseOutcome::Ok {
result: json!({"panic": "erased"}),
}
}
command => panic!("unexpected command: {command:?}"),
};
let response = Response {
version: VERSION,
id: request.id,
outcome,
};
stream
.write_all(&encode_line(&response).expect("encode response"))
.await
.expect("write response");
}
}
}

async fn connect(socket: &Path) -> Client {
Client::connect(socket, DEFAULT_TIMEOUT)
.await
.expect("connect client")
}

#[tokio::test]
async fn panic_orchestrates_token_request_and_commit() {
let temporary = tempdir().expect("temporary directory");
let socket = temporary.path().join("stub.sock");
let listener = UnixListener::bind(&socket).expect("bind stub");
let server = tokio::spawn(stub_daemon(listener, temporary.path().to_owned()));
let mut client = connect(&socket).await;
let response = request_with_confirmation(
&mut client,
Command::Panic {
confirmation: "ERASE".into(),
},
)
.await
.expect("two-phase panic");
assert!(matches!(response.outcome, ResponseOutcome::Ok { .. }));
drop(client);
server.await.expect("stub daemon");
}

#[tokio::test]
async fn mistyped_intent_is_refused_before_any_daemon_interaction() {
let temporary = tempdir().expect("temporary directory");
let socket = temporary.path().join("stub.sock");
let listener = UnixListener::bind(&socket).expect("bind stub");
let server = tokio::spawn(stub_daemon(listener, temporary.path().to_owned()));
let mut client = connect(&socket).await;
let error = request_with_confirmation(
&mut client,
Command::Panic {
confirmation: "erase".into(),
},
)
.await
.expect_err("typed intent must be exact");
assert!(matches!(error, ClientError::ConfirmationRefused(_)));
drop(client);
server.await.expect("stub daemon");
}
37 changes: 36 additions & 1 deletion crates/omachat-proto/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{error::Error, fmt};

pub const VERSION: u16 = 1;
/// Version 2 changed `Panic` and `ClaimRegistryHandle` incompatibly: their
/// `confirmation` field is now a daemon-minted single-use token obtained
/// through `RequestPanicConfirmation` / `RequestRegistryClaimConfirmation`,
/// not an in-band constant. A version-1 client's destructive flow no longer
/// works, so negotiation must reject it rather than fail at use time.
pub const VERSION: u16 = 2;
pub const MAX_LINE_BYTES: usize = 64 * 1024;
pub const MAX_CORRELATION_ID_BYTES: usize = 128;

Expand Down Expand Up @@ -61,6 +66,15 @@ pub enum Command {
handle: String,
confirmation: String,
},
/// Mint a single-use, TTL-bounded confirmation token for `Panic`. The
/// token itself travels out of band via a 0600 file in the daemon state
/// directory; the response carries only the file path and expiry.
RequestPanicConfirmation,
/// Mint a single-use, TTL-bounded confirmation token for
/// `ClaimRegistryHandle` on exactly this handle.
RequestRegistryClaimConfirmation {
handle: String,
},
Who {
geohash: String,
},
Expand Down Expand Up @@ -207,6 +221,15 @@ enum StrictRequestWire {
id: String,
params: RegistryClaimParams,
},
RequestPanicConfirmation {
version: u16,
id: String,
},
RequestRegistryClaimConfirmation {
version: u16,
id: String,
params: HandleParams,
},
Who {
version: u16,
id: String,
Expand Down Expand Up @@ -408,6 +431,18 @@ impl From<StrictRequestWire> for Request {
confirmation,
},
),
StrictRequestWire::RequestPanicConfirmation { version, id } => {
(version, id, Command::RequestPanicConfirmation)
}
StrictRequestWire::RequestRegistryClaimConfirmation {
version,
id,
params: HandleParams { handle },
} => (
version,
id,
Command::RequestRegistryClaimConfirmation { handle },
),
StrictRequestWire::Who {
version,
id,
Expand Down
Loading
Loading