Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<link>'` 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.
69 changes: 69 additions & 0 deletions docs/auth-pairing.md
Original file line number Diff line number Diff line change
@@ -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/.
108 changes: 108 additions & 0 deletions src/commands/auth/channel.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<String> {
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<Channel> {
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<String> {
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<Vec<u8>> {
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());
}
}
Loading
Loading