feat(coven): SRP/Fernet crypto parity + multi-user coven foundation ⛧
Begin the coven evolution of cmd-chat (see docs/spec-collaborative-sandbox.md): a Rust/ratatui client for the unchanged Python Sanic server, plus the multi-user + zero-knowledge groundwork. P0 — crypto parity (the spec's #1 risk), proven three ways: - Hand-rolled SRP-6a (NG_2048, SHA-256, rfc5054 padding) matching pysrp byte-for-byte, incl. the fixed b"chat" SRP identity and minimal-vs-256B width quirks. Golden-vector unit test + offline selftest. - Live handshake against the running server (H_AMK verified). - Cross-language E2E: Python client decrypts a Rust-encrypted Fernet message. P2 — multi-user coven (server): - CMD_CHAT_MAX_USERS capacity cap (default 4, infra-for-more). - Authoritative roster + user_joined broadcasts. - Free the slot/username on ws disconnect (was held until 1h stale sweep). Also: fix requirements.txt (was UTF-16, unparseable by pip). coven/ : Rust crate (crypto.rs proven; main.rs spike CLI: selftest/handshake/srpm) docs/ : full feature spec for the 6 requested features. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dc1b5e5ccf
commit
82a04f3e12
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,4 +14,5 @@ build
|
|||
dist
|
||||
true.txt
|
||||
secured_console_chat.egg-info
|
||||
.pytest_cache/
|
||||
.pytest_cache//.venv/
|
||||
/downloads/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ def create_app(password: str = "", name: str = "cmd-chat-server") -> Sanic:
|
|||
app.ctx.ws_secret = os.urandom(32)
|
||||
app.ctx.admin_token = secrets.token_hex(16)
|
||||
app.ctx.rate_limiter = RateLimiter(max_requests=10, window_seconds=60)
|
||||
# Coven capacity. 4 by default; raise via CMD_CHAT_MAX_USERS — infra-for-more,
|
||||
# the cap is data not architecture (broadcast fan-out is O(N)).
|
||||
app.ctx.max_users = int(os.environ.get("CMD_CHAT_MAX_USERS", "4"))
|
||||
app.ctx.cleanup_task = None
|
||||
|
||||
register_lifecycle(app)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,18 @@ def generate_ws_token(user_id: str, secret: bytes) -> str:
|
|||
return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _roster_frame(app: Sanic) -> str:
|
||||
"""Authoritative presence snapshot — all coven members converge on this."""
|
||||
users = app.ctx.session_store.get_all()
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "roster",
|
||||
"users": [{"user_id": u.user_id, "username": u.username} for u in users],
|
||||
"capacity": app.ctx.max_users,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
|
||||
try:
|
||||
client_ip = get_client_ip(request)
|
||||
|
|
@ -33,6 +45,9 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
|
|||
if app.ctx.session_store.username_exists(username):
|
||||
return response.json({"error": "Username taken"}, status=409)
|
||||
|
||||
if app.ctx.session_store.count() >= app.ctx.max_users:
|
||||
return response.json({"error": "Coven full"}, status=409)
|
||||
|
||||
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
|
||||
|
||||
return response.json(
|
||||
|
|
@ -64,6 +79,11 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse:
|
|||
|
||||
client_proof = base64.b64decode(client_proof_b64)
|
||||
|
||||
# Authoritative capacity gate — the slot is only consumed once a session
|
||||
# is actually added here (init is best-effort / racy).
|
||||
if app.ctx.session_store.count() >= app.ctx.max_users:
|
||||
return response.json({"error": "Coven full"}, status=409)
|
||||
|
||||
H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof)
|
||||
|
||||
fernet_key = base64.urlsafe_b64encode(session_key[:32])
|
||||
|
|
@ -115,6 +135,19 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
|
|||
try:
|
||||
await send_state(ws, app)
|
||||
|
||||
# Announce arrival to everyone already present, then a fresh roster.
|
||||
await manager.broadcast(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "user_joined",
|
||||
"user_id": user_id,
|
||||
"username": session.username,
|
||||
}
|
||||
),
|
||||
exclude_user=user_id,
|
||||
)
|
||||
await manager.broadcast(_roster_frame(app))
|
||||
|
||||
async for data in ws:
|
||||
if data is None:
|
||||
break
|
||||
|
|
@ -140,6 +173,9 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
|
|||
pass
|
||||
finally:
|
||||
await manager.disconnect(user_id)
|
||||
# Free the slot + username so the coven can be rejoined (was previously
|
||||
# held until the 1h stale sweep, which also blocked the name).
|
||||
app.ctx.session_store.remove(user_id)
|
||||
await manager.broadcast(
|
||||
json.dumps(
|
||||
{
|
||||
|
|
@ -148,6 +184,7 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
|
|||
}
|
||||
)
|
||||
)
|
||||
await manager.broadcast(_roster_frame(app))
|
||||
|
||||
|
||||
async def health(request: Request, app: Sanic) -> HTTPResponse:
|
||||
|
|
|
|||
1902
coven/Cargo.lock
generated
Normal file
1902
coven/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
coven/Cargo.toml
Normal file
35
coven/Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
[package]
|
||||
name = "coven"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "coven — encrypted collaborative covens with a summoned sandbox familiar. ⛧"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "coven"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# crypto
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
fernet = "0.2"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
|
||||
# net
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
rustls = "0.23"
|
||||
url = "2"
|
||||
|
||||
# data
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# cli / errors
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
57
coven/README.md
Normal file
57
coven/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<div align="center">
|
||||
|
||||
# ⛧ coven ⛧
|
||||
|
||||
### encrypted collaborative covens with a summoned sandbox familiar
|
||||
|
||||
`zero-knowledge server · end-to-end fernet · srp · ratatui`
|
||||
|
||||
*they want you dependent. we want you free.*
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**coven** is the evolution of `cmd-chat`: a multi-user, end-to-end-encrypted
|
||||
terminal session where a small circle (a *coven*) shares chat, files, and — when
|
||||
summoned — a disposable sandboxed Linux **familiar** they drive together, with
|
||||
real Linux permissions and a high priest who can delegate the keys.
|
||||
|
||||
The server never sees plaintext. Everything — messages, files, terminal output —
|
||||
is relayed as opaque ciphertext. Close the window, the coven dissolves.
|
||||
|
||||
## status
|
||||
|
||||
This is the Rust client (`ratatui`) for the unchanged Python (Sanic) server. The
|
||||
wire protocol is JSON-over-WebSocket; SRP + HKDF→Fernet are byte-for-byte
|
||||
compatible with the Python `srp` / `cryptography` stack.
|
||||
|
||||
| phase | feature | state |
|
||||
|---|---|---|
|
||||
| **P0** | Rust↔Python SRP / Fernet crypto parity | ✅ proven (golden vectors + live + cross-lang E2E) |
|
||||
| **P2** | multi-user coven (cap 4, infra for more) + authoritative roster | ✅ server-side done |
|
||||
| **P1** | ratatui coven UI (chat, roster, themes) | 🚧 in progress |
|
||||
| **P3** | sandbox familiar (multipass/docker) + shared PTY | ⏳ designed (see `../docs/spec-collaborative-sandbox.md`) |
|
||||
| **P4** | permissions (app RBAC + VM unix users / sudo) | ⏳ designed |
|
||||
| **P5** | file + directory offerings into the shared coven | ⏳ designed |
|
||||
|
||||
## crypto parity — the load-bearing proof
|
||||
|
||||
```
|
||||
$ coven selftest # offline: Rust SRP ≡ Python srp golden vectors
|
||||
$ coven handshake <ip> <port> <name> --password <pw> --no-tls
|
||||
⛧ /srp/verify ok — server identity proven (H_AMK ✓)
|
||||
⛧ round-trip ✓ decrypted: "the coven is summoned ⛧"
|
||||
```
|
||||
|
||||
`tools/gen_vectors.py` regenerates the golden vectors from the live Python
|
||||
library (must match the server's `_ctsrp` backend with `rfc5054_enable()`).
|
||||
|
||||
> **note:** the SRP identity is always the fixed room identity `b"chat"`; the
|
||||
> display name is carried only in JSON, never in the SRP proof. The Python `srp`
|
||||
> package's `rfc5054_enable()` toggles the *active backend's* flag — vectors must
|
||||
> be generated with the same backend the server actually loads (`_ctsrp`).
|
||||
|
||||
## license
|
||||
|
||||
MIT · *malware bless · hack the planet*
|
||||
237
coven/src/crypto.rs
Normal file
237
coven/src/crypto.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! SRP-6a + room-key crypto, byte-for-byte compatible with the Python
|
||||
//! `srp` library (NG_2048, SHA-256, `rfc5054_enable()`) and `cryptography`
|
||||
//! HKDF→Fernet as used by the Sanic server / reference client.
|
||||
//!
|
||||
//! Reference (pysrp `_pysrp.py`):
|
||||
//! x = SHA256( salt || SHA256(I || ":" || P) )
|
||||
//! k = SHA256( PAD(N) || PAD(g) ) (rfc5054: PAD to len(N))
|
||||
//! A = g^a mod N
|
||||
//! u = SHA256( PAD(A) || PAD(B) )
|
||||
//! S = (B - k*g^x)^(a + u*x) mod N
|
||||
//! K = SHA256( S )
|
||||
//! M = SHA256( (H(N) xor H(PAD(g))) || SHA256(I) || salt || A || B || K )
|
||||
//! HAMK= SHA256( A || M || K )
|
||||
//! Note: A and B inside M / HAMK use *minimal* big-endian bytes (no padding);
|
||||
//! only k and u pad to len(N) (= 256 bytes for NG_2048).
|
||||
|
||||
use num_bigint::BigUint;
|
||||
use num_traits::Zero;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// RFC 5054 / pysrp NG_2048 safe prime.
|
||||
const N_HEX: &str = "\
|
||||
AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050A37329CBB4\
|
||||
A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\
|
||||
95179A163AB3661A05FBD5FAAAE82918A9962F0B93B855F97993EC975EEAA80D740ADBF4FF\
|
||||
747359D041D5C33EA71D281E446B14773BCA97B43A23FB801676BD207A436C6481F1D2B907\
|
||||
8717461A5B9D32E688F87748544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB37861\
|
||||
60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\
|
||||
FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73";
|
||||
|
||||
/// The SRP identity used by every cmd-chat / coven room (server hardcodes this).
|
||||
/// The user's chosen display name is independent of this value.
|
||||
pub const SRP_IDENTITY: &[u8] = b"chat";
|
||||
|
||||
fn n() -> BigUint {
|
||||
BigUint::parse_bytes(N_HEX.as_bytes(), 16).expect("valid N")
|
||||
}
|
||||
fn g() -> BigUint {
|
||||
BigUint::from(2u32)
|
||||
}
|
||||
|
||||
fn sha256(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha256::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Left-pad `b` with zero bytes to exactly `width` bytes.
|
||||
fn pad(b: &[u8], width: usize) -> Vec<u8> {
|
||||
if b.len() >= width {
|
||||
return b.to_vec();
|
||||
}
|
||||
let mut out = vec![0u8; width - b.len()];
|
||||
out.extend_from_slice(b);
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes_to_long(b: &[u8]) -> BigUint {
|
||||
BigUint::from_bytes_be(b)
|
||||
}
|
||||
|
||||
/// pysrp `long_to_bytes`: minimal big-endian, empty for zero.
|
||||
fn long_to_bytes(x: &BigUint) -> Vec<u8> {
|
||||
if x.is_zero() {
|
||||
return Vec::new();
|
||||
}
|
||||
x.to_bytes_be()
|
||||
}
|
||||
|
||||
/// Multiplier k = SHA256(PAD(N) || PAD(g)), padded to len(N).
|
||||
fn compute_k(n: &BigUint) -> BigUint {
|
||||
let width = long_to_bytes(n).len();
|
||||
let nb = pad(&long_to_bytes(n), width);
|
||||
let gb = pad(&long_to_bytes(&g()), width);
|
||||
bytes_to_long(&sha256(&[&nb, &gb]))
|
||||
}
|
||||
|
||||
/// x = SHA256(salt || SHA256(I || ":" || P)).
|
||||
fn gen_x(salt: &[u8], username: &[u8], password: &[u8]) -> BigUint {
|
||||
let inner = sha256(&[username, b":", password]);
|
||||
bytes_to_long(&sha256(&[salt, &inner]))
|
||||
}
|
||||
|
||||
/// (H(N) xor H(PAD(g))) used inside M.
|
||||
fn hn_xor_g(n: &BigUint) -> Vec<u8> {
|
||||
let width = long_to_bytes(n).len();
|
||||
let h_n = sha256(&[&long_to_bytes(n)]);
|
||||
let h_g = sha256(&[&pad(&long_to_bytes(&g()), width)]);
|
||||
h_n.iter().zip(h_g.iter()).map(|(a, b)| a ^ b).collect()
|
||||
}
|
||||
|
||||
/// Client-side SRP-6a state.
|
||||
pub struct SrpClient {
|
||||
username: Vec<u8>,
|
||||
password: Vec<u8>,
|
||||
n: BigUint,
|
||||
k: BigUint,
|
||||
a: BigUint,
|
||||
pub a_pub: BigUint, // A
|
||||
}
|
||||
|
||||
impl SrpClient {
|
||||
/// New client with a random 256-byte ephemeral `a` (high bit set, per pysrp).
|
||||
pub fn new(username: &[u8], password: &[u8]) -> Self {
|
||||
let mut buf = [0u8; 256];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut buf);
|
||||
buf[0] |= 0x80;
|
||||
Self::with_a(username, password, &buf)
|
||||
}
|
||||
|
||||
/// Deterministic constructor for test vectors.
|
||||
pub fn with_a(username: &[u8], password: &[u8], a_bytes: &[u8]) -> Self {
|
||||
let n = n();
|
||||
let k = compute_k(&n);
|
||||
let a = bytes_to_long(a_bytes);
|
||||
let a_pub = g().modpow(&a, &n);
|
||||
Self {
|
||||
username: username.to_vec(),
|
||||
password: password.to_vec(),
|
||||
n,
|
||||
k,
|
||||
a,
|
||||
a_pub,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire bytes for A (minimal big-endian).
|
||||
pub fn a_bytes(&self) -> Vec<u8> {
|
||||
long_to_bytes(&self.a_pub)
|
||||
}
|
||||
|
||||
/// Process the server challenge (salt, B). Returns (M, K, H_AMK_expected).
|
||||
/// `M` is sent to the server; `h_amk` is compared to the server's reply.
|
||||
pub fn process_challenge(
|
||||
&self,
|
||||
salt: &[u8],
|
||||
b_bytes: &[u8],
|
||||
) -> anyhow::Result<Challenge> {
|
||||
let n = &self.n;
|
||||
let width = long_to_bytes(n).len();
|
||||
let big_b = bytes_to_long(b_bytes);
|
||||
if (&big_b % n).is_zero() {
|
||||
anyhow::bail!("SRP safety check failed: B mod N == 0");
|
||||
}
|
||||
|
||||
let a_min = long_to_bytes(&self.a_pub);
|
||||
let b_min = long_to_bytes(&big_b);
|
||||
let u = bytes_to_long(&sha256(&[&pad(&a_min, width), &pad(&b_min, width)]));
|
||||
if u.is_zero() {
|
||||
anyhow::bail!("SRP safety check failed: u == 0");
|
||||
}
|
||||
|
||||
let x = gen_x(salt, &self.username, &self.password);
|
||||
let v = g().modpow(&x, n);
|
||||
|
||||
// base = (B - k*v) mod N, kept non-negative.
|
||||
let kv = (&self.k * &v) % n;
|
||||
let base = ((&big_b % n) + n - kv) % n;
|
||||
let exp = &self.a + &u * &x;
|
||||
let s = base.modpow(&exp, n);
|
||||
|
||||
let k_key = sha256(&[&long_to_bytes(&s)]);
|
||||
|
||||
let m = sha256(&[
|
||||
&hn_xor_g(n),
|
||||
&sha256(&[&self.username]),
|
||||
salt,
|
||||
&a_min,
|
||||
&b_min,
|
||||
&k_key,
|
||||
]);
|
||||
let h_amk = sha256(&[&a_min, &m, &k_key]);
|
||||
|
||||
Ok(Challenge {
|
||||
m,
|
||||
session_key: k_key,
|
||||
h_amk,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Challenge {
|
||||
pub m: Vec<u8>,
|
||||
pub session_key: Vec<u8>,
|
||||
pub h_amk: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── Room key: HKDF-SHA256(password, salt=room_salt, info) → Fernet ──────────
|
||||
|
||||
/// Derive the shared room Fernet key exactly as the reference client:
|
||||
/// `Fernet(urlsafe_b64( HKDF(SHA256, 32, room_salt, "cmd-chat-room-key")(pw) ))`.
|
||||
pub fn room_fernet(password: &[u8], room_salt: &[u8]) -> anyhow::Result<fernet::Fernet> {
|
||||
use base64::Engine;
|
||||
let hk = hkdf::Hkdf::<Sha256>::new(Some(room_salt), password);
|
||||
let mut okm = [0u8; 32];
|
||||
hk.expand(b"cmd-chat-room-key", &mut okm)
|
||||
.map_err(|_| anyhow::anyhow!("hkdf expand failed"))?;
|
||||
let key_b64 = base64::engine::general_purpose::URL_SAFE.encode(okm);
|
||||
fernet::Fernet::new(&key_b64).ok_or_else(|| anyhow::anyhow!("invalid fernet key"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Golden vectors generated from the live Python `srp` (_ctsrp backend),
|
||||
// rfc5054 enabled, NG_2048, SHA-256. See tools/gen_vectors.py.
|
||||
const PW: &[u8] = b"labtest";
|
||||
const USER: &[u8] = b"chat";
|
||||
const SALT_HEX: &str = "0a1b2c3d";
|
||||
const A_HEX: &str = "8613d4e3da583215e770e4de20622d664374d237a96aabdebe1e38ae34b2d0bc45da3251d9f76337f918bbfa49a52aaf4a6d5f141aadc82f73f7559a3c0859c733d4cb258e9fdd797a3c1be8f71a0f5db0a9d15e19b5af82c408513d512c1824c3f61f3099b93bc9cf8c8bcdbd8f87ec6a347bb81bf5027a30b9ce6eb6beb110efc734164f65d4fc08ff7da2ef19732f559c07197c5a166b52c27a9806f9776b6b88c79739f6a1e024b2d3856f4fc7e69b39548f02a599e178fcb9b6a574a13964ab0331a40b839810e27d5a9bd71f9bacdf1ed26bdc4baaaa0088ecfa1d2daae7f47b6d67e5480d57e97770bbb623177f92080b0e963097fa72ef9f6ded07f0";
|
||||
const B_HEX: &str = "047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2";
|
||||
const M_HEX: &str = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd";
|
||||
const HAMK_HEX: &str = "649a7d46bb9210483e0489b7f9e6fb300a6cddd6381b018fa81770076169a837";
|
||||
const K_HEX: &str = "a12218af3fda651aa3c094a4db474a5eee919496c3ae8d38a4f6be1104ed4928";
|
||||
|
||||
fn a_bytes() -> Vec<u8> {
|
||||
let mut v = vec![0x80u8];
|
||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srp_matches_pysrp_vectors() {
|
||||
let c = SrpClient::with_a(USER, PW, &a_bytes());
|
||||
assert_eq!(hex::encode(c.a_bytes()), A_HEX, "A mismatch");
|
||||
|
||||
let salt = hex::decode(SALT_HEX).unwrap();
|
||||
let b = hex::decode(B_HEX).unwrap();
|
||||
let ch = c.process_challenge(&salt, &b).unwrap();
|
||||
assert_eq!(hex::encode(&ch.session_key), K_HEX, "K mismatch");
|
||||
assert_eq!(hex::encode(&ch.m), M_HEX, "M mismatch");
|
||||
assert_eq!(hex::encode(&ch.h_amk), HAMK_HEX, "H_AMK mismatch");
|
||||
}
|
||||
}
|
||||
212
coven/src/main.rs
Normal file
212
coven/src/main.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! coven ⛧ — encrypted collaborative covens with a summoned sandbox familiar.
|
||||
//!
|
||||
//! This binary currently exposes the crypto-parity spike used to prove the
|
||||
//! Rust client speaks the same SRP / Fernet dialect as the Python Sanic server.
|
||||
//! The ratatui coven UI is built on top of this proven foundation.
|
||||
|
||||
mod crypto;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::json;
|
||||
|
||||
const STD: base64::engine::general_purpose::GeneralPurpose =
|
||||
base64::engine::general_purpose::STANDARD;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "coven", about = "⛧ encrypted collaborative covens ⛧")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the offline SRP golden-vector self-test.
|
||||
Selftest,
|
||||
/// Debug: compute A and M from explicit a/salt/B hex (parity check vs python).
|
||||
Srpm {
|
||||
a_hex: String,
|
||||
salt_hex: String,
|
||||
b_hex: String,
|
||||
#[arg(long, default_value = "labtest")]
|
||||
password: String,
|
||||
#[arg(long, default_value = "chat")]
|
||||
user: String,
|
||||
},
|
||||
/// Perform a live SRP handshake + send one encrypted message (interop proof).
|
||||
Handshake {
|
||||
ip: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
#[arg(long)]
|
||||
password: String,
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_tls: bool,
|
||||
#[arg(long, default_value_t = false)]
|
||||
insecure: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
match Cli::parse().cmd {
|
||||
Cmd::Selftest => selftest(),
|
||||
Cmd::Srpm {
|
||||
a_hex,
|
||||
salt_hex,
|
||||
b_hex,
|
||||
password,
|
||||
user,
|
||||
} => {
|
||||
let a = hex::decode(a_hex)?;
|
||||
let salt = hex::decode(salt_hex)?;
|
||||
let b = hex::decode(b_hex)?;
|
||||
let c = crypto::SrpClient::with_a(user.as_bytes(), password.as_bytes(), &a);
|
||||
println!("A {}", hex::encode(c.a_bytes()));
|
||||
let ch = c.process_challenge(&salt, &b)?;
|
||||
println!("M {}", hex::encode(&ch.m));
|
||||
println!("K {}", hex::encode(&ch.session_key));
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Handshake {
|
||||
ip,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
no_tls,
|
||||
insecure,
|
||||
} => handshake(&ip, port, &user, &password, no_tls, insecure),
|
||||
}
|
||||
}
|
||||
|
||||
fn selftest() -> Result<()> {
|
||||
// Re-derive the golden vectors at runtime as a smoke check.
|
||||
let c = crypto::SrpClient::with_a(b"chat", b"labtest", &{
|
||||
let mut v = vec![0x80u8];
|
||||
v.extend(std::iter::repeat(0x22u8).take(31));
|
||||
v
|
||||
});
|
||||
let a = hex::encode(c.a_bytes());
|
||||
println!("⛧ A = {}…", &a[..32]);
|
||||
let salt = hex::decode("0a1b2c3d")?;
|
||||
let b = hex::decode("047426a55963c70bc385c6a51f6e9dc0bfe5e16b0d1fee4f566fb54b60fa77144f15ed1ee6ade007bd92f2b90846e1ee083ab4290239420606f48a1d861f759543d7856cbce21fd7fec98c9961a66610b412fea2efc5be78f35b18fd48176ac80c3a1cbefacac81e25e7da8079fac4012d01c47d85b783c2ea7340819bfe73d29cd0953d47c8fade77caa5459fb77d88fb918c073a77c495fa884859142a270cb0b1668de06131b150df4dbc931953a381710b7fdb98a953d6f77a4bba847c4c62c15cca8e514dc13f531427966a553c461aa4ab0caec9665612861fef03d48676e5f6551fc8ca4317f3118e0294c949bd2f5821e5900e7f695225dafa0ba2d2")?;
|
||||
let ch = c.process_challenge(&salt, &b)?;
|
||||
let want_m = "6e733ba88eb86c52e3be89207d2815a65b4dea8116f668af5de1b66ce1f047dd";
|
||||
assert_eq!(hex::encode(&ch.m), want_m, "M mismatch vs pysrp");
|
||||
println!("⛧ M matches pysrp golden vector ✓");
|
||||
println!("⛧ selftest passed — Rust SRP ≡ Python srp");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handshake(
|
||||
ip: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
password: &str,
|
||||
no_tls: bool,
|
||||
insecure: bool,
|
||||
) -> Result<()> {
|
||||
let scheme = if no_tls { "http" } else { "https" };
|
||||
let base = format!("{scheme}://{ip}:{port}");
|
||||
|
||||
let http = reqwest::blocking::Client::builder()
|
||||
.danger_accept_invalid_certs(insecure && !no_tls)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
// SRP identity is the fixed room identity b"chat" (see server srp_auth.py);
|
||||
// the display `user` name is carried only in the JSON, never in the SRP proof.
|
||||
let client = crypto::SrpClient::new(crypto::SRP_IDENTITY, password.as_bytes());
|
||||
|
||||
// /srp/init
|
||||
let init: serde_json::Value = http
|
||||
.post(format!("{base}/srp/init"))
|
||||
.json(&json!({ "username": user, "A": STD.encode(client.a_bytes()) }))
|
||||
.send()
|
||||
.context("srp/init request")?
|
||||
.error_for_status()
|
||||
.context("srp/init status")?
|
||||
.json()?;
|
||||
|
||||
let user_id = init["user_id"].as_str().context("no user_id")?.to_string();
|
||||
let b = STD.decode(init["B"].as_str().context("no B")?)?;
|
||||
let salt = STD.decode(init["salt"].as_str().context("no salt")?)?;
|
||||
let room_salt = STD.decode(init["room_salt"].as_str().context("no room_salt")?)?;
|
||||
println!("⛧ /srp/init ok — user_id={}…", &user_id[..8]);
|
||||
|
||||
let ch = client.process_challenge(&salt, &b)?;
|
||||
|
||||
// /srp/verify
|
||||
let verify: serde_json::Value = http
|
||||
.post(format!("{base}/srp/verify"))
|
||||
.json(&json!({
|
||||
"user_id": user_id,
|
||||
"username": user,
|
||||
"M": STD.encode(&ch.m),
|
||||
}))
|
||||
.send()
|
||||
.context("srp/verify request")?
|
||||
.error_for_status()
|
||||
.context("srp/verify status — auth rejected?")?
|
||||
.json()?;
|
||||
|
||||
let server_hamk = STD.decode(verify["H_AMK"].as_str().context("no H_AMK")?)?;
|
||||
anyhow::ensure!(server_hamk == ch.h_amk, "server H_AMK mismatch — MITM?");
|
||||
let ws_token = verify["ws_token"].as_str().context("no ws_token")?.to_string();
|
||||
println!("⛧ /srp/verify ok — server identity proven (H_AMK ✓)");
|
||||
|
||||
// Room key + encrypt a message the Python clients can read.
|
||||
let fernet = crypto::room_fernet(password.as_bytes(), &room_salt)?;
|
||||
let ct = fernet.encrypt(b"the coven is summoned \xE2\x9B\xA7");
|
||||
|
||||
// Connect WS and send the ciphertext.
|
||||
let ws_scheme = if no_tls { "ws" } else { "wss" };
|
||||
let ws_url = format!(
|
||||
"{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"
|
||||
);
|
||||
let (mut sock, _resp) =
|
||||
tungstenite::connect(&ws_url).context("ws connect (insecure wss not yet wired)")?;
|
||||
println!("⛧ websocket attached to the coven");
|
||||
|
||||
// First frame is the `init` state snapshot.
|
||||
if let Ok(msg) = sock.read() {
|
||||
if let Ok(txt) = msg.into_text() {
|
||||
let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
|
||||
println!("⛧ recv: type={}", v["type"].as_str().unwrap_or("?"));
|
||||
}
|
||||
}
|
||||
sock.send(tungstenite::Message::Text(ct))?;
|
||||
sock.flush()?;
|
||||
println!("⛧ sent encrypted offering");
|
||||
|
||||
// Read frames until we see our broadcast echo, then decrypt it to prove the
|
||||
// full round-trip (Rust encrypt → server relay → Rust decrypt).
|
||||
for _ in 0..5 {
|
||||
match sock.read() {
|
||||
Ok(tungstenite::Message::Text(txt)) => {
|
||||
let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
|
||||
if v["type"] == "message" {
|
||||
if let Some(ct) = v["data"]["text"].as_str() {
|
||||
match fernet.decrypt(ct) {
|
||||
Ok(pt) => {
|
||||
println!(
|
||||
"⛧ round-trip ✓ decrypted: {:?}",
|
||||
String::from_utf8_lossy(&pt)
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => println!("⛧ [decrypt failed]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
println!("⛧ ws read ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
32
coven/tools/gen_vectors.py
Normal file
32
coven/tools/gen_vectors.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import srp, srp._pysrp as p, hashlib, binascii
|
||||
srp.rfc5054_enable()
|
||||
# force pure-python to match (and check active backend)
|
||||
import srp as S
|
||||
print("# backend:", S._mod.__name__)
|
||||
H=hashlib.sha256
|
||||
user=b"chat"; pw=b"labtest"
|
||||
salt=bytes.fromhex("0a1b2c3d") # fixed 4-byte salt
|
||||
a=bytes.fromhex(("11"*256)) # fixed 256-byte a (high bit set via 0x11.. ok? need high bit)
|
||||
a=bytes([0x80])+bytes.fromhex("22"*255) # ensure high bit set, 256 bytes
|
||||
b=bytes([0x80])+bytes.fromhex("33"*255)
|
||||
# verifier from known salt: replicate create_salted_verification_key internals with fixed salt
|
||||
N,g=p.get_ng(p.NG_2048,None,None)
|
||||
x=p.gen_x(H, salt, user, pw)
|
||||
v=pow(g,x,N)
|
||||
v_bytes=p.long_to_bytes(v)
|
||||
usr=p.User(user,pw,hash_alg=p.SHA256,ng_type=p.NG_2048,bytes_a=a)
|
||||
I,A=usr.start_authentication()
|
||||
svr=p.Verifier(user,salt,v_bytes,A,hash_alg=p.SHA256,ng_type=p.NG_2048,bytes_b=b)
|
||||
s2,B=svr.get_challenge()
|
||||
M=usr.process_challenge(salt,B)
|
||||
HAMK=svr.verify_session(M)
|
||||
usr.verify_session(HAMK)
|
||||
def hx(x): return binascii.hexlify(x).decode()
|
||||
print("N_bits", N.bit_length())
|
||||
print("salt", hx(salt))
|
||||
print("A", hx(A))
|
||||
print("B", hx(B))
|
||||
print("M", hx(M))
|
||||
print("HAMK", hx(HAMK))
|
||||
print("K", hx(usr.K))
|
||||
print("authok", usr.authenticated())
|
||||
513
docs/spec-collaborative-sandbox.md
Normal file
513
docs/spec-collaborative-sandbox.md
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
# cmd-chat → Collaborative Sandbox Sessions — Spec
|
||||
|
||||
> **Status:** Draft v1 · **Date:** 2026-05-30
|
||||
> **Scope:** Evolves `cmd-chat` from an E2E-encrypted terminal chat into a
|
||||
> multi-user collaborative session with a shared, sandboxed Linux environment.
|
||||
> **Baseline reviewed:** `cmd_chat/` @ `main` (commit `dc1b5e5`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Decisions locked (from product owner)
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|----------|--------|
|
||||
| A | Client language / TUI | **Rust + ratatui client**, **Python Sanic server unchanged**. Stable JSON-over-WebSocket wire protocol between them. |
|
||||
| B | Sandbox backend | **Pluggable backend interface. Multipass default, Docker secondary.** |
|
||||
| C | Shared-terminal model | **Single shared PTY** (collaborative, tmux-share style). Permissions = who may type. |
|
||||
| D | Permission model | **Two layers:** app-level RBAC (owner/admin/member) **+** real VM unix users & `sudo` delegation. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & goals
|
||||
|
||||
Turn a cmd-chat "room" into a **shared workspace**: up to 4 people (infra for more)
|
||||
join one encrypted session, chat, drop files/dirs into a shared space, and
|
||||
collaboratively drive **one sandboxed Linux box** they can type commands into and
|
||||
run scripts in — with real Linux permissions and a clear owner who can delegate
|
||||
superuser rights.
|
||||
|
||||
### Goals
|
||||
- Preserve the existing security guarantees: **zero-knowledge server**, **E2E
|
||||
Fernet encryption**, **SRP auth**, **RAM-only server**, **no IP leaks**.
|
||||
- 4 concurrent users per session today; capacity is a single config constant, not
|
||||
an architectural limit.
|
||||
- A genuinely nice **ratatui** TUI: panes, themes, custom colour/layout config.
|
||||
- One-command launch of a **disposable sandbox** (Multipass VM or Docker
|
||||
container) that the whole room shares.
|
||||
- File **and directory** upload into the shared session.
|
||||
- Linux-grade permissions inside the sandbox + app-level roles governing the
|
||||
session itself.
|
||||
|
||||
### Non-goals (v1)
|
||||
- Persistence / chat history survival across server restart (server stays RAM-only).
|
||||
- Federation / multiple rooms per server process (one room per `serve`, as today).
|
||||
- Running the sandbox *on the server* (see §4 — it runs on the initiator's client).
|
||||
- Mobile / GUI clients. Terminal only.
|
||||
- Multi-VM topologies. One shared sandbox per session.
|
||||
|
||||
---
|
||||
|
||||
## 2. Baseline architecture (what exists today)
|
||||
|
||||
```
|
||||
CLIENT (python+rich) SERVER (sanic, RAM-only) CLIENT
|
||||
SRP handshake ───────────────► /srp/init, /srp/verify ──────────► (relay only)
|
||||
HKDF(pw,salt) → room_key server NEVER
|
||||
Fernet(room_key) encrypt WSS /ws/chat (broadcast) sees plaintext
|
||||
──── ciphertext ──────────────► ConnectionManager.broadcast ─────► decrypt(room_key)
|
||||
file xfer = _ft JSON over the same encrypted message channel (64KB chunks, SHA-256)
|
||||
```
|
||||
|
||||
- **Server modules:** `factory.py` (DI/wiring), `server.py` (TLS + run),
|
||||
`routes.py`, `views.py` (SRP + ws + admin), `managers.py`
|
||||
(`ConnectionManager`), `stores.py` (`MessageStore`, `UserSessionStore`),
|
||||
`srp_auth.py`, `models.py`, `helpers.py` (`RateLimiter`, `get_client_ip`).
|
||||
- **Client:** single `client.py` — `Client` class, asyncio `receive_loop` +
|
||||
`input_loop`, rich console clear-and-reprint, `_ft` file-transfer protocol.
|
||||
- **Wire types today:** `init`, `message`, `user_left`. File transfer rides inside
|
||||
`message.text` as JSON beginning `{"_ft": …}` (offer/accept/reject/chunk/done).
|
||||
|
||||
### What we keep vs. change
|
||||
| Component | v1 plan |
|
||||
|---|---|
|
||||
| Sanic server, SRP, TLS, RateLimiter | **Keep**, extend with new relay message types + capacity check. |
|
||||
| `ConnectionManager` broadcast | **Keep**; add `broadcast(exclude_user)` usage and roster events. |
|
||||
| RAM-only / zero-knowledge | **Keep** — non-negotiable; everything new also rides as ciphertext. |
|
||||
| Python rich client | **Replace** with Rust ratatui client (protocol-compatible). |
|
||||
| File transfer `_ft` protocol | **Keep & extend** for directories (tar stream). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Target architecture (high level)
|
||||
|
||||
```
|
||||
┌─────────────────────────── SESSION (one room) ───────────────────────────┐
|
||||
│ │
|
||||
│ OWNER CLIENT (Rust/ratatui) SERVER (Sanic, dumb relay) │
|
||||
│ ┌───────────────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ ratatui UI │ │ SRP / TLS / rate-limit │ │
|
||||
│ │ chat | roster | sandbox │◄──WSS───►│ ConnectionManager │◄──┐ │
|
||||
│ │ E2E Fernet(room_key) │ cipher │ broadcast(opaque bytes) │ │ │
|
||||
│ │ ┌───────────────────────┐ │ text │ Stores (RAM only) │ │ │
|
||||
│ │ │ SANDBOX BROKER (local)│ │ └─────────────────────────┘ │ │
|
||||
│ │ │ Multipass | Docker │ │ │ │
|
||||
│ │ │ PTY ⇄ encrypted frames│ │ MEMBER CLIENTS (Rust/ratatui) ─────┘ │
|
||||
│ │ │ RBAC + unix-user map │ │ decrypt → render shared PTY pane │
|
||||
│ │ └───────────────────────┘ │ send keystrokes (if permitted) │
|
||||
│ └───────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key principle — the server stays zero-knowledge.** The sandbox does **not** run
|
||||
on the server (the server has no room key and must never see plaintext). Instead:
|
||||
|
||||
- The **client that launches the sandbox** (normally the owner) hosts a local
|
||||
**Sandbox Broker** that spawns the Multipass VM / Docker container and owns its
|
||||
PTY.
|
||||
- PTY output is Fernet-encrypted with the room key and relayed through the server
|
||||
as opaque ciphertext — identical trust model to file transfer.
|
||||
- Keystrokes from other clients travel encrypted to the broker, which is the
|
||||
**single policy-enforcement point** (it decides who may type / `sudo` / upload).
|
||||
|
||||
This is the only design that satisfies *both* "shared sandbox" *and* "server can't
|
||||
read anything." It is documented as a constraint, not an accident.
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire protocol (v2)
|
||||
|
||||
### 4.1 Versioning & envelope
|
||||
- Add a top-level relay type **`hello`** exchanged right after `init` carrying
|
||||
`protocol_version` (`2`). Server relays it untouched. Clients negotiate down /
|
||||
warn on mismatch.
|
||||
- All new collaborative payloads ride **inside the encrypted channel** the same way
|
||||
`_ft` does: the cleartext-to-server is a `message` frame whose decrypted `text`
|
||||
is JSON with a discriminator key. We generalise `_ft` to a namespaced envelope:
|
||||
|
||||
```jsonc
|
||||
// decrypted application payload (server only ever sees the ciphertext of this)
|
||||
{
|
||||
"v": 2,
|
||||
"kind": "chat" | "file" | "dir" | "sbx" | "perm" | "presence",
|
||||
"id": "uuid", // correlation id
|
||||
"from":"username", // asserted by sender, validated by broker for sbx/perm
|
||||
"ts": "iso8601",
|
||||
"body": { /* kind-specific */ }
|
||||
}
|
||||
```
|
||||
|
||||
> Rationale: keeps the server's relay role unchanged (still just `message`
|
||||
> broadcast of opaque bytes) while giving the app a clean, extensible schema.
|
||||
> Existing `_ft` messages map onto `kind:"file"`.
|
||||
|
||||
### 4.2 New server-visible relay types (cleartext metadata only)
|
||||
The server learns nothing it shouldn't; these carry no plaintext content.
|
||||
- `roster` — authoritative presence list + capacity (server-generated; see §5).
|
||||
- `capacity_full` — sent on connect rejection (HTTP 409 / ws close code).
|
||||
- Everything else stays `message` (opaque ciphertext).
|
||||
|
||||
### 4.3 Sandbox sub-protocol (`kind:"sbx"`, encrypted, broker-authoritative)
|
||||
| `body.op` | Direction | Meaning |
|
||||
|---|---|---|
|
||||
| `launch` | client→broker | request to start sandbox (owner/admin only) |
|
||||
| `status` | broker→all | `starting`/`ready`/`stopped`/`error`, backend, image, specs |
|
||||
| `pty_data` | broker→all | base64 PTY output chunk (the shared terminal stream) |
|
||||
| `pty_input` | client→broker | keystrokes; broker enforces "may type" ACL |
|
||||
| `resize` | client→broker | cols/rows; broker applies if sender is the active driver |
|
||||
| `run_script` | client→broker | upload+exec a script in the VM (perm-gated) |
|
||||
| `stop` | owner→broker | tear down sandbox |
|
||||
|
||||
### 4.4 Permission sub-protocol (`kind:"perm"`, broker-authoritative)
|
||||
| `body.op` | Meaning |
|
||||
|---|---|
|
||||
| `grant` / `revoke` | change app role (admin/member) — owner/admin only |
|
||||
| `sudo_grant` / `sudo_revoke` | add/remove a user from VM sudoers — superuser only |
|
||||
| `acl` | broker broadcasts the current authoritative ACL snapshot |
|
||||
|
||||
---
|
||||
|
||||
## 5. Feature 1 — Multi-user sessions (4 now, N later)
|
||||
|
||||
**Current state:** `ConnectionManager` already supports arbitrarily many websocket
|
||||
connections; `username_exists` blocks dup names. No capacity cap, no real roster
|
||||
broadcast (only `init` snapshot + `user_left`).
|
||||
|
||||
**Changes**
|
||||
1. **Capacity constant** `SESSION_MAX_USERS = 4` in `factory.py` (env override
|
||||
`CMD_CHAT_MAX_USERS`). Enforced in `srp_verify` *and* on ws connect:
|
||||
reject with `409 Username/Room full` / ws close code `4004` when
|
||||
`session_store.count() >= max`.
|
||||
2. **Authoritative roster.** Server emits a `roster` event (join, leave,
|
||||
role-change) so all clients converge on one presence list with roles. Replaces
|
||||
the ad-hoc `user_left` patching (kept for back-comp, superseded by `roster`).
|
||||
3. **`user_joined` event** added (today only `user_left` exists) for live roster.
|
||||
4. **Infra-for-more:** capacity is data, not code. Document that >4 needs (a) UI
|
||||
roster scroll, (b) broadcast fan-out is O(N) per message — fine to low double
|
||||
digits; note ceiling in README. No protocol change required to raise the cap.
|
||||
|
||||
**Acceptance**
|
||||
- 5th join attempt to a 4-cap room is cleanly refused with a user-visible reason.
|
||||
- Roster in every client matches server truth within one broadcast round-trip.
|
||||
- Raising `CMD_CHAT_MAX_USERS=8` works with zero code changes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature 2 — Rust ratatui client (enhanced + themeable)
|
||||
|
||||
**New crate:** `cmd-chat-tui/` (Rust 2021). Talks the v2 protocol to the existing
|
||||
Sanic server. The Python client remains in-tree as a reference/fallback until the
|
||||
Rust client reaches parity, then is deprecated.
|
||||
|
||||
### 6.1 Crate layout
|
||||
```
|
||||
cmd-chat-tui/
|
||||
Cargo.toml
|
||||
src/
|
||||
main.rs # CLI (clap): connect/serve-shim, flags mirror python
|
||||
app.rs # App state, event loop (tokio + crossterm)
|
||||
net/
|
||||
srp.rs # SRP-6a client (crate: srp + sha2) — matches python params
|
||||
ws.rs # tokio-tungstenite WS client
|
||||
crypto.rs # HKDF-SHA256 → Fernet (crate: fernet) room key
|
||||
proto.rs # v2 envelope (serde) types
|
||||
ui/
|
||||
layout.rs # ratatui layout regions
|
||||
chat.rs # chat pane (scrollback, msg styling)
|
||||
roster.rs # users + roles + sandbox status
|
||||
sandbox.rs # shared PTY pane (vt100 parse: crate `vt100`)
|
||||
input.rs # input box, command palette (/send /run /grant …)
|
||||
theme.rs # theme model + loader
|
||||
sandbox/
|
||||
broker.rs # local PTY broker (owner side) — see §8
|
||||
backend.rs # trait SandboxBackend
|
||||
multipass.rs # default backend
|
||||
docker.rs # secondary backend
|
||||
files.rs # upload (file + dir/tar), SHA-256, chunking
|
||||
perms.rs # client-side ACL view + enforcement hints
|
||||
themes/
|
||||
default.toml nord.toml gruvbox.toml mono.toml
|
||||
```
|
||||
|
||||
### 6.2 Crypto parity (must match Python exactly)
|
||||
- SRP-6a, group **RFC5054 / SHA-256**, identity `b"chat"` (matches
|
||||
`srp_auth.py`). Verify interop against the live Sanic server early — this is the
|
||||
#1 integration risk.
|
||||
- Room key: `HKDF(SHA256, len=32, salt=room_salt, info=b"cmd-chat-room-key")`
|
||||
over the password, then `Fernet(urlsafe_b64(key))` — byte-for-byte as
|
||||
`client.py::srp_authenticate`.
|
||||
- WS auth: `ws_token` echoed from `/srp/verify`; HMAC token already server-side.
|
||||
|
||||
### 6.3 UI / layout
|
||||
Default layout (resizable, ratatui `Layout` constraints):
|
||||
```
|
||||
┌ cmd-chat ── room: <name> ── 🔒 E2E ── users 3/4 ──────────────┐
|
||||
│ ┌─ chat ───────────────────────────┐ ┌─ roster ─────────────┐ │
|
||||
│ │ 12:01 alice: hey │ │ ● alice (owner,root)│ │
|
||||
│ │ 12:01 bob: yo │ │ ● bob (admin,sudo)│ │
|
||||
│ │ …scrollback… │ │ ○ carol (member) │ │
|
||||
│ └──────────────────────────────────┘ │ sandbox: ● ready │ │
|
||||
│ ┌─ sandbox (shared PTY · driver: alice) ─────────────────────┐│
|
||||
│ │ ubuntu@sbx:~$ ./build.sh ││
|
||||
│ │ …live vt100 output for everyone… ││
|
||||
│ └────────────────────────────────────────────────────────────┘│
|
||||
│ > type message · /send /run /sbx /grant (F2 toggle PTY focus)│
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- **Panes:** chat, roster (with roles + sandbox status), shared PTY, input.
|
||||
- **Focus model:** Tab cycles panes; `F2` toggles "drive the PTY" (keystrokes go to
|
||||
sandbox) vs "chat input". Visible indicator of who currently holds the PTY driver
|
||||
token (see §8.3).
|
||||
- **Command palette** (`/`): `/send`, `/sendd <dir>`, `/run <script>`, `/sbx
|
||||
launch|stop`, `/grant <user> <role>`, `/sudo <user>`, `/theme <name>`, `/quit`.
|
||||
- **vt100 rendering** via the `vt100` crate so real terminal apps (vim, htop) render.
|
||||
|
||||
### 6.4 Themes & custom design
|
||||
- TOML themes in `themes/`, loaded from `$XDG_CONFIG_HOME/cmd-chat/themes/` first.
|
||||
- Schema: named colours (16 + hex), per-element styles (chat.self, chat.other,
|
||||
timestamp, roster.owner, sandbox.border, …), border style, layout ratios
|
||||
(chat:roster split, PTY height), and key-bindings overrides.
|
||||
- `--theme <name>` flag + `/theme` live switch. Ships: default, nord, gruvbox, mono.
|
||||
|
||||
**Acceptance**
|
||||
- Authenticates against the unchanged Sanic server and exchanges chat with a
|
||||
Python client (proves crypto + protocol parity).
|
||||
- vim/htop usable inside the shared PTY pane.
|
||||
- Switching theme at runtime restyles without reconnect.
|
||||
|
||||
---
|
||||
|
||||
## 7. Feature 3 — Launchable sandboxed environment (pluggable backend)
|
||||
|
||||
### 7.1 Backend abstraction
|
||||
```rust
|
||||
trait SandboxBackend {
|
||||
async fn launch(&self, spec: &SandboxSpec) -> Result<Handle>; // create + boot
|
||||
async fn attach_pty(&self, h: &Handle) -> Result<PtyPair>; // master pty
|
||||
async fn exec(&self, h: &Handle, argv: &[String], as_user: &str) -> Result<Output>;
|
||||
async fn put_file(&self, h: &Handle, dst: &str, bytes: &[u8], mode: u32) -> Result<()>;
|
||||
async fn add_user(&self, h: &Handle, name: &str, sudo: bool) -> Result<()>;
|
||||
async fn set_sudo(&self, h: &Handle, name: &str, enabled: bool) -> Result<()>;
|
||||
async fn stop(&self, h: &Handle) -> Result<()>; // destroy
|
||||
}
|
||||
```
|
||||
- **Multipass (default):** `multipass launch`, `multipass exec`, `multipass
|
||||
transfer`, `multipass shell` (PTY via `multipass exec -- bash -i` over a pty),
|
||||
`multipass delete --purge`. Real VM ⇒ strong isolation, genuine users/sudo.
|
||||
- **Docker (secondary):** `docker run -d`, `docker exec -it`, `docker cp`,
|
||||
`useradd`/`gpasswd`. Faster, weaker isolation; flag the root-in-container caveat.
|
||||
- Backend chosen via `--sbx-backend multipass|docker` (default multipass), with
|
||||
capability probe (which binary exists) and graceful fallback message.
|
||||
|
||||
### 7.2 `SandboxSpec`
|
||||
- image/release (e.g. `24.04` for multipass, `ubuntu:24.04` for docker),
|
||||
cpus, mem, disk, name (`sbx-<room>-<short>`), `disposable: true` (purge on stop /
|
||||
session end). Defaults sized small (1 cpu / 1G / 5G).
|
||||
|
||||
### 7.3 Launch flow
|
||||
1. Owner/admin runs `/sbx launch` → broker validates RBAC (§9) → `sbx.status:
|
||||
starting` broadcast.
|
||||
2. Broker boots backend, opens PTY, creates VM users for each *current* member
|
||||
(owner = sudoer/root, others = standard) — see §9.4.
|
||||
3. `sbx.status: ready` broadcast with backend/image/specs.
|
||||
4. Broker streams `pty_data` (encrypted) to all; accepts `pty_input` from the
|
||||
permitted driver; everyone watches live.
|
||||
5. `/sbx stop` (owner) → `stop()` → purge → `sbx.status: stopped`.
|
||||
|
||||
### 7.4 Lifecycle & safety
|
||||
- Sandbox is **disposable**: destroyed on `/sbx stop`, on owner disconnect (grace
|
||||
timer), and on server/session end. Nothing persists by default (matches RAM-only
|
||||
ethos). A `--keep` flag can opt out for the owner.
|
||||
- Resource caps enforced via backend spec; reject launch if host lacks resources.
|
||||
- The broker runs on the **owner's machine**, so the owner is implicitly trusting
|
||||
their own host to run the VM — documented clearly.
|
||||
|
||||
**Acceptance**
|
||||
- `/sbx launch` boots a Multipass VM in the owner's client; all members see
|
||||
`ready` and live output. Switching `--sbx-backend docker` works unchanged at the
|
||||
protocol level.
|
||||
- `/sbx stop` fully purges the VM/container (verified: `multipass list` clean).
|
||||
|
||||
---
|
||||
|
||||
## 8. Feature 4 — Shared collaborative PTY
|
||||
|
||||
### 8.1 Model
|
||||
Single shared PTY (decision C). The broker owns one master PTY connected to a
|
||||
shell in the sandbox. Output fans out to everyone (`pty_data`); input is funneled
|
||||
from whoever currently holds the **driver token**.
|
||||
|
||||
### 8.2 Output path (E2E)
|
||||
PTY master output → broker reads → base64 → Fernet(room_key) → `message` relay →
|
||||
all clients decrypt → feed bytes to local `vt100` parser → render sandbox pane.
|
||||
Server sees only ciphertext. Output is **broadcast to all** including the driver
|
||||
(so everyone has identical screen state).
|
||||
|
||||
### 8.3 Input path & driver token
|
||||
- "Who may type" is a permission (see §9). Among permitted users, a **single driver
|
||||
token** prevents keystroke interleaving.
|
||||
- Default: token follows last `request-drive` (F2) and auto-releases after N
|
||||
seconds idle, or explicit release. Owner can force-grab.
|
||||
- Non-driver permitted users see a "request drive" affordance; the current driver
|
||||
/ owner approves (or owner config = open-grab among permitted users).
|
||||
- Broker is authoritative: it drops `pty_input` from anyone not holding the token.
|
||||
|
||||
### 8.4 Resize
|
||||
- Driver's terminal size drives `resize`; others letterbox/scroll to fit. Broker
|
||||
applies `TIOCSWINSZ`.
|
||||
|
||||
**Acceptance**
|
||||
- Two members alternately take the driver token and type; no interleaved garbage;
|
||||
all panes show identical output.
|
||||
- A member without "may type" permission is silently prevented from driving and
|
||||
told why.
|
||||
|
||||
---
|
||||
|
||||
## 9. Features 5 & 6 — File/dir upload + permission model
|
||||
|
||||
### 9.1 File & directory upload (feature 5)
|
||||
Extends the existing `_ft` flow (now `kind:"file"` / `kind:"dir"`):
|
||||
- **File:** unchanged semantics (offer/accept/chunk/done, 64KB, SHA-256), but a
|
||||
shared-session target: accepted files land in the **sandbox shared dir**
|
||||
(`/srv/shared`, default) *and/or* local `./downloads/` (user choice on accept).
|
||||
- **Directory:** `/sendd <dir>` → broker/sender streams a **tar** of the tree as
|
||||
`kind:"dir"` chunks (same chunking + a single SHA-256 over the tar). On accept,
|
||||
extracted into `/srv/shared/<name>/` in the sandbox (path-traversal-guarded:
|
||||
reject entries with `..`, absolute paths, or symlinks escaping root).
|
||||
- If no sandbox is running, dir/file upload still works peer-to-peer into
|
||||
`./downloads/` (parity with today).
|
||||
- Max sizes: keep 50 MB/file; add `CMD_CHAT_MAX_UPLOAD` and a per-session
|
||||
aggregate cap. Files written into the VM get owner = uploader's VM user, mode
|
||||
`0644` (dirs `0755`).
|
||||
|
||||
**Acceptance**
|
||||
- `/sendd ./project` puts the tree under `/srv/shared/project` in the VM with
|
||||
correct ownership; SHA-256 verified; traversal attack entries rejected.
|
||||
|
||||
### 9.2 Permission model — two layers (feature 6)
|
||||
|
||||
**Layer 1 — App-level RBAC (session control).** Enforced by the broker (the only
|
||||
component that can read plaintext and owns the sandbox). Roles:
|
||||
|
||||
| Role | Capabilities |
|
||||
|---|---|
|
||||
| **owner** | Everything. The initiator. Can launch/stop sandbox, grant/revoke admin, delegate VM superuser, force driver token, kick. Exactly one. |
|
||||
| **admin** | Launch sandbox, manage members (grant/revoke member-level), approve drive requests, manage uploads. Cannot remove owner. |
|
||||
| **member** | Chat, request drive, upload (if allowed), use sandbox per VM perms. |
|
||||
|
||||
- Owner is whoever ran `serve` / first authenticated as owner (config:
|
||||
`CMD_CHAT_OWNER=<username>`; default = first user to connect). Ownership transfer
|
||||
via `perm.grant owner <user>` (owner only).
|
||||
- RBAC changes broadcast as `perm.acl` snapshots so all clients show current roles.
|
||||
|
||||
**Layer 2 — Real VM unix users & sudo (filesystem/command control).** Inside the
|
||||
sandbox, each session member maps to a real Linux account:
|
||||
- On member join *while sandbox is up*, broker `add_user(name, sudo=role∈{owner})`.
|
||||
- **Superuser = real root/sudoer.** The initiator's VM user is in `sudo` group.
|
||||
- **Delegation:** owner runs `/sudo <user>` → `perm.sudo_grant` → broker
|
||||
`set_sudo(user, true)` (adds to `sudo` group in VM). `/sudo -r <user>` revokes.
|
||||
- The shared PTY shell runs **as the current driver's VM user**, so real Linux
|
||||
permissions (file ownership, `sudo` prompts) apply naturally — a member without
|
||||
sudo who types `sudo apt install` gets denied by the *VM*, not just the app.
|
||||
|
||||
> The two layers are complementary: app RBAC decides *who can touch the session and
|
||||
> the driver token*; unix perms decide *what a command can actually do once typed*.
|
||||
> Defense in depth — an RBAC bug can't grant root, and a VM-perms bug can't bypass
|
||||
> session control.
|
||||
|
||||
**Acceptance**
|
||||
- Owner delegates sudo to bob; bob's `sudo` commands now succeed in the VM; revoke
|
||||
works (`gpasswd -d`).
|
||||
- A member demoted from admin immediately loses launch/grant abilities (next
|
||||
broker-validated action rejected; `perm.acl` updated in all clients).
|
||||
- Driver shell runs as the driver's own unix user (verify with `whoami`/`id`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Security considerations
|
||||
|
||||
- **Server remains zero-knowledge.** PTY frames, uploads, perms — all ride as
|
||||
Fernet ciphertext inside `message`. The server's job is unchanged (broadcast
|
||||
opaque bytes). Re-audit `views.py::chat_ws` to confirm no new plaintext leaks.
|
||||
- **Broker = trust anchor.** It holds the room key and runs the VM on the owner's
|
||||
host. It is the single enforcement point for sbx/perm ops. Document that members
|
||||
are trusting the owner's host (where the VM runs).
|
||||
- **Sender spoofing:** `from` in the envelope is asserted by the sender. For
|
||||
chat that's acceptable (today's model). For `sbx`/`perm` ops the broker **must
|
||||
bind identity to the ws session** (the `user_id`/`ws_token` already authenticated
|
||||
by the server), not to the self-asserted `from`. Add a server-stamped, integrity-
|
||||
protected sender id to relayed frames, or have the broker correlate via the
|
||||
authenticated channel. **Open item — see §12.**
|
||||
- **Sandbox escape:** Multipass (VM) >> Docker (container). Default to Multipass;
|
||||
warn loudly when Docker is selected. Never run the broker's host shell — only the
|
||||
VM shell is shared.
|
||||
- **Path traversal / zip-slip** on dir upload: hard-reject `..`, absolute, escaping
|
||||
symlinks; extract under a fixed root with a safe tar extractor.
|
||||
- **Resource exhaustion:** cap VM cpu/mem/disk, upload sizes, PTY output rate
|
||||
(coalesce + backpressure so a `yes` flood can't DoS the room).
|
||||
- **Rate limiting:** keep on `/srp/*`; add light rate-limit on launch/upload ops at
|
||||
the broker.
|
||||
- Keep TLS-by-default, SRP, getpass, no-IP-broadcast guarantees intact.
|
||||
|
||||
---
|
||||
|
||||
## 11. Milestones
|
||||
|
||||
| Phase | Deliverable | Gates |
|
||||
|---|---|---|
|
||||
| **P0** | Spec sign-off + crypto interop spike: Rust SRP+HKDF+Fernet talks to live Sanic server. | Rust client exchanges one chat msg with Python client. |
|
||||
| **P1** | Rust ratatui client at parity (chat, roster, file xfer, themes). Deprecate rich client. | Feature-2 acceptance. |
|
||||
| **P2** | Multi-user hardening: capacity cap, authoritative roster, `user_joined`. | Feature-1 acceptance. |
|
||||
| **P3** | Sandbox broker + backend trait + Multipass; shared PTY (output+driver token). | Features 3 & 4 acceptance. |
|
||||
| **P4** | Permissions: app RBAC + VM unix users/sudo delegation; sender-identity binding. | Feature-6 acceptance + §10 sender fix. |
|
||||
| **P5** | Dir upload (tar) into shared dir; Docker backend; resource/rate caps; docs. | Feature-5 acceptance; Docker smoke test. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions / risks
|
||||
|
||||
1. **Sender-identity binding (security-critical).** `sbx`/`perm` ops must be tied to
|
||||
the server-authenticated `user_id`, not the self-asserted `from`. Options: (a)
|
||||
server stamps an authenticated sender id onto every relayed `message`
|
||||
(small server change, breaks "pure relay" purity slightly but stays
|
||||
zero-knowledge re: *content*); (b) broker-side challenge per privileged op.
|
||||
**Recommend (a).** Needs owner decision.
|
||||
2. **Where does the broker live if the owner uses the Rust client on a phone/thin
|
||||
host?** v1 assumes owner host can run Multipass. Fallback: allow a designated
|
||||
"sandbox-host" member. Out of scope v1?
|
||||
3. **Multipass PTY fidelity** via `multipass exec` — confirm full pty (job control,
|
||||
resize). If lacking, SSH into the VM instead (`multipass` injects a key).
|
||||
4. **vt100 crate completeness** for heavy TUIs (vim/tmux-in-VM). Spike in P3.
|
||||
5. **Cross-platform** broker (owner on macOS/Windows)? Multipass supports both;
|
||||
Docker paths differ. v1 target = Linux owner (matches current usage).
|
||||
6. **Capacity >4 fan-out** — O(N) broadcast + N PTY decryptions per frame. Fine to
|
||||
~12; note ceiling. Coalesce PTY output to bound it.
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing strategy
|
||||
|
||||
- **Crypto interop** (P0): Rust↔Python chat round-trip; golden vectors for
|
||||
HKDF/Fernet/SRP.
|
||||
- **Protocol:** schema tests for v2 envelope; back-compat with `_ft`.
|
||||
- **Server:** extend existing pytest suite (`tests/`) for capacity cap, roster
|
||||
events, sender-id stamping.
|
||||
- **Broker (Rust):** unit tests for RBAC matrix, ACL transitions, driver-token
|
||||
state machine, tar-extract traversal guard.
|
||||
- **Integration:** scripted Multipass launch in CI-capable runner (or gated
|
||||
manual); verify user/sudo mapping, file ownership, purge-on-stop.
|
||||
- **Manual lab:** extend `lab/setup-lab.sh` to a 3-pane Rust-client lab + a
|
||||
`/sbx launch` smoke walkthrough.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Mapping requested features → spec sections
|
||||
|
||||
| Requested | Section |
|
||||
|---|---|
|
||||
| 1. Up to 4 users, infra for more | §5 |
|
||||
| 2. ratatui TUI, enhanced + custom colours/layout | §6 |
|
||||
| 3. Launch sandboxed env (Multipass), run commands/scripts | §7, §8 |
|
||||
| 4. Shared collaborative terminal | §8 |
|
||||
| 5. Upload files/directories to shared session | §9.1 |
|
||||
| 6. Linux perms in VM, superuser by initiator + delegation | §9.2 |
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Loading…
Reference in New Issue
Block a user