diff --git a/Cargo.lock b/Cargo.lock index 0510c55..92ad0a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.41" @@ -408,6 +432,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1000,6 +1025,7 @@ dependencies = [ "clap_complete", "colored", "console", + "crossterm", "crypto-hash", "ctrlc", "envx_sdk", @@ -1021,6 +1047,7 @@ dependencies = [ "serde_json", "shlex", "smallvec", + "snow", "tempfile", "textwrap", "thiserror 1.0.69", @@ -2313,6 +2340,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3048,6 +3086,23 @@ dependencies = [ "syn", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.3", + "ring", + "rustc_version", + "sha2", + "subtle", +] + [[package]] name = "socket2" version = "0.5.9" diff --git a/Cargo.toml b/Cargo.toml index e27e517..2da338d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ once_cell = "1.21.3" uuid = { version = "^1.8", features = ["v4"] } shlex = "1.3.0" rusqlite = { version = "0.32", features = ["bundled"] } +snow = "0.10" +crossterm = "0.25" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index 60eb813..ea1e71a 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,14 @@ Options: This project is licensed under the GPLv3 License. A copy of the GPLv3 License can be found in the [LICENSE](LICENSE) file. This project uses code from the [Railway's CLIv3](https://github.com/railwayapp/cli), copyright (c) [2023] Railway Corp. The Railway CLI is licensed under the MIT License. A copy of the MIT License can be found in the [attributions/railway/LICENSE](attributions/railway/LICENSE) file. + +### Log in on another machine + +Run `envx auth link` on your existing machine and keep its terminal open. Run the +printed `envx auth login ''` command on the new machine, enter its verification +code on the original machine, then enter your existing identity passphrase on the +new machine. Links contain only a pairing identifier; identity material travels +through an encrypted, verified channel. See [pairing and its security contract](docs/auth-pairing.md). + +Identity commands are also available under `auth`: `status`, `gen`, `register`, +and `export`. Existing command spellings remain supported. diff --git a/docs/auth-pairing.md b/docs/auth-pairing.md new file mode 100644 index 0000000..19c1765 --- /dev/null +++ b/docs/auth-pairing.md @@ -0,0 +1,69 @@ +# Pair an existing identity with another machine + +On the machine where envx already works: + +```sh +envx auth link +``` + +Keep that terminal open. Copy the printed `envx auth login 'https://…'` command +and run it on the new machine. The invitation contains a server URL and a random +session identifier, not your identity or a decryption secret. It can be sent +through a messaging service; someone with the invitation can race to request +pairing, but cannot obtain your identity merely by possessing the invitation. + +The new machine shows a verification code. Copy that complete code into the +original machine's waiting terminal. Only enter a code from a terminal you +control. This explicitly approves that receiving machine. A mismatch cancels +the transfer before any identity material is sent. If someone else claims your +invitation first, cancel and generate a fresh one. + +The new machine asks for your existing identity passphrase. The passphrase is +never transferred. Login verifies the identity and authenticates it against the +server before installing it. Existing identities and key files are not replaced. +The OS keyring may cache the passphrase, just as it does for `envx gen`; it is +never saved into `config.json` by login. Run `envx link` to connect a local project +directory after login. + +Both machines need the pairing-capable CLI and API. Pairings expire after ten +minutes. Ctrl-C cancels a pending transfer. Interrupted transfers can be restarted +with a fresh invitation. If installation fails after key files are written, the +passphrase-protected files are retained. A fresh pairing can resume installation +when those files match the transferred identity exactly; different files are never +overwritten or deleted. Restoring an identity does not copy machine-local configuration, +project directory links, aliases or trust pins. + +## Security contract + +The clients use `Noise_XX_25519_ChaChaPoly_SHA256` via `snow`, with fresh handshake +keys for every pairing and the server URL and session identifier bound into the prologue. The +verification code is the complete 256-bit handshake hash. The source releases the +identity only after the user enters the receiving terminal's exact code. The +server does not receive that code. Do not accept a code supplied by a stranger. + +The server relays bounded opaque handshake messages and an authenticated encrypted +identity bundle. The original OpenPGP private key remains passphrase-protected +inside that bundle. The bundle contains no passphrase. A receiver capability is +generated locally and hashed in server storage; it is not included in the link. +A database snapshot or invitation alone cannot decrypt a transfer. A malicious +relay can interrupt or substitute a peer, but substitution changes the verification +code and must be rejected by the user. This is not protection against a compromised +endpoint or a user approving an attacker's terminal. + +One receiver may claim each invitation. Active sessions are capped at three per +account, twelve per client IPv4 address or IPv6 /64, and 1,024 globally. Server transitions are atomic, source +operations require the existing account, receiver operations require its separate +capability, and acknowledgement or cancellation deletes the relay row. A periodic +sweep removes expired rows; the ten-minute access deadline is enforced on requests, +not only by cleanup. Backups may retain ciphertext but have no Noise decryption key. + +The new machine receives the same private identity. It has the same authority as +the original machine; this does not introduce independently revocable device keys. + +## Commands + +`auth status` checks server authentication. `auth gen`, `auth register`, and +`auth export` group the existing identity commands. Legacy `envx auth`, `gen`, +`upload`, and `export` remain supported. Project invites are unchanged. + +Protocol references: https://noiseprotocol.org/noise.html and https://docs.rs/snow/0.10.0/. diff --git a/src/commands/auth/channel.rs b/src/commands/auth/channel.rs new file mode 100644 index 0000000..538e1cf --- /dev/null +++ b/src/commands/auth/channel.rs @@ -0,0 +1,108 @@ +//! Noise handles key agreement, transcript binding and authenticated encryption. +use anyhow::{bail, Result}; +const PATTERN: &str = "Noise_XX_25519_ChaChaPoly_SHA256"; +pub struct Handshake(snow::HandshakeState); +pub struct Channel { + state: snow::TransportState, + code: String, +} +impl Handshake { + pub fn new(session: &str, initiator: bool) -> Result { + let builder = snow::Builder::new(PATTERN.parse()?); + let pair = builder.generate_keypair()?; + let prologue = format!("envx-auth-pairing-v1:{session}"); + let builder = builder + .local_private_key(&pair.private)? + .prologue(prologue.as_bytes())?; + Ok(Self(if initiator { + builder.build_initiator()? + } else { + builder.build_responder()? + })) + } + pub fn write(&mut self) -> Result { + let mut output = [0u8; 1024]; + let len = self.0.write_message(&[], &mut output)?; + Ok(hex::encode(&output[..len])) + } + pub fn read(&mut self, message: &str) -> Result<()> { + if message.len() > 2048 { + bail!("Oversized pairing handshake"); + } + let mut output = [0u8; 1024]; + if self.0.read_message(&hex::decode(message)?, &mut output)? != 0 { + bail!("Unexpected handshake payload"); + } + Ok(()) + } + pub fn finish(self) -> Result { + if !self.0.is_handshake_finished() { + bail!("Incomplete handshake"); + } + // Compare the complete transcript hash, not a grindable short numeric code. + let code = hex::encode(self.0.get_handshake_hash()); + Ok(Channel { + state: self.0.into_transport_mode()?, + code, + }) + } +} +impl Channel { + pub fn code(&self) -> &str { + &self.code + } + pub fn encrypt(&mut self, plaintext: &[u8]) -> Result { + if plaintext.len() > 65000 { + bail!("Identity is too large to transfer"); + } + let mut output = vec![0u8; 65535]; + let len = self.state.write_message(plaintext, &mut output)?; + Ok(hex::encode(&output[..len])) + } + pub fn decrypt(&mut self, ciphertext: &str) -> Result> { + if ciphertext.len() > 131070 { + bail!("Oversized pairing payload"); + } + let mut output = vec![0u8; 65535]; + let len = self + .state + .read_message(&hex::decode(ciphertext)?, &mut output)?; + output.truncate(len); + Ok(output) + } +} +#[cfg(test)] +mod tests { + use super::*; + fn handshake(id: &str) -> (Channel, Channel) { + let mut a = Handshake::new(id, true).unwrap(); + let mut b = Handshake::new(id, false).unwrap(); + b.read(&a.write().unwrap()).unwrap(); + a.read(&b.write().unwrap()).unwrap(); + b.read(&a.write().unwrap()).unwrap(); + (a.finish().unwrap(), b.finish().unwrap()) + } + #[test] + fn authenticated_transport_and_peer_substitution() { + let (mut a, mut b) = handshake("session-one"); + assert_eq!(a.code(), b.code()); + let (c, _) = handshake("session-one"); + assert_ne!(a.code(), c.code()); + let ciphertext = a.encrypt(b"synthetic identity").unwrap(); + assert!(!ciphertext.contains("synthetic identity")); + assert_eq!(b.decrypt(&ciphertext).unwrap(), b"synthetic identity"); + assert!(b.decrypt(&ciphertext).is_err()); + let mut wrong = Handshake::new("other-session", false).unwrap(); + let mut source = Handshake::new("session", true).unwrap(); + wrong.read(&source.write().unwrap()).unwrap(); + assert!(source.read(&wrong.write().unwrap()).is_err()); + } + #[test] + fn modified_ciphertext_fails_closed() { + let (mut a, mut b) = handshake("session"); + let encrypted = a.encrypt(b"synthetic identity").unwrap(); + let mut bytes = hex::decode(encrypted).unwrap(); + bytes[0] ^= 1; + assert!(b.decrypt(&hex::encode(bytes)).is_err()); + } +} diff --git a/src/commands/auth/identity.rs b/src/commands/auth/identity.rs new file mode 100644 index 0000000..070ccbf --- /dev/null +++ b/src/commands/auth/identity.rs @@ -0,0 +1,262 @@ +use crate::utils::{config::Config, key::Key, rpgp::get_vault_location}; +use anyhow::{bail, Context, Result}; +use pgp::{ + composed::{ + ArmorOptions, Deserializable, MessageBuilder, SignedPublicKey, + SignedSecretKey, + }, + crypto::hash::HashAlgorithm, + types::KeyDetails, +}; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Write}; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Bundle { + version: u8, + user_id: String, + fingerprint: String, + secret_key: String, +} +impl Bundle { + pub fn from_key(key: &Key) -> Result { + let bundle = Self { + version: 1, + user_id: key.uuid.clone().context("Identity is not registered")?, + fingerprint: key.fingerprint.clone(), + secret_key: key.secret_key_str()?, + }; + bundle.parse()?; + Ok(bundle) + } + fn parse(&self) -> Result { + if self.version != 1 || self.secret_key.len() > 60000 { + bail!("Unsupported identity bundle"); + } + uuid::Uuid::parse_str(&self.user_id).context("Invalid account ID")?; + let (secret, _) = SignedSecretKey::from_string(&self.secret_key) + .context("Invalid transferred identity")?; + secret.verify().context("Invalid identity self-signature")?; + if hex::encode(secret.fingerprint().as_bytes()) != self.fingerprint { + bail!("Identity fingerprint mismatch"); + } + if !secret.primary_key.secret_params().is_encrypted() + || secret + .secret_subkeys + .iter() + .any(|s| !s.key.secret_params().is_encrypted()) + { + bail!("Only passphrase-protected identities can be transferred"); + } + Ok(secret) + } + pub fn validate(&self, password: &str) -> Result<(Key, String, String)> { + crate::utils::key::validate_passphrase_not_empty(password)?; + let secret = self.parse()?; + secret + .primary_key + .unlock(&password.into(), |_, _| Ok(()))? + .context("Passphrase does not unlock transferred key")?; + for sub in &secret.secret_subkeys { + sub.key + .unlock(&password.into(), |_, _| Ok(()))? + .context("Passphrase does not unlock transferred subkey")?; + } + let public = SignedPublicKey::from(secret.clone()); + let public_armor = public.to_armored_string(ArmorOptions::default())?; + let mut builder = + MessageBuilder::from_bytes("", chrono::Utc::now().to_string()); + builder.sign( + &secret.primary_key, + password.into(), + HashAlgorithm::Sha3_512, + ); + let signature = builder + .to_armored_string(rand::rngs::OsRng, ArmorOptions::default())?; + let token = crate::utils::auth_token::AuthToken::new( + self.user_id.clone(), + signature, + ) + .to_string(); + let user = public + .details + .users + .first() + .context("Identity has no user ID")?; + let key = Key { + fingerprint: self.fingerprint.clone(), + uuid: Some(self.user_id.clone()), + note: "Primary Key".into(), + primary_user_id: String::from_utf8_lossy(user.id.id()).into_owned(), + pubkey_only: Some(false), + }; + Ok((key, public_armor, token)) + } + pub fn install( + &self, + config: &mut Config, + server: &str, + key: Key, + public: &str, + ) -> Result<()> { + let vault = get_vault_location()?; + fs::create_dir_all(&vault)?; + let destination = vault.join(&key.fingerprint); + if destination.exists() { + // Recover a prior publication whose final settings write failed. + if fs::read_to_string(destination.join("private.key"))? + == self.secret_key + && fs::read_to_string(destination.join("public.key"))? == public + { + return config.install_identity(key, server); + } + bail!("Different key files already exist; login will not overwrite them"); + } + let temporary = + vault.join(format!(".pairing-{}", uuid::Uuid::new_v4())); + let mut directory = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directory.mode(0o700); + } + directory.create(&temporary)?; + let result = (|| -> Result<()> { + for (name, data) in [ + ("private.key", self.secret_key.as_str()), + ("public.key", public), + ] { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(temporary.join(name))?; + file.write_all(data.as_bytes())?; + file.sync_all()?; + } + fs::rename(&temporary, &destination)?; + // Publish settings last. A failed settings write leaves recoverable encrypted files. + config.install_identity(key, server)?; + Ok(()) + })(); + if temporary.exists() { + let _ = fs::remove_dir_all(&temporary); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::Bundle; + use pgp::{ + composed::{ArmorOptions, KeyType, SecretKeyParamsBuilder}, + types::KeyDetails, + }; + #[test] + fn bundle_requires_matching_protected_key_and_correct_password() { + let secret = SecretKeyParamsBuilder::default() + .key_type(KeyType::Ed25519) + .can_sign(true) + .primary_user_id("synthetic".into()) + .passphrase(Some("fixture-passphrase".into())) + .build() + .unwrap() + .generate(rand::rngs::OsRng) + .unwrap() + .sign(rand::rngs::OsRng, &"fixture-passphrase".into()) + .unwrap(); + let mut bundle = Bundle { + version: 1, + user_id: uuid::Uuid::new_v4().to_string(), + fingerprint: hex::encode(secret.fingerprint().as_bytes()), + secret_key: secret + .to_armored_string(ArmorOptions::default()) + .unwrap(), + }; + assert!(bundle.validate("fixture-passphrase").is_ok()); + assert!(bundle.validate("wrong-password").is_err()); + bundle.fingerprint = "../../escape".into(); + assert!(bundle.validate("fixture-passphrase").is_err()); + } + use crate::utils::{config::Config, key::Key}; + #[test] + fn interrupted_install_can_resume_only_with_identical_key_files() { + let mut config = Config::load().unwrap(); + let key = Key { + fingerprint: "c".repeat(40), + note: "test".into(), + primary_user_id: "test".into(), + pubkey_only: Some(false), + uuid: Some(uuid::Uuid::new_v4().to_string()), + }; + let directory = crate::utils::rpgp::get_vault_location() + .unwrap() + .join(&key.fingerprint); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("private.key"), "protected-fixture") + .unwrap(); + std::fs::write(directory.join("public.key"), "public-fixture").unwrap(); + let mut bundle = Bundle { + version: 1, + user_id: key.uuid.clone().unwrap(), + fingerprint: key.fingerprint.clone(), + secret_key: "different-fixture".into(), + }; + assert!(bundle + .install( + &mut config, + "https://api.envx.sh/", + key.clone(), + "public-fixture" + ) + .is_err()); + assert!(Config::load().unwrap().primary_key.is_none()); + bundle.secret_key = "protected-fixture".into(); + bundle + .install( + &mut config, + "https://api.envx.sh/", + key.clone(), + "public-fixture", + ) + .unwrap(); + assert_eq!( + Config::load().unwrap().primary_key.unwrap().fingerprint, + key.fingerprint + ); + assert_eq!( + std::fs::read_to_string(directory.join("private.key")).unwrap(), + "protected-fixture" + ); + } + #[test] + fn concurrent_identity_install_never_replaces_an_existing_account() { + let mut first = Config::load().unwrap(); + let mut stale = Config::load().unwrap(); + let key = Key { + fingerprint: "a".repeat(40), + note: "test".into(), + primary_user_id: "test".into(), + pubkey_only: Some(false), + uuid: Some(uuid::Uuid::new_v4().to_string()), + }; + first + .install_identity(key.clone(), "https://api.envx.sh/") + .unwrap(); + let mut other = key.clone(); + other.fingerprint = "b".repeat(40); + assert!(stale + .install_identity(other, "https://api.envx.sh/") + .is_err()); + assert_eq!( + Config::load().unwrap().primary_key.unwrap().fingerprint, + key.fingerprint + ); + assert!(stale.primary_key.is_none()); + } +} diff --git a/src/commands/auth/mod.rs b/src/commands/auth/mod.rs new file mode 100644 index 0000000..56163c0 --- /dev/null +++ b/src/commands/auth/mod.rs @@ -0,0 +1,61 @@ +use super::*; +mod channel; +mod identity; +mod pairing; +mod status; +#[cfg(test)] +mod tests; + +/// Manage your identity and securely pair another machine +#[derive(Parser)] +pub struct Args { + #[command(subcommand)] + command: Option, + /// Show diagnostics for the legacy authentication check + #[arg(long)] + verbose: bool, + #[arg(short, long)] + debug: bool, +} +#[derive(clap::Subcommand)] +enum Commands { + /// Verify authentication with the server + Status(status::Args), + /// Generate a new identity + Gen(super::gen::Args), + /// Register an existing local key + Register(super::upload::Args), + /// Export a public or secret identity key + Export(super::export::Args), + /// Create a pairing invitation and approve the receiving machine + Link, + /// Receive your existing identity from another machine + Login { link: String }, +} +pub async fn command( + args: Args, + config: &mut crate::utils::config::Config, +) -> anyhow::Result<()> { + match args.command { + Some(Commands::Link) => pairing::link(config).await, + Some(Commands::Login { link }) => pairing::login(config, &link).await, + Some(Commands::Gen(args)) => super::gen::command(args, config).await, + Some(Commands::Register(args)) => { + super::upload::command(args, config).await + } + Some(Commands::Export(args)) => { + super::export::command(args, config).await + } + Some(Commands::Status(args)) => status::command(args, config).await, + None => { + status::command( + status::Args { + verbose: args.verbose, + debug: args.debug, + }, + config, + ) + .await + } + } +} diff --git a/src/commands/auth/pairing.rs b/src/commands/auth/pairing.rs new file mode 100644 index 0000000..5601b22 --- /dev/null +++ b/src/commands/auth/pairing.rs @@ -0,0 +1,370 @@ +use super::{channel::Handshake, identity::Bundle}; +use crate::utils::{ + config::Config, key::UnlockedKey, prompt::require_interactive, +}; +use anyhow::{bail, Context, Result}; +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::json; +use std::time::Duration; +use url::Url; +use uuid::Uuid; + +const PREFIX: &str = "envx-pair-v1="; +#[derive(Deserialize)] +struct Created { + id: String, +} +#[derive(Deserialize)] +struct Reply { + phase: u8, + message: Option, +} +struct Relay<'a> { + http: reqwest::Client, + base: Url, + id: String, + token: Option, + source_key: Option<&'a UnlockedKey>, + auth: std::sync::Mutex>, +} +fn validate_server(url: &Url) -> Result<()> { + let loopback = + matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if !(url.scheme() == "https" || (url.scheme() == "http" && loopback)) + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + { + bail!("Pairing requires HTTPS (HTTP is allowed only on loopback)"); + } + Ok(()) +} +fn parse_link(link: &str) -> Result<(Url, String)> { + let mut base = Url::parse(link).context("Invalid pairing link")?; + validate_server(&base)?; + let id = base + .fragment() + .and_then(|f| f.strip_prefix(PREFIX)) + .context("Not an envx pairing link")?; + let id = Uuid::parse_str(id) + .context("Invalid pairing identifier")? + .to_string(); + base.set_fragment(None); + Ok((base, id)) +} +impl<'a> Relay<'a> { + fn new( + base: Url, + id: String, + token: Option, + source_key: Option<&'a UnlockedKey>, + ) -> Result { + validate_server(&base)?; + Ok(Self { + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build()?, + base, + id, + token, + source_key, + auth: std::sync::Mutex::new(None), + }) + } + async fn post( + &self, + path: &str, + body: serde_json::Value, + ) -> Result { + let mut request = self + .http + .post(format!( + "{}/{}", + self.base.as_str().trim_end_matches('/'), + path + )) + .json(&body); + if let Some(key) = self.source_key { + let mut cached = self.auth.lock().map_err(|_| { + anyhow::anyhow!("Pairing authentication lock failed") + })?; + let critical = matches!( + body.get("action").and_then(|v| v.as_str()), + Some("payload" | "cancel") + ); + if critical + || cached.as_ref().is_none_or(|(created, _)| { + created.elapsed() >= Duration::from_secs(60) + }) + { + *cached = Some(( + std::time::Instant::now(), + key.auth_token()?.to_string(), + )); + } + request = request.bearer_auth( + &cached.as_ref().context("Missing pairing authentication")?.1, + ); + } + let mut response = request + .send() + .await + .context("Pairing server could not be reached")?; + if !response.status().is_success() { + bail!("Pairing request failed ({}). The pairing may have expired, been cancelled, or already been claimed.",response.status()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if bytes.len() + chunk.len() > 150000 { + bail!("Oversized pairing response"); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes) + .context("Invalid pairing server response") + } + async fn exchange( + &self, + action: &str, + message: Option, + ) -> Result { + self.post( + &format!( + "v2/auth/pairing/{}/{}", + self.id, + if self.source_key.is_some() { + "source" + } else { + "receiver" + } + ), + json!({"action":action,"message":message,"token":self.token}), + ) + .await + } + async fn wait(&self, phase: u8) -> Result { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + loop { + tick.tick().await; + let reply = self.exchange("poll", None).await?; + if reply.phase == phase { + return reply.message.context("Missing pairing message"); + } + if reply.phase > phase { + bail!("Unexpected pairing state"); + } + } + } + async fn bounded( + &self, + work: impl std::future::Future>, + ) -> Result { + let result = tokio::select! { + result=tokio::time::timeout(Duration::from_secs(600),work)=>result.context("Pairing expired; start again").and_then(|result| result), + _=tokio::signal::ctrl_c()=>Err(anyhow::anyhow!("Pairing cancelled")), + }; + if result.is_err() { + let _ = self.exchange("cancel", None).await; + } + result + } +} +pub async fn link(config: &mut Config) -> Result<()> { + require_interactive( + "Pairing requires verification on this terminal.", + "Run `envx auth link` in an interactive terminal.", + )?; + let key = config.unlocked_primary_key()?; + key.key.verify_passphrase(&key.password)?; + // Reject unsuitable identities before issuing a link, without exposing their contents. + Bundle::from_key(&key.key)?; + let mut relay = + Relay::new(config.sdk_url()?, String::new(), None, Some(&key))?; + let created: Created = relay.post("v2/auth/pairing/new", json!({})).await?; + relay.id = Uuid::parse_str(&created.id)?.to_string(); + let mut link = relay.base.clone(); + link.set_fragment(Some(&format!("{PREFIX}{}", relay.id))); + println!("On your new machine, run:\n\nenvx auth login '{link}'\n\nKeep this terminal open. This invitation expires in 10 minutes."); + relay.bounded(send(&relay, &key)).await +} +async fn send(relay: &Relay<'_>, key: &UnlockedKey) -> Result<()> { + let mut handshake = + Handshake::new(&format!("{}:{}", relay.base, relay.id), false)?; + handshake.read(&relay.wait(1).await?)?; + relay + .exchange("handshake", Some(handshake.write()?)) + .await?; + handshake.read(&relay.wait(3).await?)?; + let mut channel = handshake.finish()?; + println!("A machine requested your identity. Only approve a terminal you control.\nEnter the verification code shown by `envx auth login` on that machine."); + let entered = + input("Receiving terminal's verification code", false).await?; + if entered.trim() != channel.code() { + bail!("Verification code does not match. No identity was sent."); + } + let bundle = serde_json::to_vec(&Bundle::from_key(&key.key)?)?; + relay + .exchange("payload", Some(channel.encrypt(&bundle)?)) + .await?; + println!("Encrypted identity sent to the verified machine. Finish login there using your existing passphrase."); + Ok(()) +} +pub async fn login(config: &mut Config, link: &str) -> Result<()> { + require_interactive( + "Login requires an interactive terminal.", + "Run `envx auth login ` in an interactive terminal.", + )?; + if config.primary_key.is_some() { + bail!("An identity already exists; login will not replace it"); + } + let (base, id) = parse_link(link)?; + let token = hex::encode(rand::random::<[u8; 32]>()); + let relay = Relay::new(base, id, Some(token), None)?; + let received = relay.bounded(receive(&relay)).await?; + // Installation is a local commit, outside the cancellable transfer window. + if let Err(error) = received.bundle.install( + config, + relay.base.as_str(), + received.key.clone(), + &received.public, + ) { + let _ = relay.exchange("cancel", None).await; + return Err(error); + } + if crate::utils::keyring::set_password( + &received.key.fingerprint, + &received.password, + config.get_settings().get_keyring_expiry(), + ) + .is_err() + { + eprintln!("Identity installed. Your system keyring could not cache its passphrase; envx will ask when needed."); + } + println!("Logged in successfully. Your existing projects are available; use `envx link` to connect this directory."); + if relay.exchange("ack", None).await.is_err() { + eprintln!("Identity installed; relay cleanup could not be confirmed. The encrypted transfer expires automatically."); + } + Ok(()) +} +struct Received { + bundle: Bundle, + key: crate::utils::key::Key, + public: String, + password: String, +} +async fn receive(relay: &Relay<'_>) -> Result { + let mut handshake = + Handshake::new(&format!("{}:{}", relay.base, relay.id), true)?; + relay.exchange("claim", Some(handshake.write()?)).await?; + handshake.read(&relay.wait(2).await?)?; + relay + .exchange("handshake", Some(handshake.write()?)) + .await?; + let mut channel = handshake.finish()?; + println!("Verification code:\n{}\n\nEnter this code in your original machine's waiting terminal.\nWaiting for that machine to approve...",channel.code()); + let ciphertext = relay.wait(4).await?; + let bundle: Bundle = serde_json::from_slice(&channel.decrypt(&ciphertext)?) + .context("Invalid identity bundle")?; + let password = input("Existing identity passphrase", true).await?; + let (key, public, auth) = bundle.validate(&password)?; + let response = relay + .http + .post(format!( + "{}/test-auth", + relay.base.as_str().trim_end_matches('/') + )) + .bearer_auth(auth) + .send() + .await?; + if !response.status().is_success() { + bail!("Transferred identity could not authenticate; nothing was installed"); + } + Ok(Received { + bundle, + key, + public, + password, + }) +} + +// Poll in bounded blocking tasks so cancellation never leaves a prompt thread +// keeping the process alive. The guard restores the terminal on every exit. +async fn input(label: &str, secret: bool) -> Result { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + use std::io::Write; + struct RestoreTerminal; + impl Drop for RestoreTerminal { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + eprintln!(); + } + } + eprint!("{label}: "); + std::io::stderr().flush()?; + crossterm::terminal::enable_raw_mode()?; + let _restore = RestoreTerminal; + let mut value = String::new(); + loop { + let event = tokio::task::spawn_blocking( + || -> std::io::Result> { + if event::poll(Duration::from_millis(100))? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } + }, + ) + .await??; + if let Some(Event::Key(key)) = event { + if key.kind == event::KeyEventKind::Release { + continue; + } + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c' | 'd')) + { + bail!("Pairing cancelled"); + } + match key.code { + KeyCode::Enter => return Ok(value), + KeyCode::Esc => bail!("Pairing cancelled"), + KeyCode::Backspace => { + if value.pop().is_some() && !secret { + eprint!("\x08 \x08"); + } + } + KeyCode::Char(c) if !c.is_control() && value.len() < 4096 => { + value.push(c); + if !secret { + eprint!("{c}"); + } + } + _ => {} + } + std::io::stderr().flush()?; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn invitation_contains_only_session_and_safe_server() { + let id = Uuid::new_v4(); + let link = format!("https://api.envx.sh/#{PREFIX}{id}"); + let (base, session) = parse_link(&link).unwrap(); + assert_eq!(base.as_str(), "https://api.envx.sh/"); + assert_eq!(session, id.to_string()); + for bad in [ + format!("http://example.com/#{PREFIX}{id}"), + format!("https://user:pass@example.com/#{PREFIX}{id}"), + format!("https://api.envx.sh/?secret=x#{PREFIX}{id}"), + "https://api.envx.sh/#key-material".into(), + ] { + assert!(parse_link(&bad).is_err()); + } + } +} diff --git a/src/commands/auth.rs b/src/commands/auth/status.rs similarity index 95% rename from src/commands/auth.rs rename to src/commands/auth/status.rs index e270820..23f9c6c 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth/status.rs @@ -8,11 +8,11 @@ use reqwest::header; pub struct Args { /// Show server response and request diagnostics #[arg(long)] - verbose: bool, + pub(super) verbose: bool, /// Debug output (alias for --verbose) #[arg(short, long)] - debug: bool, + pub(super) debug: bool, } pub async fn command(args: Args, config: &mut Config) -> anyhow::Result<()> { diff --git a/src/commands/auth/tests.rs b/src/commands/auth/tests.rs new file mode 100644 index 0000000..8af742a --- /dev/null +++ b/src/commands/auth/tests.rs @@ -0,0 +1,7 @@ +use clap::Parser; +#[test] +fn auth_namespace_and_legacy_status_parse() { + for args in [vec!["envx","auth"], vec!["envx","auth","--verbose"],vec!["envx","auth","status"],vec!["envx","auth","link"],vec!["envx","auth","login","https://api.envx.sh/#envx-pair-v1=00000000-0000-4000-8000-000000000000"],vec!["envx","auth","gen","--no-upload"]] { + assert!(crate::Args::try_parse_from(args.clone()).is_ok(),"{args:?}"); + } +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 541318f..a32ae1d 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -178,6 +178,28 @@ impl Config { } let mut store = super::state::StateStore::open(self)?; let _guard = store.lock_settings()?; + self.write_locked() + } + + /// Compare against current disk state under the same lock used by settings writes. + pub fn install_identity(&mut self, key: Key, server: &str) -> Result<()> { + let mut store = super::state::StateStore::open(self)?; + let _guard = store.lock_settings()?; + let mut current = Self::load_settings()?; + if current.primary_key.is_some() { + bail!("An identity already exists; login will not replace it"); + } + current.primary_key = Some(key); + current.sdk_url = Some(server.to_owned()); + current.primary_key_password = None; + current.primary_key_command = None; + current.write_locked()?; + current.projects = Vec::new(); + *self = current; + Ok(()) + } + + fn write_locked(&mut self) -> Result<()> { let path = get_config_file_path().context("Failed to get config path")?; diff --git a/tests/auth_pairing_pty.py b/tests/auth_pairing_pty.py new file mode 100644 index 0000000..dd2d91d --- /dev/null +++ b/tests/auth_pairing_pty.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Real two-terminal auth pairing against a disposable API; never use a real profile.""" +import datetime, fcntl, json, os, pty, re, select, signal, sqlite3, struct, subprocess, tempfile, termios, time +import http.client, http.server, threading, urllib.parse +from pathlib import Path +assert Path('/.dockerenv').exists(), 'Run only inside Docker' +root=Path(tempfile.mkdtemp(prefix='envx-auth-pairing-')) +binary=os.environ.get('ENVX_TEST_BIN','/opt/envx') +server=os.environ.get('ENVX_TEST_API','http://127.0.0.1:55440') +password='synthetic-pairing-passphrase' +upstream=urllib.parse.urlparse(server) +wire=[] +class RelayRecorder(http.server.BaseHTTPRequestHandler): + def do_POST(self): + body=self.rfile.read(int(self.headers.get('Content-Length',0))) + conn=http.client.HTTPConnection(upstream.hostname,upstream.port,timeout=20) + conn.request('POST',self.path,body,dict(self.headers)) + response=conn.getresponse();payload=response.read();conn.close() + if '/auth/pairing/' in self.path: wire.append((self.path,body,payload)) + self.send_response(response.status);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(payload) + def do_GET(self): + conn=http.client.HTTPConnection(upstream.hostname,upstream.port,timeout=20) + conn.request('GET',self.path,headers=dict(self.headers));response=conn.getresponse();payload=response.read();conn.close() + self.send_response(response.status);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(payload) + def log_message(self,*args): pass +proxy=http.server.ThreadingHTTPServer(('127.0.0.1',0),RelayRecorder) +threading.Thread(target=proxy.serve_forever,daemon=True).start() +server=f'http://127.0.0.1:{proxy.server_port}' + +def env(user): return {**os.environ,'HOME':str(root/user),'USERPROFILE':str(root/user),'TERM':'xterm-256color'} +def run(user,*args,data=None,ok=True): + result=subprocess.run([binary,*args],env=env(user),cwd=root/user,input=data,text=True,capture_output=True) + if ok: assert result.returncode==0,(args[0],result.stderr) + else: assert result.returncode!=0 + return result.stdout + +def config(user): return root/user/'.config/envx/config.json' +def setup(user): + (root/user).mkdir() + run(user,'config','get','sdk_url') + path=config(user); value=json.loads(path.read_text());value['sdk_url']=server;path.write_text(json.dumps(value)) + run(user,'config','get','sdk_url') + run(user,'whoami',ok=False) + db=sqlite3.connect(root/user/'.config/envx/state.sqlite') + state=json.dumps({'last_update_check':datetime.datetime.now(datetime.timezone.utc).isoformat(),'latest_version':None}).encode() + db.execute("INSERT OR REPLACE INTO records VALUES('global','updates','latest',?)",(state,));db.commit();db.close() +class Terminal: + def __init__(self,user,*args): + self.pid,self.fd=pty.fork() + if self.pid==0: + os.chdir(root/user);os.execve(binary,[binary,*args],env(user)) + fcntl.ioctl(self.fd,termios.TIOCSWINSZ,struct.pack('HHHH',40,180,0,0)) + self.output=b'';self.status=None + def pump(self): + if self.status is not None:return + ready,_,_=select.select([self.fd],[],[],0.1) + if ready: + try: chunk=os.read(self.fd,65536) + except OSError: chunk=b'' + self.output+=chunk + if b'\x1b[6n' in chunk: os.write(self.fd,b'\x1b[1;1R') + pid,status=os.waitpid(self.pid,os.WNOHANG) + if pid:self.status=status + def until(self,text,timeout=35): + deadline=time.monotonic()+timeout + while text.encode() not in self.output: + self.pump() + assert self.status is None,('exited',text,self.output.decode(errors='replace')) + assert time.monotonic()deadline: + os.kill(self.pid,signal.SIGKILL);raise AssertionError(('hung terminal',self.output.decode(errors='replace'))) + os.close(self.fd) + return os.waitstatus_to_exitcode(self.status) + +def pair(target): + source=Terminal('source','auth','link');source.until('Keep this terminal open') + link=re.search(rb"envx auth login '([^']+)'",source.output).group(1).decode() + assert re.fullmatch(re.escape(server)+r'/#envx-pair-v1=[a-f0-9-]{36}',link) + receiver=Terminal(target,'auth','login',link);receiver.until('Waiting for that machine to approve') + source.until("Receiving terminal's verification code") + code=re.search(rb'Verification code:\r?\n([a-f0-9]{64})',receiver.output).group(1).decode() + return source,receiver,code + +for user in ('source','receiver','rejected','wrongpass','cancelled','cancelpass'):setup(user) +run('source','auth','gen','--username','pairing-test','--passphrase',password,'--json') +v=json.loads(config('source').read_text());v['primary_key_password']=password;config('source').write_text(json.dumps(v)) +project=json.loads(run('source','project','new','--name','pairing-secret','--no-link','--json'))['project_id'] +run('source','set','--project-id',project,'--yes',data='PAIRING_TEST=synthetic-project-secret\n') +source,receiver,code=pair('receiver') +source.send(code);assert source.finish()==0 +receiver.until('Existing identity passphrase');receiver.send(password);assert receiver.finish()==0,receiver.output.decode(errors='replace') +assert password.encode() not in receiver.output +assert json.loads(config('source').read_text())['primary_key']['uuid']==json.loads(config('receiver').read_text())['primary_key']['uuid'] +v=json.loads(config('receiver').read_text());assert v.get('primary_key_password') is None +v['primary_key_password']=password;config('receiver').write_text(json.dumps(v)) +assert json.loads(run('receiver','variables','--project-id',project,'--all','--json'))=={'PAIRING_TEST':'synthetic-project-secret'} +run('receiver','auth','status') +source,receiver,code=pair('rejected');source.send('0'*64) +assert source.finish()!=0 +assert receiver.finish()!=0 +assert json.loads(config('rejected').read_text()).get('primary_key') is None +assert not list((root/'rejected/.config/envx/keys').glob('*/private.key')) +source,receiver,code=pair('wrongpass');source.send(code);assert source.finish()==0 +receiver.until('Existing identity passphrase');receiver.send('wrong-passphrase') +assert receiver.finish()!=0 +assert json.loads(config('wrongpass').read_text()).get('primary_key') is None +assert not list((root/'wrongpass/.config/envx/keys').glob('*/private.key')) +existing=Terminal('receiver','auth','login',server+'/#envx-pair-v1=00000000-0000-4000-8000-000000000000') +assert existing.finish()!=0;assert b'will not replace' in existing.output +source,receiver,code=pair('cancelled');os.write(source.fd,b'\x03') +assert source.finish()!=0;assert receiver.finish()!=0 +assert json.loads(config('cancelled').read_text()).get('primary_key') is None +source,receiver,code=pair('cancelpass');source.send(code);assert source.finish()==0 +receiver.until('Existing identity passphrase');os.write(receiver.fd,b'\x03');assert receiver.finish()!=0 +assert json.loads(config('cancelpass').read_text()).get('primary_key') is None +for path,request,response in wire: + for content in (request,response): + assert password.encode() not in content + assert b'PRIVATE KEY' not in content + assert b'secret_key' not in content + assert b'fingerprint' not in content + try: data=json.loads(content) + except json.JSONDecodeError: continue + if isinstance(data,dict) and data.get('message'): + raw=bytes.fromhex(data['message']) + assert b'PRIVATE KEY' not in raw and password.encode() not in raw +print('PASS: real pairing; invitation contains only session ID; matching identity authenticates and decrypts existing project; passphrase not persisted in config or echoed; wrong verification/passphrase install nothing; existing identity preserved; cancellation restores control and installs nothing; wire contains no identity/passphrase plaintext')