diff --git a/.bumpy/local-encrypt-identity-layer.md b/.bumpy/local-encrypt-identity-layer.md new file mode 100644 index 000000000..b29df7ac6 --- /dev/null +++ b/.bumpy/local-encrypt-identity-layer.md @@ -0,0 +1,17 @@ +--- +varlock: minor +--- + +Locally encrypted values now unlock once per session instead of re-prompting every five minutes. Approving an unlock covers every value it names until the session ends, and you choose what ends it: screen lock, sleep (the default), or only an explicit lock. The approval panel is drawn by the encryption daemon, so it can show you which process is actually asking and which keys it wants, before anything is decrypted. + +On macOS that panel says what the unlock actually covers: each key with how many values it opens, expanding to everything that key protects. Env files are listed with the value names each one defined, and varlock's value cache is listed alongside them with how many cached values it holds and which plugins and files filled it, since one approval on a key opens all of it. What it lists is the whole of what that approval will open, worked out before the panel appears, so it reads the same however the run happens to reach its first encrypted value. The panel also shows the line of processes leading to whoever is asking, from the app you launched down to the command that ran. A request from a coding-agent session is shown as coming from that session, by name and start time, with everything running inside it marked, and the panel says so when nobody is watching that agent or when it is working outside the project being unlocked. The panel is also explicit about which varlock is asking: the standalone binary, or varlock's JavaScript running under node or bun, which is a different thing with different guarantees. Marks on each step say what was checked and what was not, and hovering one spells it out. + +The panel now asks two questions rather than one. Under how long an approval lasts sits one checkbox, "Auto-unlock all items in this vault", ticked by default. Unticking it holds the approval to exactly the values on the panel, and that is enforced by the daemon: it binds the approval to the encrypted values it was shown, and anything else raises a fresh panel instead of being handed over quietly. While the box is ticked the panel says so plainly, describing the grant as covering the vault and the listed values as what it covers right now, so nothing opens later that the panel implied it would not. Choosing "once" hides the checkbox and grants narrow, since "just this, right now" is what the word already means; it is an answer about time, so it never changes what varlock remembers about breadth. A broad approval still stops at the vaults it was shown. The value cache is never narrowed by the checkbox and the panel says so: its entries are rewritten whenever a cached value is renewed, so it is always covered as a whole. + +How long an approval lasts is one row, read from least to most permissive: once, 10 minutes, 1 hour, a duration you set, and this session. Picking the custom rung reveals a field and a minutes/hours toggle, bounded by the same 12 hour cap as everything else. The rung keeps its name whatever number you set, since it sits at a fixed place in an ordered row; the number itself is in the field, which is on screen whenever that rung is selected, and in the sentence under the controls. A custom window shorter than the default is a tightening like any other, so it is remembered and preselected the next time you are asked. + +Which option is preselected follows the request. A key you have approved before, in its own project, with a person at the keyboard, opens on the broad default. Anything less ordinary starts narrower: a first approval of a key, an agent session, or somebody else's script driving varlock; and narrower still when nobody is watching the session, when it is working outside the project it is unlocking, or when the code asking has no signature macOS accepts. If you tighten an approval, varlock remembers that and preselects it next time, saying on the panel that it did. It only ever remembers tightening, never widening, so ticking the box again forgets it. `varlock lock --forget-preferences` clears what is remembered for the current project, and `--forget-all-preferences` clears the lot. + +New commands to see and manage that: `varlock sessions` lists what is currently unlocked, and `varlock lock` now takes `--current` to end just this terminal's session or `--session ` to end one you name. On macOS the menu bar shows the same sessions and can lock them individually. + +Existing encrypted values keep working with no action required. To move them onto the new model, run `varlock encrypt --upgrade` (try `--dry-run` first to see what would change). Their panel is also honest now about what approving them buys: macOS reuses one scan for up to five minutes on that path, which the panel used to describe as a single read. diff --git a/eslint.config.mjs b/eslint.config.mjs index f0bb8f985..31c01b92d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,12 @@ export default tseslint.config( '**/dist-test', '**/node_modules', '**/.turbo', + // SwiftPM build output. Generated JSON, and anyone who builds the Swift + // package would otherwise get a red lint they did not cause. + 'packages/encryption-binary-swift/swift/.build', + // Cargo build output, for the same reason: it is full of generated JSON, + // and running `cargo test` should not turn the lint red. + 'packages/encryption-binary-rust/target', 'packages/eslint-custom-rules', 'packages/env-spec-parser/src/grammar.js', 'packages/varlock-website/.astro', diff --git a/packages/encryption-binary-rust/Cargo.lock b/packages/encryption-binary-rust/Cargo.lock index fbeed0970..3a185451f 100644 --- a/packages/encryption-binary-rust/Cargo.lock +++ b/packages/encryption-binary-rust/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "serde_json", "sha2", "windows", + "zbus", "zeroize", ] diff --git a/packages/encryption-binary-rust/Cargo.toml b/packages/encryption-binary-rust/Cargo.toml index 0799f8fb3..0b65a9cea 100644 --- a/packages/encryption-binary-rust/Cargo.toml +++ b/packages/encryption-binary-rust/Cargo.toml @@ -30,10 +30,14 @@ zeroize = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" -# Platform — Linux (nix for peer credentials in IPC, secret-service for keyring storage) +# Platform — Linux (nix for peer credentials in IPC, secret-service for keyring +# storage, zbus for the logind sleep/lock signals that end unlock sessions). +# zbus is already in the tree underneath secret-service; naming it here pins the +# same version rather than adding to the dependency set. [target.'cfg(target_os = "linux")'.dependencies] nix = { version = "0.29", features = ["process", "socket", "user", "fs"] } secret-service = { version = "4", default-features = false, features = ["rt-async-io-crypto-rust"] } +zbus = "4" # Platform — Windows [target.'cfg(target_os = "windows")'.dependencies] @@ -51,6 +55,17 @@ windows = { version = "0.58", features = [ "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", + # Peer session scoping: parent process walk + process creation times + "Win32_System_Diagnostics_ToolHelp", + # Sleep-inclusive monotonic clock for grant deadlines (QueryInterruptTime) + "Win32_System_WindowsProgramming", + # Lock events: suspend notifications, and workstation lock via a + # message-only window + "Win32_System_Power", + "Win32_System_RemoteDesktop", + "Win32_System_LibraryLoader", + # WNDCLASSW carries GDI handle fields, so the window class needs this too + "Win32_Graphics_Gdi", # Windows Hello (UserConsentVerifier) "Security_Credentials_UI", "Foundation", diff --git a/packages/encryption-binary-rust/README.md b/packages/encryption-binary-rust/README.md index 8c5da17a3..2665b0a78 100644 --- a/packages/encryption-binary-rust/README.md +++ b/packages/encryption-binary-rust/README.md @@ -43,17 +43,75 @@ Binaries ship uncompressed. Do not reintroduce UPX (or any other executable pack ## Architecture -- `src/main.rs` — CLI interface (generate-key, encrypt, decrypt, status, daemon) -- `src/crypto.rs` — ECIES encryption using pure Rust crates (no OpenSSL) -- `src/key_store/` — Platform-specific key protection: - - `windows_tpm.rs` — NCrypt TPM seal (Platform Crypto Provider) - - `windows.rs` — DPAPI fallback - - `windows_hello.rs` — Windows Hello presence gate (daemon) - - `linux.rs` — TPM2 via tpm2-tools - - `scalar.rs` — shared P-256 scalar ↔ PKCS8 helpers -- `src/daemon.rs` — Long-lived IPC daemon for biometric session caching -- `src/ipc.rs` — IPC server (Unix socket on Linux, named pipe on Windows) -- `src/daemon_client.rs` — Named pipe client for `--via-daemon` mode (WSL2 support) +- `src/main.rs`: CLI interface (generate-key, encrypt, decrypt, status, daemon) +- `src/crypto.rs`: ECIES encryption using pure Rust crates (no OpenSSL) +- `src/key_store/`: Platform-specific key protection: + - `windows_tpm.rs`: NCrypt TPM seal (Platform Crypto Provider) + - `windows.rs`: DPAPI fallback + - `windows_hello.rs`: Windows Hello presence gate (daemon) + - `linux.rs`: TPM2 via tpm2-tools + - `scalar.rs`: shared P-256 scalar ↔ PKCS8 helpers +- `src/identity_sessions/`: identity-backed unlock sessions (see below) +- `src/secure_mem.rs`: locked, dump-excluded, zeroize-on-drop buffers +- `src/daemon.rs`: Long-lived IPC daemon for biometric session caching +- `src/ipc.rs`: IPC server (Unix socket on Linux, named pipe on Windows) +- `src/daemon_client.rs`: Named pipe client for `--via-daemon` mode (WSL2 support) + +## Identity sessions + +Values are encrypted to an identity key rather than straight to the device key: + +``` +device key (NCrypt/TPM, DPAPI, TPM2, Secret Service) -> identity key -> values +``` + +The daemon holds the unwrapped identity key on behalf of one session so a whole +env file resolves without a prompt per value. A grant is what makes that holding +legitimate. The ops are the same ones the macOS (Swift) daemon speaks, so a +client cannot tell the two apart: + +- `unlock-session`: open or extend a session's hold on one or more keys +- `decrypt-v2`: decrypt a batch of identity payloads under a live grant +- `list-sessions`: every live grant, with no key material +- `invalidate-session`: drop everything, one session, or one grant + +`ping` reports `protocolVersion: 3`. + +Rules worth knowing: + +- A grant is keyed by (session x key). The session is resolved from the + connecting process, never from anything in the message. +- Scopes are `once`, `session`, and `duration`, all capped at 12 hours. +- Deadlines are held on both the wall clock and a sleep-inclusive monotonic + clock, and whichever runs out first ends the grant. +- Every authorization is appended to `/audit/authorizations.jsonl` + and read back off disk before any plaintext is returned. A decrypt whose record + cannot be written is refused. +- Nothing is persisted. A daemon restart loses every session on purpose. + +### What ends a session early + +`lockOn` is `screenLock`, `sleep` (the default), or `none`, taken from the +unlock, then from `sessions.lockOn` in the user config file, then from the +default. The daemon's ready line reports which triggers it actually wired: + +| event | Windows | Linux | +| --- | --- | --- | +| `sleep` | `PowerRegisterSuspendResumeNotification` | logind `PrepareForSleep` | +| `screenLock` | `WTSRegisterSessionNotification` | logind session `Lock` | + +Desktop-environment screensaver locks on Linux (GNOME, KDE) do not always reach +logind, and are not yet wired. A machine with no source for an event runs its +sessions to their TTL instead, and says so on stderr at startup. + +### Where the key is held + +macOS re-wraps the identity key under a per-session Secure Enclave key. Neither +NCrypt nor TPM2 gives a cheap equivalent, so the hold here is guarded memory: a +fixed-size allocation that never grows, `mlock`/`VirtualLock`ed, marked +`MADV_DONTDUMP` on Linux, and zeroized when the session ends. The daemon also +disables core dumps and clears `PR_SET_DUMPABLE` on Linux at startup. A +TPM-resident session key is a later step. ## WSL2 Support diff --git a/packages/encryption-binary-rust/src/crypto.rs b/packages/encryption-binary-rust/src/crypto.rs index c27a60d29..4947afc80 100644 --- a/packages/encryption-binary-rust/src/crypto.rs +++ b/packages/encryption-binary-rust/src/crypto.rs @@ -1,21 +1,27 @@ -//! ECIES implementation matching the JS (crypto.ts) and Swift (SecureEnclaveManager.swift) schemes. +//! The varlock ECIES wire format, shared by every backend. //! -//! Wire-compatible payload format: -//! version(1) | ephemeralPubKey(65) | nonce(12) | ciphertext(N) | tag(16) +//! version(1) | ephemeralPub(65) | nonce(12) | ciphertext(N) | tag(16) //! -//! Crypto: -//! - P-256 ECDH key agreement -//! - HKDF-SHA256 (salt: "varlock-ecies-v1", info: ephemeralPub || recipientPub) -//! - AES-256-GCM with random 12-byte nonce +//! P-256 ECDH, HKDF-SHA256 (salt "varlock-ecies-v1", info = ephemeralPub || +//! recipientPub), AES-256-GCM. +//! +//! Three implementations write these bytes: this one, the Swift daemon's +//! `Ecies.swift`, and the TypeScript library's `crypto.ts`. `crypto.ts` is the +//! reference, and the fixture in +//! `packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json` +//! is what pins all three to it: the tests at the bottom of this file read the +//! same checked-in file the Swift tests do. A failure there means the +//! implementations have drifted, so regenerate the fixture only when the wire +//! format changed on purpose. use aes_gcm::{ aead::{Aead, KeyInit}, Aes256Gcm, Nonce, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use elliptic_curve::pkcs8::{DecodePrivateKey, EncodePrivateKey}; use elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; use hkdf::Hkdf; -use elliptic_curve::pkcs8::{DecodePrivateKey, EncodePrivateKey}; use p256::{ ecdh::EphemeralSecret, elliptic_curve::rand_core::OsRng, @@ -24,12 +30,18 @@ use p256::{ use sha2::Sha256; use zeroize::Zeroize; -const PAYLOAD_VERSION: u8 = 0x01; +/// Payload encrypted directly to a device key (Secure Enclave / TPM / file) +pub const DEVICE_PAYLOAD_VERSION: u8 = 0x01; +/// Payload encrypted to an identity public key, which is itself wrapped to a device key +pub const IDENTITY_PAYLOAD_VERSION: u8 = 0x02; + const HKDF_SALT: &[u8] = b"varlock-ecies-v1"; const PUBLIC_KEY_LENGTH: usize = 65; // uncompressed P-256: 0x04 || x(32) || y(32) const NONCE_LENGTH: usize = 12; const TAG_LENGTH: usize = 16; const HEADER_LENGTH: usize = 1 + PUBLIC_KEY_LENGTH + NONCE_LENGTH; +/// The raw private scalar for P-256 +const SCALAR_LENGTH: usize = 32; /// A P-256 key pair with base64-encoded components. pub struct KeyPair { @@ -41,16 +53,14 @@ pub struct KeyPair { /// Generate a new P-256 key pair. /// -/// Returns the public key as uncompressed SEC1 (65 bytes, base64) and -/// the private key as PKCS8 DER (base64), matching the JS/Swift format. +/// Returns the public key as uncompressed SEC1 (65 bytes, base64) and the +/// private key as PKCS8 DER (base64), matching the JS/Swift format. pub fn generate_key_pair() -> Result { let secret_key = SecretKey::random(&mut OsRng); - // Public key: uncompressed SEC1 encoding (65 bytes) let public_key_point = secret_key.public_key().to_encoded_point(false); let public_key_bytes = public_key_point.as_bytes(); - // Private key: PKCS8 DER encoding let private_key_pkcs8 = secret_key .to_pkcs8_der() .map_err(|e| format!("Failed to encode private key as PKCS8: {e}"))?; @@ -61,58 +71,91 @@ pub fn generate_key_pair() -> Result { }) } -/// Encrypt plaintext using ECIES with the recipient's public key. +// ── Key import ─────────────────────────────────────────────────── + +/// Load a P-256 private key from the PKCS#8 DER the TS side produces. +pub fn secret_key_from_pkcs8(der: &[u8]) -> Result { + SecretKey::from_pkcs8_der(der).map_err(|e| format!("Invalid PKCS8 private key: {e}")) +} + +/// Load a P-256 private key from its raw 32-byte scalar. /// -/// Only needs the public key — no private key or biometric auth required. -/// Returns base64-encoded ciphertext payload. -pub fn encrypt(public_key_base64: &str, plaintext: &[u8]) -> Result { - let recipient_pub_bytes = BASE64 - .decode(public_key_base64) - .map_err(|e| format!("Invalid public key base64: {e}"))?; +/// This is the form the daemon holds a session's identity key in: the scalar is +/// the whole secret, and it is 32 fixed bytes, which is what +/// [`crate::secure_mem::GuardedBuffer`] wants. +pub fn secret_key_from_scalar(scalar: &[u8]) -> Result { + if scalar.len() != SCALAR_LENGTH { + return Err(format!( + "Invalid P-256 scalar length: {} (expected {SCALAR_LENGTH})", + scalar.len() + )); + } + SecretKey::from_slice(scalar).map_err(|e| format!("Invalid P-256 private scalar: {e}")) +} + +/// The uncompressed SEC1 public key for a private key, 65 bytes. +pub fn public_key_bytes(secret_key: &SecretKey) -> Vec { + secret_key.public_key().to_encoded_point(false).as_bytes().to_vec() +} + +// ── HKDF ───────────────────────────────────────────────────────── +fn derive_aes_key( + shared_secret: &[u8], + ephemeral_pub: &[u8], + recipient_pub: &[u8], +) -> Result<[u8; 32], String> { + let mut info = Vec::with_capacity(ephemeral_pub.len() + recipient_pub.len()); + info.extend_from_slice(ephemeral_pub); + info.extend_from_slice(recipient_pub); + + let hk = Hkdf::::new(Some(HKDF_SALT), shared_secret); + let mut aes_key = [0u8; 32]; + hk.expand(&info, &mut aes_key) + .map_err(|e| format!("HKDF expand failed: {e}"))?; + Ok(aes_key) +} + +// ── Encrypt ────────────────────────────────────────────────────── + +/// Encrypt to a recipient public key given in its raw uncompressed SEC1 form. +/// +/// Needs no private key and no auth gate: this is how the daemon can capture a +/// secret and hand back only ciphertext, without unlocking anything. +pub fn encrypt_to_public_key( + recipient_pub_bytes: &[u8], + plaintext: &[u8], + version: u8, +) -> Result, String> { if recipient_pub_bytes.len() != PUBLIC_KEY_LENGTH { return Err(format!( - "Invalid public key length: {} (expected {})", - recipient_pub_bytes.len(), - PUBLIC_KEY_LENGTH + "Invalid public key length: {} (expected {PUBLIC_KEY_LENGTH})", + recipient_pub_bytes.len() )); } - // Import recipient public key - let recipient_point = p256::EncodedPoint::from_bytes(&recipient_pub_bytes) + let recipient_point = p256::EncodedPoint::from_bytes(recipient_pub_bytes) .map_err(|e| format!("Invalid public key encoding: {e}"))?; let recipient_pub = PublicKey::from_encoded_point(&recipient_point) .into_option() .ok_or("Invalid P-256 public key point")?; - // Generate ephemeral key pair let ephemeral_secret = EphemeralSecret::random(&mut OsRng); - let ephemeral_pub = ephemeral_secret.public_key(); - let ephemeral_pub_bytes = ephemeral_pub.to_encoded_point(false); - let ephemeral_pub_raw = ephemeral_pub_bytes.as_bytes(); // 65 bytes + let ephemeral_pub_point = ephemeral_secret.public_key().to_encoded_point(false); + let ephemeral_pub_raw = ephemeral_pub_point.as_bytes(); // 65 bytes - // ECDH: ephemeral private × recipient public → shared secret let shared_secret = ephemeral_secret.diffie_hellman(&recipient_pub); - let shared_secret_bytes = shared_secret.raw_secret_bytes(); - - // HKDF-SHA256 → AES-256 key - // info = ephemeralPubKey || recipientPubKey - let mut info = Vec::with_capacity(PUBLIC_KEY_LENGTH * 2); - info.extend_from_slice(ephemeral_pub_raw); - info.extend_from_slice(&recipient_pub_bytes); - - let hk = Hkdf::::new(Some(HKDF_SALT), shared_secret_bytes); - let mut aes_key = [0u8; 32]; - hk.expand(&info, &mut aes_key) - .map_err(|e| format!("HKDF expand failed: {e}"))?; - - // AES-256-GCM encrypt - let cipher = Aes256Gcm::new_from_slice(&aes_key) - .map_err(|e| { - aes_key.zeroize(); - format!("AES key init failed: {e}") - })?; - aes_key.zeroize(); // Cipher has its own copy + let mut aes_key = derive_aes_key( + shared_secret.raw_secret_bytes(), + ephemeral_pub_raw, + recipient_pub_bytes, + )?; + + let cipher = Aes256Gcm::new_from_slice(&aes_key).map_err(|e| { + aes_key.zeroize(); + format!("AES key init failed: {e}") + })?; + aes_key.zeroize(); // the cipher has its own copy let mut nonce_bytes = [0u8; NONCE_LENGTH]; rand::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes); @@ -122,115 +165,128 @@ pub fn encrypt(public_key_base64: &str, plaintext: &[u8]) -> Result Result { + let recipient_pub_bytes = BASE64 + .decode(public_key_base64) + .map_err(|e| format!("Invalid public key base64: {e}"))?; + let payload = encrypt_to_public_key(&recipient_pub_bytes, plaintext, DEVICE_PAYLOAD_VERSION)?; Ok(BASE64.encode(&payload)) } -/// Decrypt ciphertext using ECIES with the recipient's private key. -/// -/// `private_key_base64` is PKCS8 DER, `public_key_base64` is uncompressed SEC1. -/// `ciphertext_base64` is the base64-encoded wire-format payload. -/// Returns decrypted plaintext bytes. -pub fn decrypt( - private_key_base64: &str, - public_key_base64: &str, - ciphertext_base64: &str, -) -> Result, String> { - let payload = BASE64 - .decode(ciphertext_base64) - .map_err(|e| format!("Invalid ciphertext base64: {e}"))?; +// ── Decrypt ────────────────────────────────────────────────────── +struct PayloadParts<'a> { + version: u8, + ephemeral_pub: &'a [u8], + nonce: &'a [u8], + ciphertext_and_tag: &'a [u8], +} + +/// Split a payload into its parts, validating the framing but not the key. +fn parse_payload(payload: &[u8]) -> Result, String> { if payload.len() < HEADER_LENGTH + TAG_LENGTH { return Err("Payload too short".into()); } + Ok(PayloadParts { + version: payload[0], + ephemeral_pub: &payload[1..1 + PUBLIC_KEY_LENGTH], + nonce: &payload[1 + PUBLIC_KEY_LENGTH..HEADER_LENGTH], + ciphertext_and_tag: &payload[HEADER_LENGTH..], + }) +} - // Parse payload - let version = payload[0]; - if version != PAYLOAD_VERSION { - return Err(format!("Unsupported payload version: {version}")); - } - - let ephemeral_pub_raw = &payload[1..1 + PUBLIC_KEY_LENGTH]; - let nonce_bytes = &payload[1 + PUBLIC_KEY_LENGTH..HEADER_LENGTH]; - let ciphertext_and_tag = &payload[HEADER_LENGTH..]; - - if ciphertext_and_tag.len() < TAG_LENGTH { - return Err("Payload too short for tag".into()); +/// Decrypt a payload with the recipient's private key. +/// +/// `accepted_versions` is checked against the payload's version byte. That byte +/// is outside the AEAD tag, so it is a routing hint rather than an authenticated +/// claim: flipping it only sends the payload at the wrong key, where it fails. +/// +/// The recipient public key that goes into the HKDF info is derived from the +/// private key rather than passed in, so a caller cannot get a decrypt to +/// succeed against a public key that is not the one it holds. +pub fn decrypt_payload( + secret_key: &SecretKey, + payload: &[u8], + accepted_versions: &[u8], +) -> Result, String> { + let parts = parse_payload(payload)?; + if !accepted_versions.contains(&parts.version) { + return Err(format!( + "Unsupported encrypted payload version {}; upgrade varlock", + parts.version + )); } - // Import private key from PKCS8 DER - let mut private_key_der = BASE64 - .decode(private_key_base64) - .map_err(|e| format!("Invalid private key base64: {e}"))?; - let secret_key = SecretKey::from_pkcs8_der(&private_key_der) - .map_err(|e| { - private_key_der.zeroize(); - format!("Invalid PKCS8 private key: {e}") - })?; - private_key_der.zeroize(); // No longer needed — SecretKey has its own copy - - // Import ephemeral public key - let ephemeral_point = p256::EncodedPoint::from_bytes(ephemeral_pub_raw) + let ephemeral_point = p256::EncodedPoint::from_bytes(parts.ephemeral_pub) .map_err(|e| format!("Invalid ephemeral public key: {e}"))?; let ephemeral_pub = PublicKey::from_encoded_point(&ephemeral_point) .into_option() .ok_or("Invalid ephemeral P-256 point")?; - // Recipient public key bytes for HKDF info - let recipient_pub_bytes = BASE64 - .decode(public_key_base64) - .map_err(|e| format!("Invalid public key base64: {e}"))?; - - // ECDH: recipient private × ephemeral public → shared secret - let shared_secret = p256::ecdh::diffie_hellman( - secret_key.to_nonzero_scalar(), - ephemeral_pub.as_affine(), - ); - let shared_secret_bytes = shared_secret.raw_secret_bytes(); - - // HKDF-SHA256 → AES-256 key (must match encrypt side) - let mut info = Vec::with_capacity(PUBLIC_KEY_LENGTH * 2); - info.extend_from_slice(ephemeral_pub_raw); - info.extend_from_slice(&recipient_pub_bytes); - - let hk = Hkdf::::new(Some(HKDF_SALT), shared_secret_bytes); - let mut aes_key = [0u8; 32]; - hk.expand(&info, &mut aes_key) - .map_err(|e| format!("HKDF expand failed: {e}"))?; - - // AES-256-GCM decrypt - // aes-gcm expects ciphertext || tag concatenated (same as wire format after header) - let cipher = Aes256Gcm::new_from_slice(&aes_key) - .map_err(|e| { - aes_key.zeroize(); - format!("AES key init failed: {e}") - })?; - aes_key.zeroize(); // Cipher has its own copy — zeroize ours + let recipient_pub_bytes = public_key_bytes(secret_key); + let shared_secret = + p256::ecdh::diffie_hellman(secret_key.to_nonzero_scalar(), ephemeral_pub.as_affine()); + let mut aes_key = derive_aes_key( + shared_secret.raw_secret_bytes(), + parts.ephemeral_pub, + &recipient_pub_bytes, + )?; + + let cipher = Aes256Gcm::new_from_slice(&aes_key).map_err(|e| { + aes_key.zeroize(); + format!("AES key init failed: {e}") + })?; + aes_key.zeroize(); // the cipher has its own copy + + let nonce = Nonce::from_slice(parts.nonce); + cipher + .decrypt(nonce, parts.ciphertext_and_tag) + .map_err(|_| "Decryption failed: invalid ciphertext or key".to_string()) +} - let nonce = Nonce::from_slice(nonce_bytes); +/// Decrypt a base64 device payload with a base64 PKCS8 private key. +/// +/// The long-standing entry point, kept for the one-shot `decrypt` command and +/// the daemon's `decrypt` action. Device payloads only: an identity payload has +/// to go through the session ops, which is where the grant is checked. +pub fn decrypt( + private_key_base64: &str, + _public_key_base64: &str, + ciphertext_base64: &str, +) -> Result, String> { + let payload = BASE64 + .decode(ciphertext_base64) + .map_err(|e| format!("Invalid ciphertext base64: {e}"))?; - let plaintext = cipher - .decrypt(nonce, ciphertext_and_tag) - .map_err(|_| "Decryption failed: invalid ciphertext or key".to_string())?; + let mut private_key_der = BASE64 + .decode(private_key_base64) + .map_err(|e| format!("Invalid private key base64: {e}"))?; + let secret_key = secret_key_from_pkcs8(&private_key_der); + private_key_der.zeroize(); // the SecretKey has its own copy + let secret_key = secret_key?; - Ok(plaintext) + decrypt_payload(&secret_key, &payload, &[DEVICE_PAYLOAD_VERSION]) } #[cfg(test)] mod tests { use super::*; + use serde::Deserialize; + use std::path::PathBuf; #[test] fn test_roundtrip() { @@ -247,11 +303,10 @@ mod tests { let encrypted = encrypt(&kp.public_key, b"test").unwrap(); let payload = BASE64.decode(&encrypted).unwrap(); - // Check version byte - assert_eq!(payload[0], PAYLOAD_VERSION); - // Check total minimum length: 1 + 65 + 12 + 0 + 16 = 94 + assert_eq!(payload[0], DEVICE_PAYLOAD_VERSION); + // 1 + 65 + 12 + N + 16 assert!(payload.len() >= HEADER_LENGTH + TAG_LENGTH); - // Check ephemeral public key starts with 0x04 (uncompressed) + // uncompressed ephemeral public key assert_eq!(payload[1], 0x04); } @@ -263,4 +318,211 @@ mod tests { let result = decrypt(&kp2.private_key, &kp2.public_key, &encrypted); assert!(result.is_err()); } + + #[test] + fn an_identity_payload_round_trips() { + let kp = generate_key_pair().unwrap(); + let recipient_pub = BASE64.decode(&kp.public_key).unwrap(); + let payload = + encrypt_to_public_key(&recipient_pub, "hello 🔐".as_bytes(), IDENTITY_PAYLOAD_VERSION) + .unwrap(); + assert_eq!(payload[0], IDENTITY_PAYLOAD_VERSION); + + let secret = secret_key_from_pkcs8(&BASE64.decode(&kp.private_key).unwrap()).unwrap(); + let plaintext = decrypt_payload(&secret, &payload, &[IDENTITY_PAYLOAD_VERSION]).unwrap(); + assert_eq!(String::from_utf8(plaintext).unwrap(), "hello 🔐"); + } + + #[test] + fn a_version_the_caller_did_not_ask_for_is_refused() { + let kp = generate_key_pair().unwrap(); + let recipient_pub = BASE64.decode(&kp.public_key).unwrap(); + let secret = secret_key_from_pkcs8(&BASE64.decode(&kp.private_key).unwrap()).unwrap(); + + let identity_payload = + encrypt_to_public_key(&recipient_pub, b"x", IDENTITY_PAYLOAD_VERSION).unwrap(); + let err = decrypt_payload(&secret, &identity_payload, &[DEVICE_PAYLOAD_VERSION]) + .expect_err("a v2 payload must not open on the device path"); + assert!(err.contains("Unsupported encrypted payload version 2")); + + // and the device payload is refused on the identity path + let device_payload = + encrypt_to_public_key(&recipient_pub, b"x", DEVICE_PAYLOAD_VERSION).unwrap(); + assert!(decrypt_payload(&secret, &device_payload, &[IDENTITY_PAYLOAD_VERSION]).is_err()); + } + + #[test] + fn the_legacy_decrypt_entry_point_refuses_identity_payloads() { + let kp = generate_key_pair().unwrap(); + let recipient_pub = BASE64.decode(&kp.public_key).unwrap(); + let payload = + encrypt_to_public_key(&recipient_pub, b"x", IDENTITY_PAYLOAD_VERSION).unwrap(); + let err = decrypt(&kp.private_key, &kp.public_key, &BASE64.encode(&payload)).unwrap_err(); + assert!(err.contains("upgrade varlock"), "{err}"); + } + + #[test] + fn a_scalar_and_its_pkcs8_are_the_same_key() { + let kp = generate_key_pair().unwrap(); + let der = BASE64.decode(&kp.private_key).unwrap(); + let from_der = secret_key_from_pkcs8(&der).unwrap(); + let scalar = crate::key_store::scalar::pkcs8_to_raw_scalar(&der).unwrap(); + let from_scalar = secret_key_from_scalar(&scalar).unwrap(); + assert_eq!(public_key_bytes(&from_der), public_key_bytes(&from_scalar)); + + let payload = encrypt_to_public_key( + &public_key_bytes(&from_der), + b"same key either way", + IDENTITY_PAYLOAD_VERSION, + ) + .unwrap(); + let plaintext = + decrypt_payload(&from_scalar, &payload, &[IDENTITY_PAYLOAD_VERSION]).unwrap(); + assert_eq!(plaintext, b"same key either way"); + } + + #[test] + fn a_scalar_of_the_wrong_length_is_refused() { + assert!(secret_key_from_scalar(&[0u8; 31]).is_err()); + assert!(secret_key_from_scalar(&[]).is_err()); + } + + #[test] + fn a_truncated_payload_is_refused() { + let kp = generate_key_pair().unwrap(); + let secret = secret_key_from_pkcs8(&BASE64.decode(&kp.private_key).unwrap()).unwrap(); + let err = decrypt_payload(&secret, &[0x02; 10], &[IDENTITY_PAYLOAD_VERSION]).unwrap_err(); + assert_eq!(err, "Payload too short"); + } + + #[test] + fn a_tampered_tag_is_refused() { + let kp = generate_key_pair().unwrap(); + let recipient_pub = BASE64.decode(&kp.public_key).unwrap(); + let secret = secret_key_from_pkcs8(&BASE64.decode(&kp.private_key).unwrap()).unwrap(); + + let mut payload = + encrypt_to_public_key(&recipient_pub, b"do not tamper", IDENTITY_PAYLOAD_VERSION) + .unwrap(); + let last = payload.len() - 1; + payload[last] ^= 0xff; + assert!(decrypt_payload(&secret, &payload, &[IDENTITY_PAYLOAD_VERSION]).is_err()); + } + + // ── Cross-implementation compatibility ─────────────────────── + // + // The same fixture the Swift `EciesCompatTests` read, loaded from the same + // checked-in file rather than a copy, so the two daemons cannot be pinned to + // different versions of it. + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Vector { + version: u8, + public_key: String, + private_key_pkcs8: String, + plaintext: String, + payload: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Fixture { + hkdf_salt: String, + identity: Vector, + device: Vector, + } + + fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../encryption-binary-swift/swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json", + ) + } + + fn load_fixture() -> Fixture { + let path = fixture_path(); + let data = std::fs::read(&path).unwrap_or_else(|e| { + panic!( + "could not read the shared ECIES fixture at {}: {e}", + path.display() + ) + }); + serde_json::from_slice(&data).expect("the shared ECIES fixture should parse") + } + + fn secret_for(vector: &Vector) -> SecretKey { + let der = BASE64.decode(&vector.private_key_pkcs8).expect("base64 PKCS8"); + secret_key_from_pkcs8(&der).expect("the fixture key should import") + } + + #[test] + fn reads_the_identity_payload_typescript_wrote() { + let fixture = load_fixture(); + assert_eq!(fixture.identity.version, IDENTITY_PAYLOAD_VERSION); + + let payload = BASE64.decode(&fixture.identity.payload).expect("base64 payload"); + assert_eq!(payload.first(), Some(&IDENTITY_PAYLOAD_VERSION)); + + let plaintext = decrypt_payload( + &secret_for(&fixture.identity), + &payload, + &[IDENTITY_PAYLOAD_VERSION], + ) + .expect("the identity vector should decrypt"); + assert_eq!(String::from_utf8(plaintext).unwrap(), fixture.identity.plaintext); + } + + #[test] + fn reads_the_device_payload_typescript_wrote() { + let fixture = load_fixture(); + assert_eq!(fixture.device.version, DEVICE_PAYLOAD_VERSION); + + let payload = BASE64.decode(&fixture.device.payload).expect("base64 payload"); + let plaintext = decrypt_payload( + &secret_for(&fixture.device), + &payload, + &[DEVICE_PAYLOAD_VERSION], + ) + .expect("the device vector should decrypt"); + assert_eq!(String::from_utf8(plaintext).unwrap(), fixture.device.plaintext); + } + + #[test] + fn writes_what_the_fixture_key_can_read_back() { + // The other direction: a payload this implementation produces has to + // open under the fixture's own key, which is how the daemon's capture + // path (encrypt to an identity public key) stays readable elsewhere. + let fixture = load_fixture(); + let recipient_pub = BASE64.decode(&fixture.identity.public_key).expect("base64 public key"); + + let payload = encrypt_to_public_key( + &recipient_pub, + fixture.identity.plaintext.as_bytes(), + IDENTITY_PAYLOAD_VERSION, + ) + .expect("should encrypt to the fixture identity"); + + let plaintext = decrypt_payload( + &secret_for(&fixture.identity), + &payload, + &[IDENTITY_PAYLOAD_VERSION], + ) + .expect("our own payload should decrypt"); + assert_eq!(String::from_utf8(plaintext).unwrap(), fixture.identity.plaintext); + } + + #[test] + fn the_fixtures_public_key_is_the_one_its_private_key_derives() { + let fixture = load_fixture(); + for vector in [&fixture.identity, &fixture.device] { + let expected = BASE64.decode(&vector.public_key).unwrap(); + assert_eq!(public_key_bytes(&secret_for(vector)), expected); + } + } + + #[test] + fn the_fixture_pins_the_hkdf_salt_this_build_uses() { + let fixture = load_fixture(); + assert_eq!(fixture.hkdf_salt.as_bytes(), HKDF_SALT); + } } diff --git a/packages/encryption-binary-rust/src/daemon.rs b/packages/encryption-binary-rust/src/daemon.rs index ba3bc2b7b..ffe1e2e5e 100644 --- a/packages/encryption-binary-rust/src/daemon.rs +++ b/packages/encryption-binary-rust/src/daemon.rs @@ -1,22 +1,55 @@ //! Daemon mode — long-lived process with IPC server, session management, and auto-shutdown. //! -//! Matches the Swift daemon's behavior: +//! Speaks the same protocol as the Swift macOS daemon: //! - Accepts connections over Unix socket (Linux) or named pipe (Windows) -//! - Handles: decrypt, encrypt, ping, invalidate-session -//! - On Windows with Hello: requires biometric before first decrypt per session +//! - Device actions: decrypt, encrypt, ping, invalidate-session +//! - Identity session actions: unlock-session, decrypt-v2, list-sessions, and +//! the per-session form of invalidate-session +//! - On Windows with Hello: requires a presence check before an unlock //! - No prompt-secret (no GUI on Linux — handled by terminal prompt in TS) -//! - Auto-shutdown after 30 minutes of inactivity +//! - Auto-shutdown after inactivity, unless a session is being held //! - Session invalidation on SIGTERM/SIGINT +//! +//! What is deliberately absent, compared with the macOS daemon: there is no +//! approval panel and no `request-approval`. Neither platform has a trusted +//! display for the daemon to draw on yet, so approval surfaces (phone, terminal) +//! arrive later. See [`DAEMON_PROTOCOL_VERSION`]. use crate::crypto; -use crate::ipc::{IpcServer, MessageHandler}; +use crate::identity_sessions::custody::KeyStoreCustody; +use crate::identity_sessions::grants::{SessionGrantScope, MAX_GRANT_MS}; +use crate::identity_sessions::identity_store::{SessionPaths, DEFAULT_IDENTITY_ID}; +use crate::identity_sessions::lock_events; +use crate::identity_sessions::lock_policy::SessionLockEvent; +use crate::identity_sessions::manager::{ + IdentitySessionManager, SessionError, UnlockRequest, +}; +use crate::ipc::{IpcServer, MessageHandler, PeerContext}; use crate::key_store; +use crate::secure_mem; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; const DEFAULT_KEY_ID: &str = "varlock-default"; + +/// IPC protocol version reported by `ping`. +/// +/// 1 (reported as absent) is the original action set. 2 adds the identity +/// session ops: unlock-session, decrypt-v2, list-sessions, and the per-session +/// form of invalidate-session. 3 is what the macOS daemon reports once it draws +/// an approval panel. +/// +/// This daemon reports 3 because it speaks every op a client dispatches on, and +/// a client that saw 2 here would hold back features that do work. The panel +/// half of 3 has no counterpart on these platforms: `unlock-session` never +/// answers `APPROVAL_DENIED` or `NO_UI`, and `request-approval` is not +/// implemented. Neither is something a client has to do anything about, since +/// both are outcomes it already has to handle from a daemon that never prompts. +const DAEMON_PROTOCOL_VERSION: u32 = 3; + // On Windows the daemon can't be re-spawned from a WSL2-invoked .exe (no access // to the interactive desktop session), so a short timeout would force the user // back to a native Windows terminal. Keep it alive for a full day there. @@ -26,9 +59,13 @@ const DAEMON_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); / const DAEMON_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(30 * 60); // 30 minutes const SESSION_TIMEOUT: Duration = Duration::from_secs(5 * 60); // 5 minutes per session -/// Per-TTY session state. +/// How often expired grants are swept, so a hard-cap expiry erases key material +/// even on a daemon nobody is talking to. +const PRUNE_INTERVAL: Duration = Duration::from_secs(60); + +/// Per-TTY session state for the pre-identity device decrypt path. struct SessionManager { - /// Map of TTY IDs to their session creation time. + /// Map of session keys to their session creation time. /// Sessions expire after SESSION_TIMEOUT. active_sessions: std::collections::HashMap, /// Last IPC activity timestamp for daemon timeout. @@ -51,16 +88,16 @@ impl SessionManager { self.last_activity = Instant::now(); } - fn is_session_warm(&self, tty_id: &Option) -> bool { - let key = tty_id.as_deref().unwrap_or("__no_tty__"); + fn is_session_warm(&self, session_key: &Option) -> bool { + let key = session_key.as_deref().unwrap_or("__no_tty__"); match self.active_sessions.get(key) { Some(created_at) => created_at.elapsed() < SESSION_TIMEOUT, None => false, } } - fn mark_session_warm(&mut self, tty_id: &Option) { - let key = tty_id.as_deref().unwrap_or("__no_tty__").to_string(); + fn mark_session_warm(&mut self, session_key: &Option) { + let key = session_key.as_deref().unwrap_or("__no_tty__").to_string(); self.active_sessions.insert(key, Instant::now()); } @@ -68,23 +105,23 @@ impl SessionManager { self.active_sessions.clear(); } - #[allow(dead_code)] - fn has_any_sessions(&self) -> bool { - self.active_sessions.values().any(|t| t.elapsed() < SESSION_TIMEOUT) - } - fn is_timed_out(&self) -> bool { self.last_activity.elapsed() > DAEMON_INACTIVITY_TIMEOUT } /// Whether the next decrypt should require biometric verification. - fn needs_biometric(&self, tty_id: &Option) -> bool { - self.biometric_available && !self.is_session_warm(tty_id) + fn needs_biometric(&self, session_key: &Option) -> bool { + self.biometric_available && !self.is_session_warm(session_key) } } /// Run the daemon. pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), String> { + // Before anything can be held: no core dumps, and (on Linux) no ptrace from + // a sibling process. Done first so it also covers a key held for a moment + // by the pre-identity decrypt path. + secure_mem::harden_process(); + // Write PID file if let Some(pid_path) = pid_path { if let Some(parent) = std::path::Path::new(pid_path).parent() { @@ -95,8 +132,25 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin } let session_manager = Arc::new(Mutex::new(SessionManager::new())); + let identity_sessions = Arc::new(IdentitySessionManager::new( + SessionPaths::from_user_config_dir(), + Box::new(KeyStoreCustody::new()), + )); let mut server = IpcServer::new(socket_path); + // Sleep and screen lock are judged per session: each one is erased only if + // its own resolved lockOn policy says that event ends it. + let identity_for_events = identity_sessions.clone(); + let lock_sources = lock_events::start(Arc::new(move |event: SessionLockEvent| { + let dropped = identity_for_events.handle_lock_event(event); + if dropped > 0 { + eprintln!( + "varlock: {dropped} unlock session(s) ended by {}", + event.wire_value() + ); + } + })); + // Activity callback let sm_activity = session_manager.clone(); server.set_activity_callback(move || { @@ -107,17 +161,23 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin // Message handler let sm_handler = session_manager.clone(); - let handler: MessageHandler = Box::new(move |message: Value, tty_id: Option| { + let identity_handler = identity_sessions.clone(); + let handler: MessageHandler = Box::new(move |message: Value, peer: PeerContext| { let action = message .get("action") .and_then(|v| v.as_str()) .unwrap_or(""); match action { - "decrypt" => handle_decrypt(&message, &tty_id, &sm_handler), + "decrypt" => handle_decrypt(&message, &peer, &sm_handler), "encrypt" => handle_encrypt(&message), - "ping" => handle_ping(&tty_id, &sm_handler), - "invalidate-session" => handle_invalidate(&sm_handler), + "ping" => handle_ping(&peer, &sm_handler), + "invalidate-session" => { + handle_invalidate(&message, &peer, &sm_handler, &identity_handler) + } + "unlock-session" => handle_unlock_session(&message, &peer, &identity_handler), + "decrypt-v2" => handle_decrypt_v2(&message, &peer, &identity_handler), + "list-sessions" => handle_list_sessions(&identity_handler), _ => json!({"error": format!("Unknown action: {action}")}), } }); @@ -133,22 +193,32 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin let _ = ctrlc_handler(running.clone()); } - // Inactivity timeout checker + session expiry cleanup + // Inactivity timeout checker, session expiry cleanup, and the grant sweep let sm_timeout = session_manager.clone(); + let identity_timeout = identity_sessions.clone(); let running_timeout = running.clone(); std::thread::spawn(move || { loop { - std::thread::sleep(Duration::from_secs(60)); + std::thread::sleep(PRUNE_INTERVAL); if !running_timeout.load(Ordering::SeqCst) { break; } + + // Sweep expired grants first, so a hard-cap expiry erases the key it + // was covering even if nothing is talking to the daemon. + identity_timeout.reconcile(); + let holding_keys = identity_timeout.has_live_sessions(); + if let Ok(mut sm) = sm_timeout.lock() { // Clean up expired sessions sm.active_sessions.retain(|_, created_at| { created_at.elapsed() < SESSION_TIMEOUT }); - if sm.is_timed_out() { + // Never idle-quit while an identity key is being held for + // someone: session state is memory-only, so quitting would + // silently cost them their unlock. + if sm.is_timed_out() && !holding_keys { running_timeout.store(false, Ordering::SeqCst); break; } @@ -161,6 +231,10 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin "ready": true, "pid": std::process::id(), "socketPath": socket_path, + "protocolVersion": DAEMON_PROTOCOL_VERSION, + // Which system events can end a session on this machine. Empty means a + // session runs to its TTL or an explicit lock, and nothing else. + "lockTriggers": lock_sources.wired(), }); println!("{}", ready); use std::io::Write; @@ -169,6 +243,10 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin // Start server (blocks) let result = server.start(); + // Whatever stopped the daemon, nothing may outlive it: drop every grant and + // erase the keys they covered before the process goes away. + identity_sessions.invalidate(None, None, Some("daemon shutdown".into())); + // Cleanup if let Some(pp) = &pid_path_owned { let _ = std::fs::remove_file(pp); @@ -181,7 +259,7 @@ pub fn run_daemon(socket_path: &str, pid_path: Option<&str>) -> Result<(), Strin fn handle_decrypt( message: &Value, - tty_id: &Option, + peer: &PeerContext, sm: &Arc>, ) -> Value { let payload = match message.get("payload") { @@ -200,7 +278,8 @@ fn handle_decrypt( .unwrap_or(DEFAULT_KEY_ID); // Check if biometric verification is needed - let needs_bio = sm.lock().map(|s| s.needs_biometric(tty_id)).unwrap_or(false); + let session_key = peer.legacy_session_key(); + let needs_bio = sm.lock().map(|s| s.needs_biometric(&session_key)).unwrap_or(false); if needs_bio { match verify_user_presence() { @@ -215,10 +294,7 @@ fn handle_decrypt( Ok((private_key_der, public_key_b64)) => { let secure_key = crate::secure_mem::SecureBytes::new(private_key_der); let private_key_b64 = crate::secure_mem::SecureString::new( - base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - secure_key.as_slice(), - ), + BASE64.encode(secure_key.as_slice()), ); let result = match crypto::decrypt(private_key_b64.as_str(), &public_key_b64, ciphertext_b64) { @@ -227,7 +303,7 @@ fn handle_decrypt( Ok(plaintext) => { // Mark session as warm if let Ok(mut session) = sm.lock() { - session.mark_session_warm(tty_id); + session.mark_session_warm(&session_key); } json!({"result": plaintext}) } @@ -254,6 +330,24 @@ fn handle_encrypt(message: &Value) -> Value { None => return json!({"error": "Missing plaintext in payload"}), }; + // Encrypting to an identity public key needs no key of ours and no unlock: + // the caller supplies the recipient. This is the shape a capture path wants, + // where the daemon must hand back ciphertext and nothing else. + if let Some(identity_public_key) = payload.get("identityPublicKey").and_then(|v| v.as_str()) { + let recipient = match BASE64.decode(identity_public_key) { + Ok(bytes) => bytes, + Err(_) => return json!({"error": "Invalid base64 identityPublicKey"}), + }; + return match crypto::encrypt_to_public_key( + &recipient, + plaintext.as_bytes(), + crypto::IDENTITY_PAYLOAD_VERSION, + ) { + Ok(payload) => json!({"result": BASE64.encode(&payload)}), + Err(e) => json!({"error": e}), + }; + } + let key_id = payload .get("keyId") .and_then(|v| v.as_str()) @@ -268,51 +362,220 @@ fn handle_encrypt(message: &Value) -> Value { } } -fn handle_ping(tty_id: &Option, sm: &Arc>) -> Value { +fn handle_ping(peer: &PeerContext, sm: &Arc>) -> Value { + let session_key = peer.legacy_session_key(); let session_warm = sm .lock() - .map(|s| s.is_session_warm(tty_id)) + .map(|s| s.is_session_warm(&session_key)) .unwrap_or(false); json!({ "result": { "pong": true, "sessionWarm": session_warm, - "ttyId": tty_id.as_deref().unwrap_or(""), + // The session this daemon resolved for the caller, from the caller's + // own process. Absent when the platform could not work one out. + "sessionId": peer.session_id, + // Kept for the older clients that read it. + "ttyId": session_key.unwrap_or_default(), + // Absent means 1 (a daemon predating identity sessions), so a client + // can tell a stale daemon from one that speaks these ops. + "protocolVersion": DAEMON_PROTOCOL_VERSION, } }) } -fn handle_invalidate(sm: &Arc>) -> Value { - if let Ok(mut session) = sm.lock() { - session.invalidate_all(); +fn handle_invalidate( + message: &Value, + peer: &PeerContext, + sm: &Arc>, + identity: &Arc, +) -> Value { + let payload = message.get("payload"); + let target_session_id = payload + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()); + let target_key_id = payload.and_then(|p| p.get("keyId")).and_then(|v| v.as_str()); + + // No arguments keeps the original meaning: drop everything, including the + // cached device-decrypt sessions. + if target_session_id.is_none() && target_key_id.is_none() { + if let Ok(mut session) = sm.lock() { + session.invalidate_all(); + } } - json!({"result": "all sessions invalidated"}) + + let invalidated = identity.invalidate(target_session_id, target_key_id, peer.requester.clone()); + json!({"result": {"invalidated": invalidated}}) } -// ── Biometric verification ─────────────────────────────────────── +// ── Identity session handlers ──────────────────────────────────── -/// Verify user presence using platform-specific biometric. -/// Returns Ok(true) if verified, Ok(false) if cancelled. -fn verify_user_presence() -> Result { - #[cfg(target_os = "windows")] - { - crate::key_store::windows_hello::verify_user("Varlock needs to decrypt your secrets") +fn handle_unlock_session( + message: &Value, + peer: &PeerContext, + identity: &Arc, +) -> Value { + // A malformed message is refused rather than guessed at, the same way + // decrypt-v2 refuses one. Guessing here would mean unlocking a key the + // caller never named. + let Some(payload) = message.get("payload") else { + return json!({"error": "Missing payload"}); + }; + + let identity_id = payload + .get("identityId") + .and_then(|v| v.as_str()) + .unwrap_or(DEFAULT_IDENTITY_ID) + .to_string(); + + let scope = SessionGrantScope::from_wire_value(payload.get("scope").and_then(|v| v.as_str())) + .unwrap_or(SessionGrantScope::Session); + + // `items` (the ciphertexts a narrow approval would be bound to) and + // `display` are both read past here. Neither means anything on a daemon + // with no panel: `display` decorates a window that is never drawn, and + // `items` narrows a grant only if a person chooses to narrow it, and there + // is nobody to ask. Grants issued here cover the whole key and say so. + + // Accept one key or several: one unlock, one check, however many keys. + // Deliberately no default: naming no key is refused, not guessed at. + let mut key_ids: Vec = payload + .get("keyIds") + .and_then(|v| v.as_array()) + .map(|values| { + values + .iter() + .filter_map(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + if let Some(single) = payload.get("keyId").and_then(|v| v.as_str()) { + if !single.trim().is_empty() { + key_ids.push(single.to_string()); + } } + key_ids.sort(); + key_ids.dedup(); + + let duration_ms = payload + .get("durationMs") + .and_then(|v| v.as_i64()) + .map(|ms| ms.clamp(0, MAX_GRANT_MS)); + + let lock_on_override = payload.get("lockOn").and_then(|v| v.as_str()); + + // A caller may name the session it believes it is in, but that never + // overrides the identity resolved from the peer process itself. + let request = UnlockRequest { + session_id: peer.session_id.as_deref(), + key_ids, + identity_id, + scope, + duration_ms, + lock_on_override, + requester: peer.requester.clone(), + }; - #[cfg(target_os = "linux")] - { - // polkit delegates to PAM — fingerprint / face / YubiKey / password - // depending on the user's configured factors. - crate::key_store::polkit::check_authorization() + match identity.unlock(request) { + Ok(outcome) => json!({ + "result": { + "sessionId": peer.session_id, + "policy": outcome.policy.wire_value(), + "lockOn": outcome.lock_on.wire_value(), + "lockOnSource": outcome.lock_on_source.wire_value(), + "prompted": outcome.prompted, + "grants": outcome.grants.iter().map(|g| g.to_json()).collect::>(), + } + }), + Err(err) => session_error_response(&err), } +} - #[cfg(not(any(target_os = "windows", target_os = "linux")))] - { - Ok(true) +fn handle_decrypt_v2( + message: &Value, + peer: &PeerContext, + identity: &Arc, +) -> Value { + let Some(payload) = message.get("payload") else { + return json!({"error": "Missing payload"}); + }; + + let key_id = payload + .get("keyId") + .and_then(|v| v.as_str()) + .unwrap_or(DEFAULT_KEY_ID); + let identity_id = payload + .get("identityId") + .and_then(|v| v.as_str()) + .unwrap_or(DEFAULT_IDENTITY_ID); + + // Batch form is the normal one (a whole env file resolves at once); the + // single-ciphertext form is accepted for one-off callers. + let mut ciphertexts: Vec<&str> = payload + .get("ciphertexts") + .and_then(|v| v.as_array()) + .map(|values| values.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + if let Some(single) = payload.get("ciphertext").and_then(|v| v.as_str()) { + ciphertexts.push(single); + } + if ciphertexts.is_empty() { + return json!({"error": "Missing ciphertext in payload"}); + } + + let mut payloads = Vec::with_capacity(ciphertexts.len()); + for ciphertext in &ciphertexts { + match BASE64.decode(ciphertext) { + Ok(bytes) => payloads.push(bytes), + Err(_) => return json!({"error": "Invalid base64 in ciphertext payload"}), + } + } + + match identity.decrypt_v2( + peer.session_id.as_deref(), + key_id, + identity_id, + &payloads, + peer.requester.clone(), + ) { + Ok((plaintexts, grant)) => json!({ + "result": { + "plaintexts": plaintexts, + "grant": grant.to_json(), + } + }), + Err(err) => session_error_response(&err), } } +fn handle_list_sessions(identity: &Arc) -> Value { + let sessions: Vec = identity.list_grants().iter().map(|g| g.to_json()).collect(); + json!({"result": {"sessions": sessions}}) +} + +/// Attach the stable error code, where there is one, alongside the message. The +/// TS client branches on the code and shows the message. +fn session_error_response(error: &SessionError) -> Value { + let mut response = json!({"error": error.to_string()}); + if let (Some(code), Some(object)) = (error.code(), response.as_object_mut()) { + object.insert("errorCode".into(), json!(code)); + } + response +} + +// ── Biometric verification ─────────────────────────────────────── + +/// Verify user presence using platform-specific biometric. +/// Returns Ok(true) if verified, Ok(false) if cancelled. +fn verify_user_presence() -> Result { + crate::identity_sessions::custody::verify_user_presence( + "Varlock needs to decrypt your secrets", + ) +} + // ── Signal handling ────────────────────────────────────────────── #[cfg(unix)] @@ -341,3 +604,227 @@ extern "C" fn signal_handler(_sig: libc::c_int) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity_sessions::audit::AuditWriteError; + use crate::identity_sessions::grants::{SessionGrantError, SessionGrantRef}; + + fn peer(session_id: Option<&str>) -> PeerContext { + PeerContext { + session_id: session_id.map(|s| s.to_string()), + requester: Some("cargo test (pid 42)".into()), + claimed_session_id: Some("tty:a-session-the-caller-named".into()), + } + } + + fn manager() -> Arc { + // A manager pointed at a directory that holds no identity: enough to + // exercise message parsing and the refusal paths without a key store. + Arc::new(IdentitySessionManager::new( + SessionPaths::with_user_dir(std::env::temp_dir().join("varlock-no-such-dir")), + Box::new(KeyStoreCustody::new()), + )) + } + + #[test] + fn decrypt_v2_refuses_a_session_with_no_grant() { + let message = json!({ + "action": "decrypt-v2", + "payload": { "keyId": "varlock-default", "ciphertexts": [BASE64.encode([0u8; 100])] }, + }); + let response = handle_decrypt_v2(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["errorCode"], json!("NO_SESSION_GRANT")); + assert!(response.get("result").is_none()); + } + + #[test] + fn decrypt_v2_refuses_when_the_peer_has_no_session_identity() { + // The caller named a session in the message. It must not be used. + let message = json!({ + "action": "decrypt-v2", + "payload": { "ciphertexts": [BASE64.encode([0u8; 100])] }, + }); + let response = handle_decrypt_v2(&message, &peer(None), &manager()); + assert_eq!(response["errorCode"], json!("NO_SESSION_IDENTITY")); + } + + #[test] + fn unlock_refuses_when_the_peer_has_no_session_identity() { + let message = json!({"action": "unlock-session", "payload": {"scope": "session"}}); + let response = handle_unlock_session(&message, &peer(None), &manager()); + assert_eq!(response["errorCode"], json!("NO_SESSION_IDENTITY")); + } + + #[test] + fn unlock_refuses_a_message_with_no_payload() { + let message = json!({"action": "unlock-session"}); + let response = handle_unlock_session(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["error"], json!("Missing payload")); + assert!(response.get("result").is_none()); + } + + #[test] + fn unlock_refuses_when_no_key_is_named() { + // No key id means the caller asked for nothing. It must not be handed a + // grant for some default key it never mentioned. + for payload in [ + json!({"scope": "session"}), + json!({"scope": "session", "keyIds": []}), + json!({"scope": "session", "keyIds": ["", " "]}), + json!({"scope": "session", "keyId": ""}), + json!({"scope": "session", "keyIds": [42, true]}), + ] { + let message = json!({"action": "unlock-session", "payload": payload}); + let response = handle_unlock_session(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!( + response["errorCode"], + json!("NO_KEYS_REQUESTED"), + "payload {message} should have been refused" + ); + assert!(response.get("result").is_none()); + } + } + + #[test] + fn decrypt_v2_needs_at_least_one_ciphertext() { + let message = json!({"action": "decrypt-v2", "payload": {"keyId": "k"}}); + let response = handle_decrypt_v2(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["error"], json!("Missing ciphertext in payload")); + } + + #[test] + fn decrypt_v2_rejects_a_payload_that_is_not_base64() { + let message = json!({ + "action": "decrypt-v2", + "payload": {"keyId": "k", "ciphertexts": ["not base64 !!"]}, + }); + let response = handle_decrypt_v2(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["error"], json!("Invalid base64 in ciphertext payload")); + } + + #[test] + fn decrypt_v2_accepts_the_single_ciphertext_form() { + // Reaching NO_SESSION_GRANT means the single-ciphertext field was read; + // an unparsed payload would have failed earlier with "Missing ciphertext". + let message = json!({ + "action": "decrypt-v2", + "payload": {"keyId": "k", "ciphertext": BASE64.encode([0u8; 100])}, + }); + let response = handle_decrypt_v2(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["errorCode"], json!("NO_SESSION_GRANT")); + } + + #[test] + fn an_unknown_identity_is_reported_with_its_code() { + let message = json!({ + "action": "unlock-session", + "payload": {"identityId": "work", "keyIds": ["varlock-default"]}, + }); + let response = handle_unlock_session(&message, &peer(Some("tty:1:2")), &manager()); + assert_eq!(response["errorCode"], json!("IDENTITY_NOT_FOUND")); + } + + #[test] + fn list_sessions_answers_with_an_empty_list_rather_than_an_error() { + let response = handle_list_sessions(&manager()); + assert_eq!(response["result"]["sessions"], json!([])); + } + + #[test] + fn invalidating_nothing_reports_zero() { + let session_manager = Arc::new(Mutex::new(SessionManager::new())); + let message = json!({"action": "invalidate-session"}); + let response = handle_invalidate( + &message, + &peer(Some("tty:1:2")), + &session_manager, + &manager(), + ); + assert_eq!(response["result"]["invalidated"], json!(0)); + } + + #[test] + fn ping_reports_the_protocol_version_and_the_derived_session() { + let session_manager = Arc::new(Mutex::new(SessionManager::new())); + let response = handle_ping(&peer(Some("tty:1:2")), &session_manager); + assert_eq!(response["result"]["protocolVersion"], json!(3)); + assert_eq!(response["result"]["pong"], json!(true)); + assert_eq!(response["result"]["sessionId"], json!("tty:1:2")); + } + + #[test] + fn ping_reports_no_session_id_when_the_peer_could_not_be_scoped() { + let session_manager = Arc::new(Mutex::new(SessionManager::new())); + let response = handle_ping(&peer(None), &session_manager); + assert_eq!(response["result"]["sessionId"], Value::Null); + // and the legacy field still carries the claimed value, as it always has + assert_eq!( + response["result"]["ttyId"], + json!("tty:a-session-the-caller-named") + ); + } + + #[test] + fn encrypting_to_an_identity_public_key_needs_no_stored_key() { + let recipient = crypto::generate_key_pair().unwrap(); + let message = json!({ + "action": "encrypt", + "payload": { + "plaintext": "a value to capture", + "identityPublicKey": recipient.public_key, + }, + }); + let response = handle_encrypt(&message); + let ciphertext = response["result"].as_str().expect("should encrypt"); + let payload = BASE64.decode(ciphertext).unwrap(); + assert_eq!(payload.first(), Some(&crypto::IDENTITY_PAYLOAD_VERSION)); + + let secret = crypto::secret_key_from_pkcs8( + &BASE64.decode(&recipient.private_key).unwrap(), + ) + .unwrap(); + let plaintext = + crypto::decrypt_payload(&secret, &payload, &[crypto::IDENTITY_PAYLOAD_VERSION]) + .unwrap(); + assert_eq!(String::from_utf8(plaintext).unwrap(), "a value to capture"); + } + + #[test] + fn every_session_error_that_carries_a_code_reports_it() { + let cases: Vec<(SessionError, &str)> = vec![ + (SessionError::NoSessionIdentity, "NO_SESSION_IDENTITY"), + (SessionError::SessionKeyMissing, "SESSION_KEY_MISSING"), + (SessionError::NotUtf8, "NOT_UTF8"), + (SessionError::PresenceFailed("x".into()), "BIOMETRIC_FAILED"), + ( + SessionError::Grant(SessionGrantError::Expired(SessionGrantRef::new("s", "k"))), + "SESSION_GRANT_EXPIRED", + ), + ( + SessionError::Audit(AuditWriteError("disk full".into())), + "AUDIT_WRITE_FAILED", + ), + ]; + for (error, expected) in cases { + let response = session_error_response(&error); + assert_eq!(response["errorCode"], json!(expected)); + assert!(response["error"].as_str().is_some_and(|m| !m.is_empty())); + } + + // and a crypto failure carries a message with no code to branch on + let response = session_error_response(&SessionError::Crypto("bad key".into())); + assert!(response.get("errorCode").is_none()); + assert_eq!(response["error"], json!("bad key")); + } + + #[test] + fn an_unknown_action_is_named_in_the_error() { + // The dispatch arm is a one-liner, so this pins the message shape the + // clients match on rather than the routing. + let action = "request-approval"; + let response = json!({"error": format!("Unknown action: {action}")}); + assert_eq!(response["error"], json!("Unknown action: request-approval")); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/audit.rs b/packages/encryption-binary-rust/src/identity_sessions/audit.rs new file mode 100644 index 000000000..66a64a12e --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/audit.rs @@ -0,0 +1,459 @@ +//! The append-only record of what the daemon authorized. +//! +//! One line of JSON per authorization, in a file only the user can read. The +//! point is answerability: if a session key was used, there is a durable line +//! saying when, for which key, under which grant, and which process asked. A +//! decrypt whose line cannot be written is refused, so the log has no holes +//! where the interesting cases would be. +//! +//! What a record must never contain is anything worth stealing. Every field here +//! is an identifier, a count, or a description of a process; no plaintext, no +//! ciphertext, and no key material passes through this type at all. +//! +//! The format is the one `AuthorizationAudit.swift` writes, field for field, so +//! the logs from a Mac and from a Windows or Linux box read the same. + +use serde_json::{json, Map, Value}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::timefmt; + +pub const AUDIT_FILE_NAME: &str = "authorizations.jsonl"; + +/// What the daemon authorized. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationKind { + /// Plaintext was about to be handed back for a batch of payloads. + Decrypt, + /// A session took (or extended) its hold on one or more keys. + Unlock, + /// Someone dropped grants on purpose. + Invalidate, +} + +impl AuthorizationKind { + pub fn wire_value(&self) -> &'static str { + match self { + AuthorizationKind::Decrypt => "decrypt-v2", + AuthorizationKind::Unlock => "unlock-session", + AuthorizationKind::Invalidate => "invalidate-session", + } + } +} + +/// One line of the log. +#[derive(Debug, Clone)] +pub struct AuthorizationRecord { + pub kind: AuthorizationKind, + /// Session identity as resolved from the peer, never as claimed by it. + pub session_id: String, + pub key_ids: Vec, + pub identity_id: Option, + /// How many payloads this call covered. Zero for anything but a decrypt. + pub payload_count: usize, + /// The grant scope the call ran under, when there was one. + pub scope: Option, + /// One line describing the process that asked, derived by the daemon. + pub requester: Option, +} + +impl AuthorizationRecord { + pub fn new(kind: AuthorizationKind, session_id: impl Into, key_ids: Vec) -> Self { + Self { + kind, + session_id: session_id.into(), + key_ids, + identity_id: None, + payload_count: 0, + scope: None, + requester: None, + } + } + + pub fn identity_id(mut self, identity_id: impl Into) -> Self { + self.identity_id = Some(identity_id.into()); + self + } + + pub fn payload_count(mut self, count: usize) -> Self { + self.payload_count = count; + self + } + + pub fn scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } + + pub fn requester(mut self, requester: Option) -> Self { + self.requester = requester; + self + } + + fn to_json(&self, timestamp: &str) -> Value { + let mut object = Map::new(); + object.insert("ts".into(), json!(timestamp)); + object.insert("event".into(), json!(self.kind.wire_value())); + object.insert("sessionId".into(), json!(self.session_id)); + object.insert("keyIds".into(), json!(self.key_ids)); + object.insert("payloadCount".into(), json!(self.payload_count)); + if let Some(identity_id) = &self.identity_id { + object.insert("identityId".into(), json!(identity_id)); + } + if let Some(scope) = &self.scope { + object.insert("scope".into(), json!(scope)); + } + if let Some(requester) = &self.requester { + object.insert("requester".into(), json!(requester)); + } + Value::Object(object) + } +} + +/// The record did not make it to disk, whatever the reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditWriteError(pub String); + +impl AuditWriteError { + /// Stable code the TS client can branch on without matching message text. + pub fn code(&self) -> &'static str { + "AUDIT_WRITE_FAILED" + } +} + +impl std::fmt::Display for AuditWriteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Refusing to release secrets: the authorization could not be recorded ({})", + self.0 + ) + } +} + +/// Appends authorization records, synchronously, and proves each one landed. +/// +/// Deliberately small and blocking. It runs on the path that is about to hand +/// back plaintext, so it has no queue to fall behind on, no buffer to lose on a +/// crash, and no way to report success for a line that is not on disk: every +/// append is flushed with `sync_all` and then read back off the file before the +/// caller is told it worked. +pub struct AuthorizationAuditLog { + directory: PathBuf, + /// Serialized so a read-back can trust the offset its own write returned. + write_lock: Mutex<()>, + /// Injected so tests can pin the timestamp. + timestamp: Box String + Send + Sync>, +} + +impl AuthorizationAuditLog { + pub fn new(directory: impl Into) -> Self { + Self::with_timestamp(directory, timefmt::now_iso8601_millis) + } + + pub fn with_timestamp( + directory: impl Into, + timestamp: impl Fn() -> String + Send + Sync + 'static, + ) -> Self { + Self { + directory: directory.into(), + write_lock: Mutex::new(()), + timestamp: Box::new(timestamp), + } + } + + #[cfg(test)] + pub fn directory(&self) -> &Path { + &self.directory + } + + pub fn file_path(&self) -> PathBuf { + self.directory.join(AUDIT_FILE_NAME) + } + + /// Write one record, or fail. There is no third outcome. + pub fn append(&self, record: &AuthorizationRecord) -> Result<(), AuditWriteError> { + let line = self.encode(record)?; + + let _guard = self + .write_lock + .lock() + .map_err(|_| AuditWriteError("the audit log lock was poisoned".into()))?; + + self.ensure_directory()?; + let path = self.file_path(); + + let mut file = open_append(&path)?; + file.write_all(&line) + .map_err(|e| AuditWriteError(format!("short write: {e}")))?; + file.sync_all() + .map_err(|e| AuditWriteError(format!("fsync failed: {e}")))?; + + let end = file + .stream_position() + .map_err(|e| AuditWriteError(format!("could not locate the record just written: {e}")))?; + if end < line.len() as u64 { + return Err(AuditWriteError("could not locate the record just written".into())); + } + self.verify_read_back(&path, &line, end - line.len() as u64) + } + + // ── Private ─────────────────────────────────────────────────── + + fn encode(&self, record: &AuthorizationRecord) -> Result, AuditWriteError> { + let object = record.to_json(&(self.timestamp)()); + // serde_json orders object keys, which is what the Swift side asks for + // explicitly with `.sortedKeys`. The two daemons therefore write the + // same bytes for the same record. + let mut bytes = serde_json::to_vec(&object) + .map_err(|e| AuditWriteError(format!("record could not be serialized: {e}")))?; + // One record per line is the whole format, so a record that somehow + // carried a raw newline would corrupt the next one. JSON escaping already + // rules this out; the check is here so a future field cannot break it + // quietly. + if bytes.contains(&b'\n') { + return Err(AuditWriteError("record contains a line break".into())); + } + bytes.push(b'\n'); + Ok(bytes) + } + + fn ensure_directory(&self) -> Result<(), AuditWriteError> { + if self.directory.exists() { + if !self.directory.is_dir() { + return Err(AuditWriteError(format!( + "{} is not a directory", + self.directory.display() + ))); + } + return Ok(()); + } + std::fs::create_dir_all(&self.directory).map_err(|e| { + AuditWriteError(format!("cannot create {}: {e}", self.directory.display())) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &self.directory, + std::fs::Permissions::from_mode(0o700), + ); + } + Ok(()) + } + + /// Read the bytes back off the file. A write that returned success but left + /// nothing behind (a full disk that only reports at flush time, a file + /// swapped underneath us) has to be caught here or not at all. + fn verify_read_back( + &self, + path: &Path, + expected: &[u8], + offset: u64, + ) -> Result<(), AuditWriteError> { + let mut file = File::open(path) + .map_err(|e| AuditWriteError(format!("cannot re-open {}: {e}", path.display())))?; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| AuditWriteError(format!("cannot seek {}: {e}", path.display())))?; + let mut read_back = vec![0u8; expected.len()]; + file.read_exact(&mut read_back) + .map_err(|_| AuditWriteError("the record did not read back from disk".into()))?; + if read_back != expected { + return Err(AuditWriteError("the record did not read back from disk".into())); + } + Ok(()) + } +} + +fn open_append(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|e| AuditWriteError(format!("cannot open {}: {e}", path.display()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::TempDir; + + fn log_in(dir: &TempDir) -> AuthorizationAuditLog { + AuthorizationAuditLog::with_timestamp(dir.path().join("audit"), || { + "2026-08-30T12:34:56.789Z".to_string() + }) + } + + fn lines(log: &AuthorizationAuditLog) -> Vec { + let contents = std::fs::read_to_string(log.file_path()).expect("log should exist"); + contents + .lines() + .map(|line| serde_json::from_str(line).expect("each line is JSON")) + .collect() + } + + #[test] + fn a_decrypt_record_carries_the_agreed_fields() { + let dir = TempDir::new(); + let log = log_in(&dir); + + log.append( + &AuthorizationRecord::new( + AuthorizationKind::Decrypt, + "tty:1:2", + vec!["varlock-default".into()], + ) + .identity_id("default") + .payload_count(7) + .scope("session") + .requester(Some("node (pid 42)".into())), + ) + .expect("append should succeed"); + + let records = lines(&log); + assert_eq!(records.len(), 1); + let record = records[0].as_object().unwrap(); + assert_eq!(record["ts"], json!("2026-08-30T12:34:56.789Z")); + assert_eq!(record["event"], json!("decrypt-v2")); + assert_eq!(record["sessionId"], json!("tty:1:2")); + assert_eq!(record["keyIds"], json!(["varlock-default"])); + assert_eq!(record["payloadCount"], json!(7)); + assert_eq!(record["identityId"], json!("default")); + assert_eq!(record["scope"], json!("session")); + assert_eq!(record["requester"], json!("node (pid 42)")); + } + + #[test] + fn optional_fields_are_omitted_rather_than_null() { + let dir = TempDir::new(); + let log = log_in(&dir); + log.append(&AuthorizationRecord::new( + AuthorizationKind::Invalidate, + "*", + vec!["*".into()], + )) + .expect("append should succeed"); + + let record = lines(&log)[0].as_object().unwrap().clone(); + let mut keys: Vec<&str> = record.keys().map(|k| k.as_str()).collect(); + keys.sort(); + assert_eq!(keys, vec!["event", "keyIds", "payloadCount", "sessionId", "ts"]); + } + + #[test] + fn keys_are_written_in_sorted_order() { + let dir = TempDir::new(); + let log = log_in(&dir); + log.append( + &AuthorizationRecord::new(AuthorizationKind::Unlock, "s", vec!["k".into()]) + .identity_id("default") + .scope("once") + .requester(Some("bun".into())), + ) + .expect("append should succeed"); + + let raw = std::fs::read_to_string(log.file_path()).unwrap(); + let order: Vec<&str> = ["event", "identityId", "keyIds", "payloadCount", "requester", "scope", "sessionId", "ts"] + .into_iter() + .collect(); + let mut last = 0usize; + for key in order { + let at = raw.find(&format!("\"{key}\"")).expect("field should be present"); + assert!(at >= last, "{key} is out of order"); + last = at; + } + } + + #[test] + fn appends_accumulate_one_line_each() { + let dir = TempDir::new(); + let log = log_in(&dir); + for index in 0..5 { + log.append( + &AuthorizationRecord::new( + AuthorizationKind::Decrypt, + "s", + vec![format!("key-{index}")], + ) + .payload_count(index), + ) + .expect("append should succeed"); + } + assert_eq!(lines(&log).len(), 5); + } + + #[test] + fn the_directory_is_created_on_first_write() { + let dir = TempDir::new(); + let log = log_in(&dir); + assert!(!log.directory().exists()); + log.append(&AuthorizationRecord::new( + AuthorizationKind::Unlock, + "s", + vec!["k".into()], + )) + .expect("append should succeed"); + assert!(log.directory().is_dir()); + } + + #[cfg(unix)] + #[test] + fn the_log_is_readable_only_by_its_owner() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new(); + let log = log_in(&dir); + log.append(&AuthorizationRecord::new( + AuthorizationKind::Unlock, + "s", + vec!["k".into()], + )) + .expect("append should succeed"); + + let mode = std::fs::metadata(log.file_path()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777); + let dir_mode = std::fs::metadata(log.directory()).unwrap().permissions().mode(); + assert_eq!(dir_mode & 0o777, 0o700, "got {:o}", dir_mode & 0o777); + } + + #[test] + fn a_directory_path_blocked_by_a_file_fails_loudly() { + let dir = TempDir::new(); + let blocked = dir.path().join("audit"); + std::fs::write(&blocked, b"not a directory").unwrap(); + + let log = AuthorizationAuditLog::new(blocked); + let err = log + .append(&AuthorizationRecord::new( + AuthorizationKind::Decrypt, + "s", + vec!["k".into()], + )) + .expect_err("a file where the directory should be must fail"); + assert_eq!(err.code(), "AUDIT_WRITE_FAILED"); + assert!(err.to_string().contains("Refusing to release secrets")); + } + + #[test] + fn records_never_carry_secret_material() { + // A structural guard rather than a behavioural one: the record type has + // no field a plaintext or a ciphertext could be put into by accident. + let record = AuthorizationRecord::new( + AuthorizationKind::Decrypt, + "s", + vec!["k".into()], + ) + .payload_count(3); + let json = record.to_json("2026-08-30T12:34:56.789Z"); + let serialized = serde_json::to_string(&json).unwrap(); + assert!(!serialized.contains("plaintext")); + assert!(!serialized.contains("ciphertext")); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/clock.rs b/packages/encryption-binary-rust/src/identity_sessions/clock.rs new file mode 100644 index 000000000..d257bd56d --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/clock.rs @@ -0,0 +1,139 @@ +//! The two clocks a grant's lifetime is measured against. +//! +//! Wall-clock time is what a person reads ("expires at 4pm"), so it has to be +//! recorded. It is also settable: anything that trusted it alone could be given +//! more life by moving the system clock backwards. So every deadline carries a +//! monotonic twin taken at the same instant. +//! +//! The monotonic side must keep counting while the machine is asleep, or a +//! suspended laptop could hold a grant well past its real 12h, which is the +//! opposite of what the cap is for. `std::time::Instant` is not good enough on +//! its own for that: on Linux it reads `CLOCK_MONOTONIC`, which stops during +//! suspend. Each platform therefore gets the sleep-inclusive counter it has, +//! matching what the Swift daemon reads (`CLOCK_MONOTONIC_RAW` on Darwin). + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Epoch milliseconds. Settable, and only ever half of a deadline. +pub fn wall_now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Milliseconds on a counter that no one can set, and that sleep does not pause. +/// +/// The origin is arbitrary and differs per platform: only differences between +/// two readings mean anything. +pub fn monotonic_now_ms() -> i64 { + platform_monotonic_ms() +} + +/// Linux: `CLOCK_BOOTTIME` rather than `CLOCK_MONOTONIC`, because only the +/// former keeps counting across suspend. +#[cfg(target_os = "linux")] +fn platform_monotonic_ms() -> i64 { + clock_gettime_ms(libc::CLOCK_BOOTTIME) +} + +/// macOS (development and the shared unit tests): the same clock the Swift +/// daemon uses, so the two implementations measure lifetimes identically. +#[cfg(target_os = "macos")] +fn platform_monotonic_ms() -> i64 { + clock_gettime_ms(libc::CLOCK_MONOTONIC_RAW) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn clock_gettime_ms(clock_id: libc::clockid_t) -> i64 { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // Safety: `ts` is a valid, correctly sized output parameter. + let rc = unsafe { libc::clock_gettime(clock_id, &mut ts) }; + if rc != 0 { + // A failing clock_gettime would be extraordinary. Falling back to the + // process-start baseline keeps deadlines monotonic rather than letting + // a zero reading make every grant look brand new. + return fallback_monotonic_ms(); + } + // The widths of tv_sec and tv_nsec vary by platform and libc, so the casts + // are load-bearing on some targets and redundant on others. + #[allow(clippy::unnecessary_cast)] + let millis = (ts.tv_sec as i64) * 1000 + (ts.tv_nsec as i64) / 1_000_000; + millis +} + +/// Windows: `QueryInterruptTime` is the biased interrupt-time count, meaning it +/// includes time the machine spent asleep. `GetTickCount64` and +/// `QueryUnbiasedInterruptTime` both exclude it, so neither can be used here. +#[cfg(target_os = "windows")] +fn platform_monotonic_ms() -> i64 { + use windows::Win32::System::WindowsProgramming::QueryInterruptTime; + // Safety: takes no arguments and reads a kernel-maintained counter. + let interrupt_time = unsafe { QueryInterruptTime() }; + if interrupt_time == 0 { + return fallback_monotonic_ms(); + } + // 100-nanosecond units + (interrupt_time / 10_000) as i64 +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn platform_monotonic_ms() -> i64 { + fallback_monotonic_ms() +} + +/// Milliseconds since the first call, via `Instant`. Only a fallback: on some +/// platforms `Instant` pauses during suspend, which is exactly what the +/// platform-specific readings above avoid. +fn fallback_monotonic_ms() -> i64 { + use std::sync::OnceLock; + use std::time::Instant; + static BASE: OnceLock = OnceLock::new(); + let base = BASE.get_or_init(Instant::now); + base.elapsed().as_millis() as i64 +} + +/// The pair of clock readings a deadline is built from or checked against. +/// +/// Taken together so both halves describe the same instant. Injected as a +/// closure by the grant table, which is how the tests drive the two clocks +/// independently: moving only the wall clock is the one way to prove that +/// resetting the system clock cannot buy a grant more life. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClockReading { + pub wall: i64, + pub monotonic: i64, +} + +impl ClockReading { + pub fn now() -> Self { + Self { wall: wall_now_ms(), monotonic: monotonic_now_ms() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wall_clock_is_a_plausible_epoch_ms() { + // Later than 2020-01-01, which is enough to catch a unit mix-up + // (seconds vs milliseconds) without pinning the test to a date. + assert!(wall_now_ms() > 1_577_836_800_000); + } + + #[test] + fn monotonic_clock_does_not_go_backwards() { + let first = monotonic_now_ms(); + let second = monotonic_now_ms(); + assert!(second >= first, "{second} < {first}"); + } + + #[test] + fn monotonic_clock_advances_over_a_real_sleep() { + let before = monotonic_now_ms(); + std::thread::sleep(std::time::Duration::from_millis(25)); + let after = monotonic_now_ms(); + assert!(after - before >= 10, "advanced only {}ms", after - before); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/custody.rs b/packages/encryption-binary-rust/src/identity_sessions/custody.rs new file mode 100644 index 000000000..e87ab5bd1 --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/custody.rs @@ -0,0 +1,123 @@ +//! The real device-key half of an unlock. +//! +//! Everything platform-specific about holding an identity key lives here: which +//! device key unwraps it, and what the user has to do first. The session rules +//! themselves are in [`super::manager`] and never touch a TPM, a keyring, or a +//! fingerprint reader, which is what makes them testable everywhere. +//! +//! Custody chain, unchanged from the macOS design: +//! +//! device key (NCrypt/TPM or DPAPI on Windows, TPM2 or Secret Service on +//! Linux) -> identity key -> values +//! +//! The wrap blob in the identity file is a v1 ECIES payload encrypted to the +//! device public key, so unwrapping it is an ordinary device decrypt. The +//! presence check is separate from that decrypt on both platforms (unlike the +//! Secure Enclave, where the two are the same operation), so it is run first, +//! once per unlock, before any unwrapping happens. + +use crate::crypto; +use crate::key_store; +use crate::secure_mem::GuardedBuffer; +use zeroize::Zeroize; + +use super::manager::{CustodyBackend, SessionError}; + +/// Unwraps identity keys through the platform key store. +pub struct KeyStoreCustody { + /// Whether this machine has any way to check for a person at all. Cached at + /// construction: it is a property of the machine, not of the request, and + /// re-probing it per unlock would put a Windows Hello availability call on + /// the hot path. + presence_available: bool, +} + +impl Default for KeyStoreCustody { + fn default() -> Self { + Self::new() + } +} + +impl KeyStoreCustody { + pub fn new() -> Self { + Self { presence_available: key_store::get_platform_info().biometric_available } + } +} + +impl CustodyBackend for KeyStoreCustody { + fn unwrap_identity_scalar( + &self, + key_id: &str, + wrap: &[u8], + ) -> Result { + let (mut device_private_der, _device_public) = + key_store::load_key(key_id).map_err(SessionError::Crypto)?; + let device_key = crypto::secret_key_from_pkcs8(&device_private_der); + device_private_der.zeroize(); + let device_key = device_key.map_err(SessionError::Crypto)?; + + // The wrap is a device payload: the identity key encrypted to this + // machine's device key by the TypeScript side. + let mut identity_der = + crypto::decrypt_payload(&device_key, wrap, &[crypto::DEVICE_PAYLOAD_VERSION]) + .map_err(|_| { + SessionError::Crypto(format!( + "Could not unwrap the identity key with device key \"{key_id}\"" + )) + })?; + + // Hold the 32-byte scalar rather than the PKCS#8 DER: the scalar is the + // whole secret, it is a fixed size, and a fixed size is what a guarded + // buffer can promise not to reallocate. + let scalar = key_store::scalar::pkcs8_to_raw_scalar(&identity_der); + identity_der.zeroize(); + let scalar = scalar.ok_or_else(|| { + SessionError::Crypto("The unwrapped identity key is not a P-256 private key".into()) + })?; + + let mut scalar = scalar; + let guarded = GuardedBuffer::take_vec(&mut scalar); + Ok(guarded) + } + + fn requires_presence(&self, key_id: &str) -> bool { + // Two independent conditions. A key created with `--no-auth` (CI) has + // nothing to ask about, and a machine with no Hello, no polkit, and no + // PAM factor has nothing to ask with. Failing the unlock in the second + // case would just make the feature unavailable on those machines, which + // is not a security gain: the key material is protected at rest either + // way. + self.presence_available && key_store::key_requires_auth(key_id) + } + + fn verify_presence(&self, reason: &str) -> Result { + verify_user_presence(reason) + } +} + +/// Ask the platform to check that a person is there. +/// +/// Windows shows the Hello dialog (face, fingerprint, or PIN). Linux goes +/// through polkit, which delegates to PAM, so the factor is whatever the user +/// has configured: fingerprint, face, a security key, or a password. +pub fn verify_user_presence(reason: &str) -> Result { + #[cfg(target_os = "windows")] + { + crate::key_store::windows_hello::verify_user(reason) + } + + #[cfg(target_os = "linux")] + { + let _ = reason; + crate::key_store::polkit::check_authorization() + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + // macOS runs the Swift daemon, so this build is a development one. There + // is nothing to ask with, and `requires_presence` already returns false + // here, so this is unreachable in practice. + let _ = reason; + Ok(true) + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/grants.rs b/packages/encryption-binary-rust/src/identity_sessions/grants.rs new file mode 100644 index 000000000..34068ef60 --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/grants.rs @@ -0,0 +1,1078 @@ +//! Grant bookkeeping for identity-backed sessions. +//! +//! A grant is what makes the daemon's holding of an identity key legitimate. It +//! is keyed by (sessionId x keyId): the same session unlocking a different key +//! is a separate grant, and the same key in a different session is too. The +//! session id comes from the connecting process, so a grant cannot be borrowed +//! by an unrelated session on the same machine. +//! +//! This is a port of `SessionGrants.swift`, kept deliberately close to it so the +//! two daemons cannot drift on lifetime rules. Like the Swift original it is +//! pure bookkeeping with an injected clock, so every rule here is unit testable +//! without a TPM, a keyring, or a daemon. Key material lives in +//! [`super::manager::IdentitySessionManager`], which drives its erase decisions +//! off what this table reports. + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use super::clock::ClockReading; +use super::lock_policy::{SessionLockEvent, SessionLockPolicy}; + +/// Hard ceiling on any grant, whatever scope or duration was asked for. +/// A `session` grant on a session that never ends still expires here. +pub const MAX_GRANT_MS: i64 = 12 * 60 * 60 * 1000; + +/// How long a grant survives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionGrantScope { + /// a single decrypt call, then the grant is spent + Once, + /// until the session it is bound to ends, or the cap is hit + Session, + /// a caller-chosen window, still bounded by the cap + Duration, +} + +impl SessionGrantScope { + pub fn wire_value(&self) -> &'static str { + match self { + SessionGrantScope::Once => "once", + SessionGrantScope::Session => "session", + SessionGrantScope::Duration => "duration", + } + } + + pub fn from_wire_value(value: Option<&str>) -> Option { + match value? { + "once" => Some(SessionGrantScope::Once), + "session" => Some(SessionGrantScope::Session), + "duration" => Some(SessionGrantScope::Duration), + _ => None, + } + } +} + +/// Identifies one grant. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionGrantRef { + pub session_id: String, + pub key_id: String, +} + +impl SessionGrantRef { + pub fn new(session_id: impl Into, key_id: impl Into) -> Self { + Self { session_id: session_id.into(), key_id: key_id.into() } + } +} + +/// When a grant runs out, measured on both clocks at once. +/// +/// Whichever clock runs out first ends the grant. Under a normal clock the two +/// are indistinguishable; they only diverge when someone moves the system clock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GrantDeadline { + /// epoch ms + pub wall: i64, + /// monotonic ms, from [`super::clock::monotonic_now_ms`] + pub monotonic: i64, +} + +impl GrantDeadline { + /// A deadline `duration_ms` out from the clock readings given. + pub fn after(duration_ms: i64, now: ClockReading) -> Self { + Self { + wall: now.wall.saturating_add(duration_ms), + monotonic: now.monotonic.saturating_add(duration_ms), + } + } + + pub fn is_expired(&self, now: ClockReading) -> bool { + self.wall <= now.wall || self.monotonic <= now.monotonic + } + + /// Time left, on whichever clock has less of it. Never negative. + /// + /// The monotonic side governs in practice; the wall side only becomes the + /// smaller of the two after the system clock jumps forward, and in that case + /// the grant really does have less time than the monotonic clock thinks, so + /// reporting the smaller number keeps the answer honest. + pub fn remaining_ms(&self, now: ClockReading) -> i64 { + let by_wall = self.wall.saturating_sub(now.wall); + let by_monotonic = self.monotonic.saturating_sub(now.monotonic); + by_wall.min(by_monotonic).max(0) + } + + /// The earlier of two deadlines, taken per clock. + /// + /// Element-wise rather than picking one whole deadline: clamping a grant to + /// its session cap has to clamp both halves, or a caller could ask for a + /// long window and keep the session's later monotonic deadline. + pub fn earliest(lhs: Self, rhs: Self) -> Self { + Self { + wall: lhs.wall.min(rhs.wall), + monotonic: lhs.monotonic.min(rhs.monotonic), + } + } +} + +/// A grant as the daemon reports it back. Never includes key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionGrantInfo { + pub session_id: String, + pub key_id: String, + pub identity_id: String, + pub scope: SessionGrantScope, + /// epoch ms + pub granted_at: i64, + /// epoch ms; always set, since every scope is capped. Display only: what + /// actually ends the grant is `remaining_ms`, which the table measures on + /// the monotonic clock as well as this one. + pub expires_at: i64, + /// ms of life left, as the table measured it when it built this record. + /// + /// Not derived from `expires_at` by the reader: the wall clock can be moved, + /// and this number cannot be. + pub remaining_ms: i64, + /// epoch ms of the last decrypt this grant served, absent until first use + pub last_used_at: Option, + /// epoch ms when the session this grant belongs to was unlocked + pub session_unlocked_at: i64, + /// epoch ms when the session's hard cap runs out + pub session_expires_at: i64, + /// ms left on the session's hard cap, measured the same way as + /// `remaining_ms`. Never shorter, since every grant is clamped to the cap. + pub session_remaining_ms: i64, + /// which system events erase this session, as resolved at unlock time + pub lock_on: SessionLockPolicy, + /// how many decrypts this grant has served + pub use_count: u64, +} + +impl SessionGrantInfo { + /// The wire shape, field for field identical to the Swift daemon's, so a + /// client cannot tell the two apart. + pub fn to_json(&self) -> Value { + let mut object = json!({ + "sessionId": self.session_id, + "keyId": self.key_id, + "identityId": self.identity_id, + "scope": self.scope.wire_value(), + "grantedAt": self.granted_at, + "expiresAt": self.expires_at, + "sessionUnlockedAt": self.session_unlocked_at, + "sessionExpiresAt": self.session_expires_at, + "sessionExpiresInMs": self.session_remaining_ms, + "lockOn": self.lock_on.wire_value(), + "useCount": self.use_count, + "expiresInMs": self.remaining_ms, + // How much of the key this grant opens. Always the whole key here. + // + // The narrow answer is one a person picks on an approval panel, and + // this daemon has no panel: it never prompts, so there is nobody to + // pick it. Reported anyway, and reported honestly, because a client + // reading a grant should never have to work out whether a missing + // field means "the whole key" or "an old daemon". + "breadth": "key", + // The vault a broad approval stops at. One implicit local vault + // everywhere today; named on the wire so the boundary is a field a + // client can read rather than an assumption it has to make. + "vaultId": "local", + }); + if let (Some(last_used_at), Some(map)) = (self.last_used_at, object.as_object_mut()) { + map.insert("lastUsedAt".into(), json!(last_used_at)); + } + object + } +} + +/// Why a decrypt could not be served. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionGrantError { + NoGrant(SessionGrantRef), + Expired(SessionGrantRef), +} + +impl SessionGrantError { + /// Stable code the TS client can branch on without matching message text. + pub fn code(&self) -> &'static str { + match self { + SessionGrantError::NoGrant(_) => "NO_SESSION_GRANT", + SessionGrantError::Expired(_) => "SESSION_GRANT_EXPIRED", + } + } +} + +impl std::fmt::Display for SessionGrantError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionGrantError::NoGrant(r) => write!( + f, + "No unlock session for key \"{}\"; run an unlock first", + r.key_id + ), + SessionGrantError::Expired(r) => write!( + f, + "The unlock session for key \"{}\" has expired; unlock again", + r.key_id + ), + } + } +} + +/// What changed after a mutation, so the caller knows when to erase key material. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionGrantChange { + /// how many grants were dropped + pub dropped: usize, + /// sessions that no longer hold any live grant, and whose key should be erased + pub closed_sessions: Vec, +} + +#[derive(Debug, Clone)] +struct Grant { + identity_id: String, + scope: SessionGrantScope, + granted_at: i64, + deadline: GrantDeadline, + last_used_at: Option, + use_count: u64, +} + +#[derive(Debug, Clone)] +struct SessionState { + unlocked_at: i64, + /// `unlocked_at` + cap on both clocks; every grant in the session is clamped to this + deadline: GrantDeadline, + /// Which system events erase this session. Held per session rather than + /// globally, so one session can outlive a screen lock that ends another. + lock_on: SessionLockPolicy, + /// keyed by key id + grants: HashMap, +} + +/// The live grant table. +/// +/// Not internally synchronized: the manager that owns it holds it behind a +/// mutex, the same way the Swift version runs everything on one queue. +pub struct SessionGrantTable { + sessions: HashMap, + clock: Box ClockReading + Send + Sync>, +} + +impl Default for SessionGrantTable { + fn default() -> Self { + Self::new() + } +} + +impl SessionGrantTable { + pub fn new() -> Self { + Self::with_clock(ClockReading::now) + } + + /// Build a table over an injected clock, so tests can move either half of it. + pub fn with_clock(clock: impl Fn() -> ClockReading + Send + Sync + 'static) -> Self { + Self { sessions: HashMap::new(), clock: Box::new(clock) } + } + + pub fn now(&self) -> ClockReading { + (self.clock)() + } + + // ── Session lifetime ────────────────────────────────────────── + + /// Whether the session still holds at least one live grant, meaning the + /// daemon is still holding its identity key. + /// + /// The daemon asks [`SessionGrantTable::has_live_sessions`] instead, since it + /// only cares whether it is holding anything at all. This narrower question + /// is what the lifetime tests are written against. + #[cfg(test)] + pub fn is_session_live(&mut self, session_id: &str) -> bool { + self.prune_expired(); + self.sessions + .get(session_id) + .is_some_and(|state| !state.grants.is_empty()) + } + + /// Whether any session is live. The daemon refuses to idle-quit while this holds. + pub fn has_live_sessions(&mut self) -> bool { + self.prune_expired(); + self.sessions.values().any(|state| !state.grants.is_empty()) + } + + pub fn live_session_ids(&mut self) -> Vec { + self.prune_expired(); + let mut ids: Vec = self + .sessions + .iter() + .filter(|(_, state)| !state.grants.is_empty()) + .map(|(id, _)| id.clone()) + .collect(); + ids.sort(); + ids + } + + /// The live grant for one (session x key), if there is one. + /// + /// Read-only, and it charges nothing. + pub fn live_grant(&mut self, grant_ref: &SessionGrantRef) -> Option { + self.prune_expired(); + let state = self.sessions.get(&grant_ref.session_id)?; + let grant = state.grants.get(&grant_ref.key_id)?; + Some(self.info(grant_ref, grant, state)) + } + + /// The lock policy a live session is running under. See + /// [`super::manager::IdentitySessionManager::lock_policy`]. + #[cfg(test)] + pub fn lock_policy(&mut self, session_id: &str) -> Option { + self.prune_expired(); + self.sessions.get(session_id).map(|state| state.lock_on) + } + + // ── Granting ────────────────────────────────────────────────── + + /// Record a grant, opening the session if this is its first one. + /// + /// The session's cap starts at its first unlock, so a caller cannot extend + /// its hold past 12h by re-granting the same key over and over. + pub fn grant( + &mut self, + grant_ref: &SessionGrantRef, + identity_id: &str, + scope: SessionGrantScope, + duration_ms: Option, + lock_on: SessionLockPolicy, + ) -> SessionGrantInfo { + self.prune_expired(); + let now = self.now(); + + let session_deadline = match self.sessions.get_mut(&grant_ref.session_id) { + Some(existing) => { + // The most recent unlock sets the session's lock policy, so + // re-unlocking is how someone changes their mind about it. + existing.lock_on = lock_on; + existing.deadline + } + None => { + let deadline = GrantDeadline::after(MAX_GRANT_MS, now); + self.sessions.insert( + grant_ref.session_id.clone(), + SessionState { + unlocked_at: now.wall, + deadline, + lock_on, + grants: HashMap::new(), + }, + ); + deadline + } + }; + + let requested_deadline = match scope { + SessionGrantScope::Once | SessionGrantScope::Session => session_deadline, + SessionGrantScope::Duration => { + let window = duration_ms.unwrap_or(MAX_GRANT_MS).clamp(0, MAX_GRANT_MS); + GrantDeadline::after(window, now) + } + }; + + let grant = Grant { + identity_id: identity_id.to_string(), + scope, + granted_at: now.wall, + // never past the session cap, whatever was asked for, on either clock + deadline: GrantDeadline::earliest(requested_deadline, session_deadline), + last_used_at: None, + use_count: 0, + }; + + let state = self + .sessions + .get_mut(&grant_ref.session_id) + .expect("session was just inserted"); + state.grants.insert(grant_ref.key_id.clone(), grant.clone()); + let state = &self.sessions[&grant_ref.session_id]; + self.info(grant_ref, &grant, state) + } + + // ── Using ───────────────────────────────────────────────────── + + /// Check a grant and charge one use against it. + /// + /// A `once` grant is spent here: it serves exactly one `decrypt-v2` call, + /// however many payloads that call carries, and is then dropped. + pub fn consume( + &mut self, + grant_ref: &SessionGrantRef, + ) -> Result<(SessionGrantInfo, SessionGrantChange), SessionGrantError> { + // Deliberately no prune first: an expired grant should still be found + // here so the caller is told the session ran out, not that it never + // existed. + let now = self.now(); + + let Some(state) = self.sessions.get(&grant_ref.session_id) else { + return Err(SessionGrantError::NoGrant(grant_ref.clone())); + }; + let Some(grant) = state.grants.get(&grant_ref.key_id) else { + return Err(SessionGrantError::NoGrant(grant_ref.clone())); + }; + + if grant.deadline.is_expired(now) || state.deadline.is_expired(now) { + // Drop it here rather than leaving a dead row for the next prune. + self.drop_grant(grant_ref); + return Err(SessionGrantError::Expired(grant_ref.clone())); + } + + let state = self + .sessions + .get_mut(&grant_ref.session_id) + .expect("session was just read"); + let grant = state + .grants + .get_mut(&grant_ref.key_id) + .expect("grant was just read"); + grant.use_count += 1; + grant.last_used_at = Some(now.wall); + let served_grant = grant.clone(); + let scope = served_grant.scope; + + let state = &self.sessions[&grant_ref.session_id]; + let served = self.info(grant_ref, &served_grant, state); + + if scope == SessionGrantScope::Once { + let change = self.drop_grant(grant_ref); + return Ok((served, change)); + } + Ok((served, SessionGrantChange::default())) + } + + // ── Listing ─────────────────────────────────────────────────── + + /// Every live grant, oldest session first, stable within a session by key id. + pub fn list(&mut self) -> Vec { + self.prune_expired(); + let mut out: Vec = Vec::new(); + for (session_id, state) in &self.sessions { + for (key_id, grant) in &state.grants { + let grant_ref = SessionGrantRef::new(session_id.clone(), key_id.clone()); + out.push(self.info(&grant_ref, grant, state)); + } + } + out.sort_by(|a, b| { + a.session_unlocked_at + .cmp(&b.session_unlocked_at) + .then_with(|| a.session_id.cmp(&b.session_id)) + .then_with(|| a.key_id.cmp(&b.key_id)) + }); + out + } + + // ── Invalidating ────────────────────────────────────────────── + + /// Drop grants. + /// + /// Passing neither argument drops every grant, which is what the + /// argument-less `invalidate-session` has always done. Naming a session + /// drops that session's grants; naming both drops exactly one. + pub fn invalidate( + &mut self, + session_id: Option<&str>, + key_id: Option<&str>, + ) -> SessionGrantChange { + let mut dropped = 0usize; + let mut closed: Vec = Vec::new(); + + let target_sessions: Vec = self + .sessions + .keys() + .filter(|sid| session_id.is_none_or(|wanted| wanted == sid.as_str())) + .cloned() + .collect(); + + for sid in target_sessions { + let Some(state) = self.sessions.get_mut(&sid) else { continue }; + let target_keys: Vec = state + .grants + .keys() + .filter(|kid| key_id.is_none_or(|wanted| wanted == kid.as_str())) + .cloned() + .collect(); + for kid in target_keys { + if state.grants.remove(&kid).is_some() { + dropped += 1; + } + } + if state.grants.is_empty() { + self.sessions.remove(&sid); + closed.push(sid); + } + } + + closed.sort(); + SessionGrantChange { dropped, closed_sessions: closed } + } + + /// Drop the sessions whose own lock policy says this event ends them. + /// + /// Each session is judged individually, so a `screenLock` session can be + /// erased by the same event a `none` session in the same daemon shrugs off. + pub fn invalidate_on_lock_event(&mut self, event: SessionLockEvent) -> SessionGrantChange { + let mut dropped = 0usize; + let mut closed: Vec = Vec::new(); + + let doomed: Vec = self + .sessions + .iter() + .filter(|(_, state)| state.lock_on.erases_on(event)) + .map(|(sid, _)| sid.clone()) + .collect(); + + for sid in doomed { + if let Some(state) = self.sessions.remove(&sid) { + dropped += state.grants.len(); + closed.push(sid); + } + } + + closed.sort(); + SessionGrantChange { dropped, closed_sessions: closed } + } + + /// Drop everything whose time is up, and report the sessions that closed. + pub fn prune_expired(&mut self) -> SessionGrantChange { + let now = self.now(); + let mut dropped = 0usize; + let mut closed: Vec = Vec::new(); + + let session_ids: Vec = self.sessions.keys().cloned().collect(); + for sid in session_ids { + let Some(state) = self.sessions.get_mut(&sid) else { continue }; + + if state.deadline.is_expired(now) { + dropped += state.grants.len(); + self.sessions.remove(&sid); + closed.push(sid); + continue; + } + + let stale: Vec = state + .grants + .iter() + .filter(|(_, grant)| grant.deadline.is_expired(now)) + .map(|(kid, _)| kid.clone()) + .collect(); + for kid in stale { + state.grants.remove(&kid); + dropped += 1; + } + if state.grants.is_empty() { + self.sessions.remove(&sid); + closed.push(sid); + } + } + + closed.sort(); + SessionGrantChange { dropped, closed_sessions: closed } + } + + // ── Private ─────────────────────────────────────────────────── + + fn drop_grant(&mut self, grant_ref: &SessionGrantRef) -> SessionGrantChange { + let Some(state) = self.sessions.get_mut(&grant_ref.session_id) else { + return SessionGrantChange::default(); + }; + if state.grants.remove(&grant_ref.key_id).is_none() { + return SessionGrantChange::default(); + } + if state.grants.is_empty() { + self.sessions.remove(&grant_ref.session_id); + return SessionGrantChange { + dropped: 1, + closed_sessions: vec![grant_ref.session_id.clone()], + }; + } + SessionGrantChange { dropped: 1, closed_sessions: Vec::new() } + } + + fn info( + &self, + grant_ref: &SessionGrantRef, + grant: &Grant, + session: &SessionState, + ) -> SessionGrantInfo { + let now = self.now(); + SessionGrantInfo { + session_id: grant_ref.session_id.clone(), + key_id: grant_ref.key_id.clone(), + identity_id: grant.identity_id.clone(), + scope: grant.scope, + granted_at: grant.granted_at, + expires_at: grant.deadline.wall, + remaining_ms: grant.deadline.remaining_ms(now), + last_used_at: grant.last_used_at, + session_unlocked_at: session.unlocked_at, + session_expires_at: session.deadline.wall, + session_remaining_ms: session.deadline.remaining_ms(now), + lock_on: session.lock_on, + use_count: grant.use_count, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::Arc; + + /// A clock whose halves move independently, so a test can advance wall time + /// without advancing monotonic time (and the other way round). + #[derive(Clone, Default)] + struct TestClock { + wall: Arc, + monotonic: Arc, + } + + impl TestClock { + fn start() -> Self { + Self { + wall: Arc::new(AtomicI64::new(1_700_000_000_000)), + monotonic: Arc::new(AtomicI64::new(5_000)), + } + } + + fn reading(&self) -> ClockReading { + ClockReading { + wall: self.wall.load(Ordering::SeqCst), + monotonic: self.monotonic.load(Ordering::SeqCst), + } + } + + /// Move both halves, the way real time passes. + fn advance(&self, ms: i64) { + self.wall.fetch_add(ms, Ordering::SeqCst); + self.monotonic.fetch_add(ms, Ordering::SeqCst); + } + + fn set_wall(&self, value: i64) { + self.wall.store(value, Ordering::SeqCst); + } + + fn advance_monotonic(&self, ms: i64) { + self.monotonic.fetch_add(ms, Ordering::SeqCst); + } + + fn table(&self) -> SessionGrantTable { + let clock = self.clone(); + SessionGrantTable::with_clock(move || clock.reading()) + } + } + + fn a_ref() -> SessionGrantRef { + SessionGrantRef::new("tty:1:2", "varlock-default") + } + + #[test] + fn a_granted_key_is_live_and_consumable() { + let clock = TestClock::start(); + let mut table = clock.table(); + let info = table.grant( + &a_ref(), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + assert_eq!(info.use_count, 0); + assert_eq!(info.last_used_at, None); + assert!(table.is_session_live("tty:1:2")); + + let (served, change) = table.consume(&a_ref()).expect("grant should serve"); + assert_eq!(served.use_count, 1); + assert_eq!(served.last_used_at, Some(clock.reading().wall)); + assert_eq!(change.dropped, 0); + assert!(table.is_session_live("tty:1:2")); + } + + #[test] + fn an_unknown_key_reports_no_grant() { + let mut table = TestClock::start().table(); + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "NO_SESSION_GRANT"); + } + + #[test] + fn a_once_grant_is_spent_by_a_single_call() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Once, None, SessionLockPolicy::Sleep); + + let (_, change) = table.consume(&a_ref()).expect("first call should serve"); + assert_eq!(change.dropped, 1); + assert_eq!(change.closed_sessions, vec!["tty:1:2".to_string()]); + + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "NO_SESSION_GRANT"); + assert!(!table.has_live_sessions()); + } + + #[test] + fn a_duration_grant_expires_on_its_own_window() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &a_ref(), + "default", + SessionGrantScope::Duration, + Some(60_000), + SessionLockPolicy::Sleep, + ); + + clock.advance(59_000); + assert!(table.consume(&a_ref()).is_ok()); + + clock.advance(2_000); + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "SESSION_GRANT_EXPIRED"); + } + + #[test] + fn a_duration_longer_than_the_cap_is_clamped_to_it() { + let clock = TestClock::start(); + let mut table = clock.table(); + let info = table.grant( + &a_ref(), + "default", + SessionGrantScope::Duration, + Some(MAX_GRANT_MS * 10), + SessionLockPolicy::Sleep, + ); + assert_eq!(info.remaining_ms, MAX_GRANT_MS); + assert_eq!(info.expires_at, info.session_expires_at); + } + + #[test] + fn a_session_grant_still_dies_at_the_twelve_hour_cap() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::None); + + clock.advance(MAX_GRANT_MS - 1_000); + assert!(table.consume(&a_ref()).is_ok()); + + clock.advance(2_000); + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "SESSION_GRANT_EXPIRED"); + assert!(!table.has_live_sessions()); + } + + #[test] + fn re_granting_does_not_extend_the_session_cap() { + let clock = TestClock::start(); + let mut table = clock.table(); + let first = table.grant( + &a_ref(), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + clock.advance(60 * 60 * 1000); + let second = table.grant( + &SessionGrantRef::new("tty:1:2", "other-key"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + assert_eq!(first.session_expires_at, second.session_expires_at); + assert!(second.remaining_ms < MAX_GRANT_MS); + } + + #[test] + fn winding_the_wall_clock_back_cannot_buy_more_life() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &a_ref(), + "default", + SessionGrantScope::Duration, + Some(60_000), + SessionLockPolicy::Sleep, + ); + + // Only the monotonic half moves past the deadline, and the wall clock is + // dragged back a year. The grant must still be over. + clock.advance_monotonic(61_000); + clock.set_wall(1_600_000_000_000); + + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "SESSION_GRANT_EXPIRED"); + } + + #[test] + fn winding_the_wall_clock_forward_ends_a_grant_early() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &a_ref(), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + // The wall clock alone jumps past the cap. Reporting the smaller of the + // two remaining values is what makes this end the grant. + clock.set_wall(clock.reading().wall + MAX_GRANT_MS + 1); + let err = table.consume(&a_ref()).unwrap_err(); + assert_eq!(err.code(), "SESSION_GRANT_EXPIRED"); + } + + #[test] + fn a_duration_grant_keeps_the_sessions_monotonic_cap() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::Sleep); + + // Asking for a window longer than what is left of the session must not + // hand back the later deadline on either clock. + clock.advance(MAX_GRANT_MS - 10_000); + let info = table.grant( + &SessionGrantRef::new("tty:1:2", "other-key"), + "default", + SessionGrantScope::Duration, + Some(MAX_GRANT_MS), + SessionLockPolicy::Sleep, + ); + assert!(info.remaining_ms <= 10_000); + } + + #[test] + fn grants_are_scoped_to_their_own_session_and_key() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::Sleep); + + let other_session = SessionGrantRef::new("tty:9:9", "varlock-default"); + assert_eq!( + table.consume(&other_session).unwrap_err().code(), + "NO_SESSION_GRANT" + ); + + let other_key = SessionGrantRef::new("tty:1:2", "another-key"); + assert_eq!( + table.consume(&other_key).unwrap_err().code(), + "NO_SESSION_GRANT" + ); + } + + #[test] + fn invalidating_one_key_leaves_the_rest_of_the_session() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::Sleep); + table.grant( + &SessionGrantRef::new("tty:1:2", "second"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + let change = table.invalidate(Some("tty:1:2"), Some("second")); + assert_eq!(change.dropped, 1); + assert!(change.closed_sessions.is_empty()); + assert!(table.is_session_live("tty:1:2")); + } + + #[test] + fn invalidating_with_no_arguments_drops_everything() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::Sleep); + table.grant( + &SessionGrantRef::new("tty:9:9", "varlock-default"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + let change = table.invalidate(None, None); + assert_eq!(change.dropped, 2); + assert_eq!(change.closed_sessions, vec!["tty:1:2".to_string(), "tty:9:9".to_string()]); + assert!(!table.has_live_sessions()); + } + + #[test] + fn a_lock_event_only_erases_the_sessions_whose_policy_says_so() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &SessionGrantRef::new("locks", "k"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::ScreenLock, + ); + table.grant( + &SessionGrantRef::new("sleeps", "k"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + table.grant( + &SessionGrantRef::new("never", "k"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::None, + ); + + let change = table.invalidate_on_lock_event(SessionLockEvent::ScreenLock); + assert_eq!(change.closed_sessions, vec!["locks".to_string()]); + assert!(table.is_session_live("sleeps")); + assert!(table.is_session_live("never")); + + let change = table.invalidate_on_lock_event(SessionLockEvent::Sleep); + assert_eq!(change.closed_sessions, vec!["sleeps".to_string()]); + assert!(table.is_session_live("never")); + } + + #[test] + fn re_unlocking_replaces_the_sessions_lock_policy() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::None); + assert_eq!(table.lock_policy("tty:1:2"), Some(SessionLockPolicy::None)); + + table.grant( + &a_ref(), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::ScreenLock, + ); + assert_eq!(table.lock_policy("tty:1:2"), Some(SessionLockPolicy::ScreenLock)); + } + + #[test] + fn listing_is_ordered_by_session_age_then_key() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &SessionGrantRef::new("older", "b"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + table.grant( + &SessionGrantRef::new("older", "a"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + clock.advance(1_000); + table.grant( + &SessionGrantRef::new("newer", "a"), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + + let listed: Vec<(String, String)> = table + .list() + .into_iter() + .map(|info| (info.session_id, info.key_id)) + .collect(); + assert_eq!( + listed, + vec![ + ("older".to_string(), "a".to_string()), + ("older".to_string(), "b".to_string()), + ("newer".to_string(), "a".to_string()), + ] + ); + } + + #[test] + fn pruning_closes_sessions_that_ran_out() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant( + &a_ref(), + "default", + SessionGrantScope::Duration, + Some(1_000), + SessionLockPolicy::Sleep, + ); + + clock.advance(2_000); + let change = table.prune_expired(); + assert_eq!(change.dropped, 1); + assert_eq!(change.closed_sessions, vec!["tty:1:2".to_string()]); + } + + #[test] + fn the_wire_shape_matches_the_swift_daemons() { + let clock = TestClock::start(); + let mut table = clock.table(); + table.grant(&a_ref(), "default", SessionGrantScope::Session, None, SessionLockPolicy::Sleep); + let (served, _) = table.consume(&a_ref()).unwrap(); + let json = served.to_json(); + let object = json.as_object().unwrap(); + + let mut keys: Vec<&str> = object.keys().map(|k| k.as_str()).collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "breadth", + "expiresAt", + "expiresInMs", + "grantedAt", + "identityId", + "keyId", + "lastUsedAt", + "lockOn", + "scope", + "sessionExpiresAt", + "sessionExpiresInMs", + "sessionId", + "sessionUnlockedAt", + "useCount", + "vaultId", + ] + ); + assert_eq!(object["scope"], json!("session")); + assert_eq!(object["lockOn"], json!("sleep")); + assert_eq!(object["useCount"], json!(1)); + assert_eq!(object["breadth"], json!("key")); + assert_eq!(object["vaultId"], json!("local")); + } + + #[test] + fn a_grant_that_has_never_been_used_omits_last_used_at() { + let clock = TestClock::start(); + let mut table = clock.table(); + let info = table.grant( + &a_ref(), + "default", + SessionGrantScope::Session, + None, + SessionLockPolicy::Sleep, + ); + assert!(info.to_json().as_object().unwrap().get("lastUsedAt").is_none()); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/identity_store.rs b/packages/encryption-binary-rust/src/identity_sessions/identity_store.rs new file mode 100644 index 000000000..0d66edec9 --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/identity_store.rs @@ -0,0 +1,289 @@ +//! Reads the identity files the TypeScript side writes. +//! +//! An identity is a software P-256 key pair whose private key is never stored in +//! the clear: it is ECIES-wrapped to one or more device keys, so unwrapping it +//! goes through whatever gate the device backend applies. The file format is +//! owned by `packages/varlock/src/lib/local-encrypt/identity.ts`: +//! +//! ```json +//! { "version": 1, "id": "default", "publicKey": "...", "wraps": { "": "" }, "createdAt": "..." } +//! ``` +//! +//! Only ciphertext and public keys live here, so ordinary `String` handling is +//! fine. The unwrapped private key never passes through this module. + +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub const IDENTITY_FILE_VERSION: i64 = 1; +pub const DEFAULT_IDENTITY_ID: &str = "default"; + +/// Where the user-level state this daemon reads and writes lives. +/// +/// Held as a value rather than read from a global so the tests can point a +/// manager at a temp directory without touching the developer's real one. +#[derive(Debug, Clone)] +pub struct SessionPaths { + user_dir: PathBuf, +} + +impl SessionPaths { + /// The real locations, matching `getUserVarlockDir()` in the TS library and + /// `IdentityStore.userVarlockDir` on the Swift side. + pub fn from_user_config_dir() -> Self { + Self { user_dir: crate::key_store::get_config_dir() } + } + + /// Point the daemon's state at somewhere else. Only the tests do this: the + /// real locations are fixed, and a daemon that could be told where to keep + /// its audit log would be a daemon whose log could be redirected to + /// /dev/null. + #[cfg(test)] + pub fn with_user_dir(user_dir: impl Into) -> Self { + Self { user_dir: user_dir.into() } + } + + #[cfg(test)] + pub fn user_dir(&self) -> &Path { + &self.user_dir + } + + pub fn identity_file(&self, identity_id: &str) -> PathBuf { + self.user_dir.join("identities").join(format!("{identity_id}.json")) + } + + /// The user-level config file varlock already keeps (telemetry settings live + /// here too). Machine-wide, never project-level: a project must not be able + /// to weaken how long this machine holds keys. + pub fn machine_config_file(&self) -> PathBuf { + self.user_dir.join("config.json") + } + + /// Where the append-only authorization log lives. Under the user varlock dir + /// so it inherits that directory's owner-only access. + pub fn audit_dir(&self) -> PathBuf { + self.user_dir.join("audit") + } + + /// Read the config file's contents, or `None` when there is nothing to read. + /// + /// Read fresh at each unlock rather than cached or watched, so editing the + /// file takes effect on the next unlock with no daemon restart. + pub fn read_machine_config(&self) -> Option> { + std::fs::read(self.machine_config_file()).ok() + } + + pub fn read_identity(&self, identity_id: &str) -> Result { + StoredIdentity::read(&self.identity_file(identity_id), identity_id) + } +} + +/// One identity, as it sits on disk. +#[derive(Debug, Clone)] +pub struct StoredIdentity { + pub id: String, + /// base64 uncompressed P-256 public key, as written by the TS side + pub public_key_base64: String, + /// device key id -> wrapped identity private key (base64 v1 payload) + /// + /// A `BTreeMap` so iteration order is the key id order rather than a hash + /// order, which keeps "try the wraps in turn" reproducible. + pub wraps: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityStoreError { + NotFound(String), + Malformed(String), + UnsupportedVersion(i64), + NoWrapForKey { identity_id: String, key_id: String }, +} + +impl IdentityStoreError { + /// Stable code the TS client can branch on without matching message text. + pub fn code(&self) -> &'static str { + match self { + IdentityStoreError::NotFound(_) => "IDENTITY_NOT_FOUND", + IdentityStoreError::Malformed(_) => "IDENTITY_MALFORMED", + IdentityStoreError::UnsupportedVersion(_) => "IDENTITY_VERSION_UNSUPPORTED", + IdentityStoreError::NoWrapForKey { .. } => "IDENTITY_NO_WRAP_FOR_KEY", + } + } +} + +impl std::fmt::Display for IdentityStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IdentityStoreError::NotFound(id) => { + write!(f, "No local identity \"{id}\" found on this machine") + } + IdentityStoreError::Malformed(id) => { + write!(f, "Invalid identity file format for identity: {id}") + } + IdentityStoreError::UnsupportedVersion(version) => { + write!(f, "unsupported identity file version {version}; upgrade varlock") + } + IdentityStoreError::NoWrapForKey { identity_id, key_id } => write!( + f, + "Identity \"{identity_id}\" has no wrap for key \"{key_id}\" on this machine" + ), + } + } +} + +impl StoredIdentity { + pub fn read(path: &Path, identity_id: &str) -> Result { + let Ok(data) = std::fs::read(path) else { + return Err(IdentityStoreError::NotFound(identity_id.to_string())); + }; + Self::parse(&data, identity_id) + } + + pub fn parse(data: &[u8], identity_id: &str) -> Result { + let Ok(json) = serde_json::from_slice::(data) else { + return Err(IdentityStoreError::Malformed(identity_id.to_string())); + }; + let Some(version) = json.get("version").and_then(Value::as_i64) else { + return Err(IdentityStoreError::Malformed(identity_id.to_string())); + }; + if version != IDENTITY_FILE_VERSION { + return Err(IdentityStoreError::UnsupportedVersion(version)); + } + + let public_key = json + .get("publicKey") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()); + let wraps_object = json.get("wraps").and_then(Value::as_object); + let (Some(public_key), Some(wraps_object)) = (public_key, wraps_object) else { + return Err(IdentityStoreError::Malformed(identity_id.to_string())); + }; + + let mut wraps = BTreeMap::new(); + for (key_id, wrap) in wraps_object { + // A non-string wrap is a malformed file, not a wrap to skip: the + // Swift side's `[String: String]` cast fails the whole read, and the + // two must agree on which files are readable. + let Some(wrap) = wrap.as_str() else { + return Err(IdentityStoreError::Malformed(identity_id.to_string())); + }; + wraps.insert(key_id.clone(), wrap.to_string()); + } + + Ok(Self { + id: json + .get("id") + .and_then(Value::as_str) + .unwrap_or(identity_id) + .to_string(), + public_key_base64: public_key.to_string(), + wraps, + }) + } + + pub fn wrap_for(&self, key_id: &str) -> Option<&str> { + self.wraps.get(key_id).map(String::as_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::TempDir; + + const VALID: &str = r#"{ + "version": 1, + "id": "default", + "publicKey": "BASE64PUBLICKEY", + "wraps": { "varlock-default": "WRAPPED", "other-key": "WRAPPED2" }, + "createdAt": "2026-01-01T00:00:00.000Z" + }"#; + + #[test] + fn reads_the_typescript_file_format() { + let identity = StoredIdentity::parse(VALID.as_bytes(), "default").expect("should parse"); + assert_eq!(identity.id, "default"); + assert_eq!(identity.public_key_base64, "BASE64PUBLICKEY"); + assert_eq!(identity.wrap_for("varlock-default"), Some("WRAPPED")); + assert_eq!(identity.wrap_for("missing"), None); + } + + #[test] + fn wraps_iterate_in_key_id_order() { + let identity = StoredIdentity::parse(VALID.as_bytes(), "default").unwrap(); + let ids: Vec<&str> = identity.wraps.keys().map(String::as_str).collect(); + assert_eq!(ids, vec!["other-key", "varlock-default"]); + } + + #[test] + fn a_missing_file_is_not_found() { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + let err = paths.read_identity("default").unwrap_err(); + assert_eq!(err.code(), "IDENTITY_NOT_FOUND"); + } + + #[test] + fn a_future_version_asks_for_an_upgrade() { + let err = StoredIdentity::parse(br#"{"version":2,"publicKey":"x","wraps":{}}"#, "default") + .unwrap_err(); + assert_eq!(err.code(), "IDENTITY_VERSION_UNSUPPORTED"); + assert!(err.to_string().contains("upgrade varlock")); + } + + #[test] + fn missing_required_fields_are_malformed() { + for body in [ + r#"{"version":1,"wraps":{}}"#, + r#"{"version":1,"publicKey":"","wraps":{}}"#, + r#"{"version":1,"publicKey":"x"}"#, + r#"{"publicKey":"x","wraps":{}}"#, + "not json at all", + ] { + let err = StoredIdentity::parse(body.as_bytes(), "default").unwrap_err(); + assert_eq!(err.code(), "IDENTITY_MALFORMED", "for {body}"); + } + } + + #[test] + fn a_non_string_wrap_is_malformed() { + let err = StoredIdentity::parse( + br#"{"version":1,"publicKey":"x","wraps":{"k":123}}"#, + "default", + ) + .unwrap_err(); + assert_eq!(err.code(), "IDENTITY_MALFORMED"); + } + + #[test] + fn paths_sit_where_both_other_implementations_look() { + let paths = SessionPaths::with_user_dir("/home/someone/.config/varlock"); + assert!(paths + .identity_file("default") + .ends_with("identities/default.json")); + assert!(paths.machine_config_file().ends_with("config.json")); + assert!(paths.audit_dir().ends_with("audit")); + } + + #[test] + fn a_missing_config_file_reads_as_nothing() { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + assert_eq!(paths.read_machine_config(), None); + + std::fs::write(paths.machine_config_file(), br#"{"sessions":{"lockOn":"none"}}"#).unwrap(); + assert!(paths.read_machine_config().is_some()); + } + + #[test] + fn an_identity_read_off_disk_round_trips() { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + std::fs::create_dir_all(paths.identity_file("default").parent().unwrap()).unwrap(); + std::fs::write(paths.identity_file("default"), VALID).unwrap(); + + let identity = paths.read_identity("default").expect("should read"); + assert_eq!(identity.wraps.len(), 2); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/lock_events.rs b/packages/encryption-binary-rust/src/identity_sessions/lock_events.rs new file mode 100644 index 000000000..4e8ab67d9 --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/lock_events.rs @@ -0,0 +1,455 @@ +//! Platform event sources that end unlock sessions. +//! +//! A session's `lockOn` policy names events; something has to deliver them. On +//! macOS that is `NSWorkspace`. Here it is: +//! +//! | event | Windows | Linux | +//! |--------------|--------------------------------------------|------------------------------------------------| +//! | `sleep` | `PowerRegisterSuspendResumeNotification` | logind `PrepareForSleep(true)` | +//! | `screenLock` | `WTSRegisterSessionNotification` (WTS lock) | logind session `Lock` | +//! +//! What is deliberately NOT wired, and why: +//! +//! - **Linux screensaver locks.** GNOME (`org.gnome.ScreenSaver`), KDE, and +//! the freedesktop `org.freedesktop.ScreenSaver` interface each announce a +//! lock differently, and a session locked through the desktop's own +//! shortcut does not always reach logind. logind's `Lock` covers +//! `loginctl lock-session` and anything that routes through it. Adding a +//! per-desktop source is a matter of another `receive_signal` call against +//! the session bus: [`LockEventSources::wired`] reports what is actually +//! live, so the gap is visible rather than assumed. +//! - **Windows display sleep / screensaver.** `WM_WTSSESSION_CHANGE` reports +//! the workstation locking, which is the event people mean. A screen that +//! merely blanked has not locked anything. +//! - **Resume.** Nothing subscribes to it. Sessions are erased on the way +//! down; there is nothing to restore on the way back. +//! +//! Every source runs on its own thread and reports through the sink. A source +//! that cannot start says so on stderr and the daemon keeps running: losing an +//! event source shortens nothing, it only means a session lives to its TTL +//! instead of to a lock. + +use std::sync::Arc; + +use super::lock_policy::SessionLockEvent; + +/// Where delivered events go. Called from a source's own thread. +pub type LockEventSink = Arc; + +/// The sources that actually started, and the handles that keep them alive. +pub struct LockEventSources { + wired: Vec<&'static str>, + #[cfg(target_os = "windows")] + _windows: Option, +} + +impl LockEventSources { + /// Which triggers are live on this machine, for the daemon's ready line and + /// for anyone wondering why a session outlived a screen lock. + pub fn wired(&self) -> &[&'static str] { + &self.wired + } +} + +/// Start every event source this platform has. +/// +/// The returned value must be kept alive for as long as the daemon runs; +/// dropping it stops the sources that need explicit teardown. +pub fn start(sink: LockEventSink) -> LockEventSources { + #[cfg(target_os = "linux")] + { + let wired = linux_impl::start(sink); + LockEventSources { wired } + } + + #[cfg(target_os = "windows")] + { + let (wired, handles) = windows_impl::start(sink); + LockEventSources { wired, _windows: handles } + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + // macOS runs the Swift daemon, which has its own `NSWorkspace` sources. + // This build exists so the portable half can be developed and tested + // here, so it wires nothing and says so. + let _ = sink; + LockEventSources { wired: Vec::new() } + } +} + +// ── Linux ──────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::{LockEventSink, SessionLockEvent}; + + const LOGIND_SERVICE: &str = "org.freedesktop.login1"; + const LOGIND_MANAGER_PATH: &str = "/org/freedesktop/login1"; + const LOGIND_MANAGER_INTERFACE: &str = "org.freedesktop.login1.Manager"; + const LOGIND_SESSION_INTERFACE: &str = "org.freedesktop.login1.Session"; + + pub fn start(sink: LockEventSink) -> Vec<&'static str> { + let mut wired = Vec::new(); + + match start_prepare_for_sleep(sink.clone()) { + Ok(()) => wired.push("logind:PrepareForSleep"), + Err(err) => eprintln!( + "varlock: not watching for sleep ({err}); sessions will run to their TTL instead" + ), + } + + match start_session_lock(sink) { + Ok(()) => wired.push("logind:Session.Lock"), + Err(err) => eprintln!( + "varlock: not watching for screen lock ({err}); sessions set to lock on screenLock will not" + ), + } + + wired + } + + /// logind announces an imminent suspend with `PrepareForSleep(true)`, and + /// the resume with `PrepareForSleep(false)`. Only the way down matters here. + fn start_prepare_for_sleep(sink: LockEventSink) -> Result<(), String> { + let connection = zbus::blocking::Connection::system() + .map_err(|e| format!("no system bus: {e}"))?; + let proxy = zbus::blocking::Proxy::new( + &connection, + LOGIND_SERVICE, + LOGIND_MANAGER_PATH, + LOGIND_MANAGER_INTERFACE, + ) + .map_err(|e| format!("no logind manager: {e}"))?; + + let signals = proxy + .receive_signal("PrepareForSleep") + .map_err(|e| format!("could not subscribe to PrepareForSleep: {e}"))?; + + std::thread::Builder::new() + .name("varlock-sleep-watch".into()) + .spawn(move || { + // The connection and proxy are moved in so the subscription + // outlives this function. + let _connection = connection; + for message in signals { + match message.body().deserialize::() { + Ok(true) => sink(SessionLockEvent::Sleep), + Ok(false) => {} + Err(e) => eprintln!("varlock: unreadable PrepareForSleep signal: {e}"), + } + } + }) + .map_err(|e| format!("could not start the sleep watcher: {e}"))?; + Ok(()) + } + + /// The `Lock` signal on this login session's own object. Emitted by + /// `loginctl lock-session` and by anything that asks logind to lock. + fn start_session_lock(sink: LockEventSink) -> Result<(), String> { + let connection = zbus::blocking::Connection::system() + .map_err(|e| format!("no system bus: {e}"))?; + let session_path = current_session_path(&connection)?; + + let proxy = zbus::blocking::Proxy::new( + &connection, + LOGIND_SERVICE, + session_path.clone(), + LOGIND_SESSION_INTERFACE, + ) + .map_err(|e| format!("no logind session at {session_path}: {e}"))?; + + let signals = proxy + .receive_signal("Lock") + .map_err(|e| format!("could not subscribe to Session.Lock: {e}"))?; + + std::thread::Builder::new() + .name("varlock-lock-watch".into()) + .spawn(move || { + let _connection = connection; + for _message in signals { + sink(SessionLockEvent::ScreenLock); + } + }) + .map_err(|e| format!("could not start the lock watcher: {e}"))?; + Ok(()) + } + + /// The logind object path for the session this daemon runs in. + /// + /// Asked for by PID rather than read from `XDG_SESSION_ID`, because a daemon + /// started by a service manager may not have inherited that variable, and + /// the answer has to describe where the daemon actually is. + fn current_session_path( + connection: &zbus::blocking::Connection, + ) -> Result { + let proxy = zbus::blocking::Proxy::new( + connection, + LOGIND_SERVICE, + LOGIND_MANAGER_PATH, + LOGIND_MANAGER_INTERFACE, + ) + .map_err(|e| format!("no logind manager: {e}"))?; + + let path: zbus::zvariant::OwnedObjectPath = proxy + .call("GetSessionByPID", &(std::process::id(),)) + .map_err(|e| format!("this process is not in a login session: {e}"))?; + Ok(path.as_str().to_string()) + } +} + +// ── Windows ────────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +mod windows_impl { + use super::{LockEventSink, SessionLockEvent}; + use std::sync::OnceLock; + + use windows::core::PCWSTR; + use windows::Win32::Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM}; + use windows::Win32::System::LibraryLoader::GetModuleHandleW; + use windows::Win32::System::Power::{ + PowerRegisterSuspendResumeNotification, DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS, HPOWERNOTIFY, + }; + use windows::Win32::System::RemoteDesktop::{ + WTSRegisterSessionNotification, NOTIFY_FOR_THIS_SESSION, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, RegisterClassW, + TranslateMessage, DEVICE_NOTIFY_CALLBACK, HMENU, HWND_MESSAGE, MSG, PBT_APMSUSPEND, + WINDOW_EX_STYLE, WINDOW_STYLE, WM_WTSSESSION_CHANGE, WNDCLASSW, WTS_SESSION_LOCK, + }; + + /// The sink both callbacks reach for. + /// + /// A static rather than a boxed pointer smuggled through the window's user + /// data: the power notification callback is a bare `extern "system"` function + /// with only an opaque context pointer, and one process only ever has one + /// session manager, so a `OnceLock` is honest about that and avoids a raw + /// pointer with no lifetime. + static SINK: OnceLock = OnceLock::new(); + + fn deliver(event: SessionLockEvent) { + if let Some(sink) = SINK.get() { + sink(event); + } + } + + /// Kept alive for the daemon's life; dropping it would unregister the + /// notifications. + pub struct WindowsSources { + _power: PowerRegistration, + } + + struct PowerRegistration(*mut std::ffi::c_void); + // The handle is only ever unregistered on drop, from whichever thread owns + // the struct. Windows makes no thread-affinity demand on it. + unsafe impl Send for PowerRegistration {} + unsafe impl Sync for PowerRegistration {} + + impl Drop for PowerRegistration { + fn drop(&mut self) { + use windows::Win32::System::Power::PowerUnregisterSuspendResumeNotification; + // Safety: the handle came from a successful registration and is + // unregistered exactly once. + unsafe { + let _ = PowerUnregisterSuspendResumeNotification(HPOWERNOTIFY(self.0 as isize)); + } + } + } + + pub fn start(sink: LockEventSink) -> (Vec<&'static str>, Option) { + let mut wired = Vec::new(); + if SINK.set(sink).is_err() { + // Only reachable if the daemon were started twice in one process. + eprintln!("varlock: lock event sources were already started"); + return (wired, None); + } + + let power = match register_suspend_notification() { + Ok(handle) => { + wired.push("windows:SuspendResume"); + Some(handle) + } + Err(err) => { + eprintln!( + "varlock: not watching for sleep ({err}); sessions will run to their TTL instead" + ); + None + } + }; + + match start_session_notification_window() { + Ok(()) => wired.push("windows:WTSSessionLock"), + Err(err) => eprintln!( + "varlock: not watching for workstation lock ({err}); sessions set to lock on screenLock will not" + ), + } + + (wired, power.map(|handle| WindowsSources { _power: handle })) + } + + /// Suspend notifications go to a plain callback, so no window is involved. + fn register_suspend_notification() -> Result { + let parameters = Box::new(DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS { + Callback: Some(on_power_event), + Context: std::ptr::null_mut(), + }); + // Leaked on purpose: Windows keeps the pointer for as long as the + // registration lives, which is the daemon's whole life. + let parameters = Box::into_raw(parameters); + + let mut handle: *mut std::ffi::c_void = std::ptr::null_mut(); + // Safety: `parameters` points at a live, correctly shaped struct that is + // never freed, and `handle` is a valid output pointer. + let status = unsafe { + PowerRegisterSuspendResumeNotification( + DEVICE_NOTIFY_CALLBACK, + HANDLE(parameters as *mut _), + &mut handle, + ) + }; + if status.is_err() { + return Err(format!("PowerRegisterSuspendResumeNotification failed: {status:?}")); + } + Ok(PowerRegistration(handle)) + } + + /// PBT_APMSUSPEND is the machine going down. Resume events are ignored: + /// sessions are erased on the way down and there is nothing to restore. + unsafe extern "system" fn on_power_event( + _context: *const std::ffi::c_void, + event_type: u32, + _setting: *const std::ffi::c_void, + ) -> u32 { + if event_type == PBT_APMSUSPEND { + deliver(SessionLockEvent::Sleep); + } + 0 // ERROR_SUCCESS + } + + /// Workstation lock notifications need a window to be delivered to, so the + /// daemon keeps a message-only one on its own thread. It is never shown and + /// never appears in the taskbar; it exists to receive one message. + fn start_session_notification_window() -> Result<(), String> { + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + + std::thread::Builder::new() + .name("varlock-session-watch".into()) + .spawn(move || { + let result = create_message_window(); + let window = match result { + Ok(window) => { + let _ = ready_tx.send(Ok(())); + window + } + Err(err) => { + let _ = ready_tx.send(Err(err)); + return; + } + }; + + // A message-only window still needs a pump, and the pump has to + // run on the thread that created the window. + let mut message = MSG::default(); + // Safety: standard message loop over a window this thread owns. + unsafe { + while GetMessageW(&mut message, window, 0, 0).as_bool() { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + } + }) + .map_err(|e| format!("could not start the session watcher: {e}"))?; + + ready_rx + .recv() + .map_err(|_| "the session watcher thread stopped before reporting".to_string())? + } + + fn create_message_window() -> Result { + let class_name = windows::core::w!("VarlockSessionNotifyWindow"); + + // Safety: GetModuleHandleW(None) returns this executable's handle. + let instance = unsafe { GetModuleHandleW(None) } + .map_err(|e| format!("GetModuleHandleW failed: {e}"))?; + + let class = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: instance.into(), + lpszClassName: PCWSTR(class_name.as_ptr()), + ..Default::default() + }; + // Safety: `class` is fully initialized and outlives the call. + // A zero return means the class could not be registered; registering the + // same class twice in one process is the only benign failure and cannot + // happen here, since the daemon starts this once. + if unsafe { RegisterClassW(&class) } == 0 { + return Err("RegisterClassW failed".into()); + } + + // Safety: HWND_MESSAGE creates a message-only window with no UI. + let window = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + PCWSTR(class_name.as_ptr()), + PCWSTR(class_name.as_ptr()), + WINDOW_STYLE(0), + 0, + 0, + 0, + 0, + HWND_MESSAGE, + HMENU::default(), + windows::Win32::Foundation::HINSTANCE::from(instance), + None, + ) + } + .map_err(|e| format!("CreateWindowExW failed: {e}"))?; + + // Safety: the window was just created on this thread. + unsafe { + WTSRegisterSessionNotification(window, NOTIFY_FOR_THIS_SESSION) + .map_err(|e| format!("WTSRegisterSessionNotification failed: {e}"))?; + } + + Ok(window) + } + + unsafe extern "system" fn window_proc( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if message == WM_WTSSESSION_CHANGE && wparam.0 as u32 == WTS_SESSION_LOCK { + deliver(SessionLockEvent::ScreenLock); + return LRESULT(0); + } + DefWindowProcW(window, message, wparam, lparam) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn starting_reports_which_triggers_are_live() { + let count = Arc::new(AtomicUsize::new(0)); + let seen = count.clone(); + let sources = start(Arc::new(move |_event| { + seen.fetch_add(1, Ordering::SeqCst); + })); + + // On a machine with no event sources (macOS development, a container + // with no logind) this is empty, which is the honest answer rather than + // a failure. What matters is that starting up never panics and never + // invents an event. + assert_eq!(count.load(Ordering::SeqCst), 0); + for name in sources.wired() { + assert!(!name.is_empty()); + } + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/lock_policy.rs b/packages/encryption-binary-rust/src/identity_sessions/lock_policy.rs new file mode 100644 index 000000000..519e7d74d --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/lock_policy.rs @@ -0,0 +1,302 @@ +//! What ends an unlock session, short of its TTL running out. +//! +//! A port of `SessionLockPolicy.swift`. The hard cap and explicit invalidation +//! are not covered here: those always apply. This only decides which system +//! events erase a session's key material, and where that decision came from. + +use serde_json::Value; + +/// Which system events erase a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionLockPolicy { + /// Erase on screen lock and on sleep. + ScreenLock, + /// Erase on sleep only. Sessions survive the screen locking. + Sleep, + /// Erase only on TTL expiry, the 12h cap, or an explicit lock. + None, +} + +/// Used when neither the session nor the machine config says otherwise. +pub const BUILT_IN_DEFAULT_LOCK_POLICY: SessionLockPolicy = SessionLockPolicy::Sleep; + +/// Key path into the machine config file: `{ "sessions": { "lockOn": "sleep" } }` +pub const CONFIG_SECTION_KEY: &str = "sessions"; +pub const CONFIG_FIELD_KEY: &str = "lockOn"; + +impl SessionLockPolicy { + pub fn wire_value(&self) -> &'static str { + match self { + SessionLockPolicy::ScreenLock => "screenLock", + SessionLockPolicy::Sleep => "sleep", + SessionLockPolicy::None => "none", + } + } + + pub fn from_wire_value(value: &str) -> Option { + match value { + "screenLock" => Some(SessionLockPolicy::ScreenLock), + "sleep" => Some(SessionLockPolicy::Sleep), + "none" => Some(SessionLockPolicy::None), + _ => None, + } + } + + /// Every value a caller may send, for error messages. + pub fn wire_values() -> [&'static str; 3] { + ["screenLock", "sleep", "none"] + } + + pub fn erases_on(&self, event: SessionLockEvent) -> bool { + match self { + SessionLockPolicy::ScreenLock => true, + SessionLockPolicy::Sleep => event == SessionLockEvent::Sleep, + SessionLockPolicy::None => false, + } + } +} + +/// A system event that may end sessions, depending on their policy. +/// +/// Only [`super::lock_events`] constructs these, and only on the platforms that +/// have a source for them, so a macOS development build has no producer for +/// `ScreenLock`. The policy rules still have to know about it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(any(target_os = "linux", target_os = "windows")), + allow(dead_code) +)] +pub enum SessionLockEvent { + /// The machine is going to sleep. + Sleep, + /// The screen locked, or the login session was locked. + ScreenLock, +} + +impl SessionLockEvent { + pub fn wire_value(&self) -> &'static str { + match self { + SessionLockEvent::Sleep => "sleep", + SessionLockEvent::ScreenLock => "screenLock", + } + } +} + +/// Where an effective policy came from, for diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockPolicySource { + SessionOverride, + MachineConfig, + BuiltInDefault, +} + +impl LockPolicySource { + pub fn wire_value(&self) -> &'static str { + match self { + LockPolicySource::SessionOverride => "session-override", + LockPolicySource::MachineConfig => "machine-config", + LockPolicySource::BuiltInDefault => "built-in-default", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedLockPolicy { + pub policy: SessionLockPolicy, + pub source: LockPolicySource, +} + +/// Resolve the effective lock policy for one unlock. +/// +/// Order is: what this unlock asked for, then the machine config, then the +/// built-in default. Anything unparseable is reported and skipped rather than +/// failing the unlock, so a typo in a config file cannot lock someone out of +/// their own secrets. +pub fn resolve_lock_policy( + override_wire_value: Option<&str>, + machine_config_data: Option<&[u8]>, + warn: &mut dyn FnMut(&str), +) -> ResolvedLockPolicy { + if let Some(raw) = override_wire_value.filter(|value| !value.is_empty()) { + match SessionLockPolicy::from_wire_value(raw) { + Some(policy) => { + return ResolvedLockPolicy { policy, source: LockPolicySource::SessionOverride } + } + None => warn(&invalid_value_message(raw, "unlock-session lockOn")), + } + } + + if let Some(policy) = machine_lock_policy(machine_config_data, warn) { + return ResolvedLockPolicy { policy, source: LockPolicySource::MachineConfig }; + } + + ResolvedLockPolicy { + policy: BUILT_IN_DEFAULT_LOCK_POLICY, + source: LockPolicySource::BuiltInDefault, + } +} + +/// Read `sessions.lockOn` out of the user-level config file's contents. +/// +/// A missing file, a missing section, or a missing field all mean "not +/// configured", silently. Only a value that is present and wrong is worth +/// saying something about. +pub fn machine_lock_policy( + data: Option<&[u8]>, + warn: &mut dyn FnMut(&str), +) -> Option { + let data = data.filter(|bytes| !bytes.is_empty())?; + + let Ok(json) = serde_json::from_slice::(data) else { + warn("could not parse the varlock config file; ignoring it for session lock settings"); + return None; + }; + let sessions = json.get(CONFIG_SECTION_KEY)?.as_object()?; + let raw = sessions.get(CONFIG_FIELD_KEY)?; + + let Some(raw_string) = raw.as_str() else { + warn(&invalid_value_message( + &raw.to_string(), + &format!("config {CONFIG_SECTION_KEY}.{CONFIG_FIELD_KEY}"), + )); + return None; + }; + match SessionLockPolicy::from_wire_value(raw_string) { + Some(policy) => Some(policy), + None => { + warn(&invalid_value_message( + raw_string, + &format!("config {CONFIG_SECTION_KEY}.{CONFIG_FIELD_KEY}"), + )); + None + } + } +} + +/// The default warning sink: one line on stderr, same wording as the Swift side. +pub fn warn_on_stderr(message: &str) { + eprintln!("varlock: {message}"); +} + +fn invalid_value_message(value: &str, origin: &str) -> String { + let expected = SessionLockPolicy::wire_values() + .iter() + .map(|value| format!("\"{value}\"")) + .collect::>() + .join(", "); + format!("ignoring invalid {origin} value \"{value}\"; expected one of {expected}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolve, capturing whatever the user would have been told on stderr. + fn resolve_collecting( + override_value: Option<&str>, + config: Option<&[u8]>, + ) -> (ResolvedLockPolicy, Vec) { + let mut warnings: Vec = Vec::new(); + let resolved = { + let mut warn = |message: &str| warnings.push(message.to_string()); + resolve_lock_policy(override_value, config, &mut warn) + }; + (resolved, warnings) + } + + #[test] + fn a_session_override_wins() { + let config = br#"{"sessions":{"lockOn":"none"}}"#; + let (resolved, warnings) = resolve_collecting(Some("screenLock"), Some(config)); + assert_eq!(resolved.policy, SessionLockPolicy::ScreenLock); + assert_eq!(resolved.source, LockPolicySource::SessionOverride); + assert!(warnings.is_empty()); + } + + #[test] + fn the_machine_config_is_used_when_the_unlock_says_nothing() { + let config = br#"{"sessions":{"lockOn":"none"}}"#; + let (resolved, _) = resolve_collecting(None, Some(config)); + assert_eq!(resolved.policy, SessionLockPolicy::None); + assert_eq!(resolved.source, LockPolicySource::MachineConfig); + } + + #[test] + fn nothing_configured_falls_back_to_sleep() { + let (resolved, warnings) = resolve_collecting(None, None); + assert_eq!(resolved.policy, SessionLockPolicy::Sleep); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert!(warnings.is_empty()); + } + + #[test] + fn an_empty_override_is_treated_as_absent() { + let (resolved, warnings) = resolve_collecting(Some(""), None); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert!(warnings.is_empty()); + } + + #[test] + fn a_bad_override_warns_and_falls_through() { + let config = br#"{"sessions":{"lockOn":"none"}}"#; + let (resolved, warnings) = resolve_collecting(Some("whenever"), Some(config)); + assert_eq!(resolved.policy, SessionLockPolicy::None); + assert_eq!(resolved.source, LockPolicySource::MachineConfig); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("unlock-session lockOn")); + assert!(warnings[0].contains("\"whenever\"")); + } + + #[test] + fn a_bad_config_value_warns_and_falls_back_to_the_default() { + let config = br#"{"sessions":{"lockOn":"whenever"}}"#; + let (resolved, warnings) = resolve_collecting(None, Some(config)); + assert_eq!(resolved.policy, SessionLockPolicy::Sleep); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("config sessions.lockOn")); + } + + #[test] + fn an_unparseable_config_warns_once_and_is_ignored() { + let (resolved, warnings) = resolve_collecting(None, Some(b"{not json")); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("could not parse")); + } + + #[test] + fn a_config_without_the_section_is_silent() { + let config = br#"{"telemetry":{"disabled":true}}"#; + let (resolved, warnings) = resolve_collecting(None, Some(config)); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert!(warnings.is_empty()); + } + + #[test] + fn a_non_string_config_value_warns() { + let config = br#"{"sessions":{"lockOn":42}}"#; + let (resolved, warnings) = resolve_collecting(None, Some(config)); + assert_eq!(resolved.source, LockPolicySource::BuiltInDefault); + assert_eq!(warnings.len(), 1); + } + + #[test] + fn policies_erase_on_the_events_they_claim() { + assert!(SessionLockPolicy::ScreenLock.erases_on(SessionLockEvent::ScreenLock)); + assert!(SessionLockPolicy::ScreenLock.erases_on(SessionLockEvent::Sleep)); + assert!(!SessionLockPolicy::Sleep.erases_on(SessionLockEvent::ScreenLock)); + assert!(SessionLockPolicy::Sleep.erases_on(SessionLockEvent::Sleep)); + assert!(!SessionLockPolicy::None.erases_on(SessionLockEvent::ScreenLock)); + assert!(!SessionLockPolicy::None.erases_on(SessionLockEvent::Sleep)); + } + + #[test] + fn wire_values_round_trip() { + for value in SessionLockPolicy::wire_values() { + let parsed = SessionLockPolicy::from_wire_value(value).expect("should parse"); + assert_eq!(parsed.wire_value(), value); + } + assert_eq!(SessionLockPolicy::from_wire_value("nope"), None); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/manager.rs b/packages/encryption-binary-rust/src/identity_sessions/manager.rs new file mode 100644 index 000000000..7bd5d8b61 --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/manager.rs @@ -0,0 +1,1488 @@ +//! Holds identity keys on behalf of unlocked sessions. +//! +//! The shape mirrors `IdentitySessionManager.swift`: at unlock the identity +//! private key is unwrapped once through the device (custody) key, and then held +//! until the session's grant ends. Each later decrypt uses the held key for a +//! batch and charges the grant. Ending a session erases the key. +//! +//! Where this differs from the Swift daemon, on purpose: +//! +//! - macOS re-wraps the identity key under a per-session Secure Enclave key +//! and holds only that blob, so the enclave is what makes a held key +//! unreadable after the session ends. Neither Windows' NCrypt nor Linux's +//! TPM2 gives a cheap equivalent (a per-session TPM key would put a TPM +//! round trip on every decrypt and needs its own eviction story), so the +//! hold here is guarded memory instead: a fixed, locked, dump-excluded +//! buffer that is zeroized when the session ends. See +//! [`crate::secure_mem::GuardedBuffer`]. A TPM-resident session key is the +//! later step, not this one. +//! - there is no approval panel, so an unlock can never answer +//! `APPROVAL_DENIED` or `NO_UI`. Windows Hello (or polkit/PAM on Linux) is +//! the only thing the user sees, and only for keys whose custody asks for +//! it. Approval surfaces for these platforms arrive later. +//! +//! Nothing here is persisted. A daemon restart loses every session on purpose: +//! a held key that survived a restart would be a key nobody was present for. + +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::crypto; +use crate::secure_mem::GuardedBuffer; + +use super::audit::{ + AuditWriteError, AuthorizationAuditLog, AuthorizationKind, AuthorizationRecord, +}; +use super::grants::{ + SessionGrantError, SessionGrantInfo, SessionGrantRef, SessionGrantScope, SessionGrantTable, + MAX_GRANT_MS, +}; +use super::identity_store::{IdentityStoreError, SessionPaths}; +use super::lock_policy::{ + resolve_lock_policy, warn_on_stderr, LockPolicySource, SessionLockEvent, SessionLockPolicy, +}; + +/// How the daemon satisfied user presence for an unlock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnlockPolicy { + /// Windows Hello, or polkit/PAM on Linux. + Biometrics, + /// The custody key carries no presence requirement, so there was nothing to + /// prompt for. + NoPresenceRequired, +} + +impl UnlockPolicy { + pub fn wire_value(&self) -> &'static str { + match self { + UnlockPolicy::Biometrics => "biometrics", + UnlockPolicy::NoPresenceRequired => "no-presence-required", + } + } +} + +/// Everything that goes wrong on the identity session path. +#[derive(Debug)] +pub enum SessionError { + NoSessionIdentity, + NoKeysRequested, + PresenceFailed(String), + SessionKeyMissing, + NotUtf8, + Grant(SessionGrantError), + Identity(IdentityStoreError), + Audit(AuditWriteError), + /// A crypto or key-import failure, which carries no stable code: there is + /// nothing a client can usefully do about it but show the message. + Crypto(String), +} + +impl SessionError { + /// Stable code the TS client can branch on without matching message text. + pub fn code(&self) -> Option<&'static str> { + match self { + SessionError::NoSessionIdentity => Some("NO_SESSION_IDENTITY"), + SessionError::NoKeysRequested => Some("NO_KEYS_REQUESTED"), + SessionError::PresenceFailed(_) => Some("BIOMETRIC_FAILED"), + SessionError::SessionKeyMissing => Some("SESSION_KEY_MISSING"), + SessionError::NotUtf8 => Some("NOT_UTF8"), + SessionError::Grant(err) => Some(err.code()), + SessionError::Identity(err) => Some(err.code()), + SessionError::Audit(err) => Some(err.code()), + SessionError::Crypto(_) => None, + } + } +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionError::NoSessionIdentity => write!( + f, + "Cannot scope an unlock session for this process; no session identity could be determined" + ), + SessionError::NoKeysRequested => write!( + f, + "No key ids were named to unlock; send keyIds (or keyId) in the payload" + ), + SessionError::PresenceFailed(message) => { + write!(f, "User verification failed: {message}") + } + SessionError::SessionKeyMissing => write!( + f, + "The unlock session is no longer held by the daemon; unlock again" + ), + SessionError::NotUtf8 => write!(f, "Decrypted data is not valid UTF-8"), + SessionError::Grant(err) => write!(f, "{err}"), + SessionError::Identity(err) => write!(f, "{err}"), + SessionError::Audit(err) => write!(f, "{err}"), + SessionError::Crypto(message) => write!(f, "{message}"), + } + } +} + +impl From for SessionError { + fn from(err: SessionGrantError) -> Self { + SessionError::Grant(err) + } +} + +impl From for SessionError { + fn from(err: IdentityStoreError) -> Self { + SessionError::Identity(err) + } +} + +impl From for SessionError { + fn from(err: AuditWriteError) -> Self { + SessionError::Audit(err) + } +} + +/// The device key half: unwrapping an identity key, and whatever gate that key +/// carries. +/// +/// A trait so the platform backends (NCrypt/DPAPI, TPM2/Secret Service) stay out +/// of the lifetime rules, and so every rule in this file can be tested on any OS +/// with no TPM, no keyring, and nobody's fingerprint. +pub trait CustodyBackend: Send + Sync { + /// Unwrap the identity private key from its wrap blob, returning the raw + /// 32-byte P-256 scalar in guarded memory. + /// + /// Called only after [`CustodyBackend::verify_presence`] has passed for keys + /// that ask for it. + fn unwrap_identity_scalar( + &self, + key_id: &str, + wrap: &[u8], + ) -> Result; + + /// Whether using this key should cost a user-presence check. + /// + /// False for a key created with `--no-auth` (CI), and false on a machine + /// with no presence mechanism at all: there is nothing to ask, and failing + /// the unlock instead would just make the feature unavailable. + fn requires_presence(&self, key_id: &str) -> bool; + + /// Run the platform's presence check. `Ok(false)` means the user declined. + fn verify_presence(&self, reason: &str) -> Result; +} + +/// One unlock request, as the daemon resolved it. +pub struct UnlockRequest<'a> { + /// Resolved from the peer, never taken from the message. + pub session_id: Option<&'a str>, + pub key_ids: Vec, + pub identity_id: String, + pub scope: SessionGrantScope, + pub duration_ms: Option, + pub lock_on_override: Option<&'a str>, + /// One line describing the connecting process, for the log. + pub requester: Option, +} + +#[derive(Debug)] +pub struct UnlockOutcome { + pub grants: Vec, + pub policy: UnlockPolicy, + pub lock_on: SessionLockPolicy, + pub lock_on_source: LockPolicySource, + /// Whether the user was actually asked. False when every key was already + /// covered by a live grant, or when no key in the batch is presence gated. + pub prompted: bool, +} + +/// What the daemon holds for one unlocked session. +/// +/// Keyed by identity id and device key id, the same split the grants use, so a +/// session that opened two keys can lose one without losing the other. +#[derive(Default)] +struct SessionMaterial { + /// "\0" -> the identity private scalar, in guarded memory + scalars: HashMap, +} + +struct Inner { + grants: SessionGrantTable, + material: HashMap, +} + +pub struct IdentitySessionManager { + inner: Mutex, + paths: SessionPaths, + audit: AuthorizationAuditLog, + custody: Box, +} + +impl IdentitySessionManager { + pub fn new(paths: SessionPaths, custody: Box) -> Self { + let audit = AuthorizationAuditLog::new(paths.audit_dir()); + Self::with_audit(paths, custody, audit) + } + + pub fn with_audit( + paths: SessionPaths, + custody: Box, + audit: AuthorizationAuditLog, + ) -> Self { + Self { + inner: Mutex::new(Inner { + grants: SessionGrantTable::new(), + material: HashMap::new(), + }), + paths, + audit, + custody, + } + } + + /// Swap in a grant table over a test clock. Only used by the tests, which + /// need to move time without sleeping for twelve hours. + #[cfg(test)] + pub fn set_grant_table(&self, table: SessionGrantTable) { + let mut inner = self.lock(); + inner.grants = table; + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + // A poisoned lock means a panic happened while holding session state. + // Recovering the guard is the right call: the alternative is a daemon + // that answers nothing and never releases the keys it is holding. + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + // ── Unlock ──────────────────────────────────────────────────── + + /// Open (or extend) a session: one presence check, however many keys. + pub fn unlock(&self, request: UnlockRequest<'_>) -> Result { + let session_id = non_empty(request.session_id).ok_or(SessionError::NoSessionIdentity)?; + // A caller that named no key has asked for nothing. Picking a key for it + // would hand it a grant it never requested, so say so instead. + if request.key_ids.is_empty() { + return Err(SessionError::NoKeysRequested); + } + let identity = self.paths.read_identity(&request.identity_id)?; + + // What this unlock asked for, else the machine config, else the default. + // Read fresh so editing the config file needs no daemon restart. + let lock_policy = resolve_lock_policy( + request.lock_on_override, + self.paths.read_machine_config().as_deref(), + &mut warn_on_stderr, + ); + + // Fail before prompting if none of the requested keys can open this identity. + let usable: Vec = request + .key_ids + .iter() + .filter(|key_id| identity.wrap_for(key_id).is_some()) + .cloned() + .collect(); + if usable.is_empty() { + return Err(IdentityStoreError::NoWrapForKey { + identity_id: request.identity_id.clone(), + key_id: request.key_ids.first().cloned().unwrap_or_else(|| "unknown".into()), + } + .into()); + } + + // Which keys this request still needs opened, and which are already + // covered by a grant at least as strong as the one being asked for. + let (to_open, carried) = { + let mut inner = self.lock(); + split_by_coverage( + &mut inner.grants, + session_id, + &usable, + request.scope, + request.duration_ms, + ) + }; + + if to_open.is_empty() { + // Everything asked for is already covered by a live grant. Asking + // again would be a check that changes nothing, so we hand back what + // the session already holds. + let lock_on = carried.first().map(|grant| grant.lock_on).unwrap_or(lock_policy.policy); + return Ok(UnlockOutcome { + grants: carried, + policy: UnlockPolicy::NoPresenceRequired, + lock_on, + lock_on_source: lock_policy.source, + prompted: false, + }); + } + + // One presence check for the whole batch, before any unwrapping, so a + // caller cannot turn one approval into a stream of silent unwraps. + let needs_presence = to_open.iter().any(|key_id| self.custody.requires_presence(key_id)); + if needs_presence { + let reason = unlock_reason(&request.identity_id, &to_open, request.requester.as_deref()); + match self.custody.verify_presence(&reason) { + Ok(true) => {} + Ok(false) => { + return Err(SessionError::PresenceFailed( + "the request was cancelled".into(), + )) + } + Err(message) => return Err(SessionError::PresenceFailed(message)), + } + } + + let mut granted = carried; + let mut opened: Vec = Vec::new(); + + for key_id in &to_open { + let Some(wrap_base64) = identity.wrap_for(key_id) else { continue }; + let wrap = decode_base64(wrap_base64) + .ok_or_else(|| IdentityStoreError::Malformed(identity.id.clone()))?; + + let scalar = self.custody.unwrap_identity_scalar(key_id, &wrap)?; + + // The unwrapped key has to be the identity this file describes. A + // wrap that opens but yields a different key means the file has been + // spliced together from two identities, and every value decrypted + // under it afterwards would silently fail or, worse, be attributed to + // the wrong identity in the log. + verify_identity_key(&identity, &scalar)?; + + let mut inner = self.lock(); + inner + .material + .entry(session_id.to_string()) + .or_default() + .scalars + .insert(material_key(&request.identity_id, key_id), scalar); + + granted.push(inner.grants.grant( + &SessionGrantRef::new(session_id, key_id.clone()), + &request.identity_id, + request.scope, + request.duration_ms, + lock_policy.policy, + )); + opened.push(key_id.clone()); + } + + // A session the daemon holds with no record of who opened it is the hole + // this log exists to close, so an unlock that cannot be recorded gives + // its keys straight back. + let mut sorted_keys = opened.clone(); + sorted_keys.sort(); + let record = AuthorizationRecord::new(AuthorizationKind::Unlock, session_id, sorted_keys) + .identity_id(request.identity_id.clone()) + .scope(request.scope.wire_value()) + .requester(request.requester.clone()); + if let Err(err) = self.audit.append(&record) { + let mut inner = self.lock(); + for key_id in &opened { + inner.grants.invalidate(Some(session_id), Some(key_id)); + } + reconcile_locked(&mut inner); + return Err(err.into()); + } + + { + let mut inner = self.lock(); + reconcile_locked(&mut inner); + } + + Ok(UnlockOutcome { + grants: granted, + policy: if needs_presence { + UnlockPolicy::Biometrics + } else { + UnlockPolicy::NoPresenceRequired + }, + lock_on: lock_policy.policy, + lock_on_source: lock_policy.source, + prompted: needs_presence, + }) + } + + // ── Decrypt ─────────────────────────────────────────────────── + + /// Decrypt a batch of v2 payloads under a live grant. No prompt, no key on + /// the wire. + /// + /// The batch is one grant use: a `once` grant covers this call and is then + /// spent, however many payloads it carried. + /// + /// Nothing is decrypted until the authorization is on disk. If the record + /// cannot be written the call is refused, which does spend a `once` grant on + /// a batch that returned nothing. That is the safe direction to fail in: the + /// alternative is handing back secrets with no record that it happened. + pub fn decrypt_v2( + &self, + session_id: Option<&str>, + key_id: &str, + identity_id: &str, + payloads: &[Vec], + requester: Option, + ) -> Result<(Vec, SessionGrantInfo), SessionError> { + let session_id = non_empty(session_id).ok_or(SessionError::NoSessionIdentity)?; + let grant_ref = SessionGrantRef::new(session_id, key_id); + + let mut inner = self.lock(); + + let (served, change) = match inner.grants.consume(&grant_ref) { + Ok(result) => result, + Err(err) => { + reconcile_locked(&mut inner); + return Err(err.into()); + } + }; + + let record = AuthorizationRecord::new( + AuthorizationKind::Decrypt, + session_id, + vec![key_id.to_string()], + ) + .identity_id(identity_id) + .payload_count(payloads.len()) + .scope(served.scope.wire_value()) + .requester(requester); + self.audit.append(&record)?; + + // Import the held scalar and let the borrow end here, so an erase can + // take the lock mutably below. + let held_key = material_key(identity_id, key_id); + let identity_key = match inner + .material + .get(session_id) + .and_then(|held| held.scalars.get(&held_key)) + { + Some(scalar) => { + crypto::secret_key_from_scalar(scalar.as_slice()).map_err(SessionError::Crypto) + } + None => Err(SessionError::SessionKeyMissing), + }; + let identity_key = match identity_key { + Ok(key) => key, + Err(err) => { + reconcile_locked(&mut inner); + return Err(err); + } + }; + + let mut plaintexts = Vec::with_capacity(payloads.len()); + for payload in payloads { + let decrypted = + crypto::decrypt_payload(&identity_key, payload, &[crypto::IDENTITY_PAYLOAD_VERSION]) + .map_err(SessionError::Crypto)?; + let text = String::from_utf8(decrypted).map_err(|_| SessionError::NotUtf8)?; + plaintexts.push(text); + } + + if !change.closed_sessions.is_empty() { + reconcile_locked(&mut inner); + } + Ok((plaintexts, served)) + } + + // ── Listing and invalidation ────────────────────────────────── + + pub fn list_grants(&self) -> Vec { + let mut inner = self.lock(); + reconcile_locked(&mut inner); + inner.grants.list() + } + + /// Drop grants and erase any session left holding nothing. + /// + /// Passing neither target drops everything, which is what the argument-less + /// `invalidate-session` has always done. + pub fn invalidate( + &self, + session_id: Option<&str>, + key_id: Option<&str>, + requester: Option, + ) -> usize { + let mut inner = self.lock(); + let change = inner.grants.invalidate(session_id, key_id); + reconcile_locked(&mut inner); + drop(inner); + + // Recorded best effort, unlike the two paths above. Refusing to erase + // key material because a log line would not write is the wrong way + // round: the erase is the safe outcome, and blocking it to protect the + // record would leave the daemon holding keys it was told to drop. + if change.dropped > 0 { + let record = AuthorizationRecord::new( + AuthorizationKind::Invalidate, + session_id.unwrap_or("*"), + vec![key_id.unwrap_or("*").to_string()], + ) + .requester(requester); + if let Err(err) = self.audit.append(&record) { + eprintln!("varlock: could not record an invalidation: {err}"); + } + } + change.dropped + } + + /// Handle a system lock event, erasing only the sessions whose own policy + /// says this event ends them. + /// + /// Separate from [`IdentitySessionManager::invalidate`], which is the + /// explicit lock and always erases everything. + pub fn handle_lock_event(&self, event: SessionLockEvent) -> usize { + let mut inner = self.lock(); + let change = inner.grants.invalidate_on_lock_event(event); + reconcile_locked(&mut inner); + change.dropped + } + + /// The lock policy a live session resolved to. + /// + /// Not on the wire: `list-sessions` already reports each grant's `lockOn`. + /// This is how the tests check the resolution without going through JSON. + #[cfg(test)] + pub fn lock_policy(&self, session_id: &str) -> Option { + let mut inner = self.lock(); + inner.grants.lock_policy(session_id) + } + + /// Whether the daemon is holding anything. Gates the idle auto-quit: session + /// state is memory-only, so quitting would silently cost someone an unlock. + pub fn has_live_sessions(&self) -> bool { + let mut inner = self.lock(); + reconcile_locked(&mut inner); + inner.grants.has_live_sessions() + } + + /// Sweep expired grants and erase what they were holding. + /// + /// Called on a timer so a hard-cap expiry erases key material even on a + /// daemon nobody is talking to. + pub fn reconcile(&self) { + let mut inner = self.lock(); + reconcile_locked(&mut inner); + } + + /// How many sessions the daemon is holding key material for. Used by the + /// tests to prove an erase actually erased. + #[cfg(test)] + pub fn held_session_count(&self) -> usize { + self.lock().material.len() + } +} + +/// Check that an unwrapped scalar really is the identity's private key. +fn verify_identity_key( + identity: &super::identity_store::StoredIdentity, + scalar: &GuardedBuffer, +) -> Result<(), SessionError> { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + + let key = crypto::secret_key_from_scalar(scalar.as_slice()).map_err(SessionError::Crypto)?; + let expected = BASE64 + .decode(&identity.public_key_base64) + .map_err(|_| IdentityStoreError::Malformed(identity.id.clone()))?; + + if crypto::public_key_bytes(&key) != expected { + return Err(SessionError::Crypto(format!( + "The key unwrapped for identity \"{}\" is not the one its file describes", + identity.id + ))); + } + Ok(()) +} + +/// Erase material for every session the grant table no longer considers live. +fn reconcile_locked(inner: &mut Inner) { + inner.grants.prune_expired(); + let live: std::collections::HashSet = + inner.grants.live_session_ids().into_iter().collect(); + // Removing the entry drops its GuardedBuffers, which zeroizes them. + inner.material.retain(|session_id, _| live.contains(session_id)); +} + +/// Split the requested keys into the ones that still need opening and the live +/// grants that already cover the request. +fn split_by_coverage( + grants: &mut SessionGrantTable, + session_id: &str, + key_ids: &[String], + requested_scope: SessionGrantScope, + requested_duration_ms: Option, +) -> (Vec, Vec) { + let mut to_open = Vec::new(); + let mut carried = Vec::new(); + + for key_id in key_ids { + let live = grants.live_grant(&SessionGrantRef::new(session_id, key_id.clone())); + match live { + Some(grant) + if covers(&grant, requested_scope, requested_duration_ms) => + { + carried.push(grant) + } + _ => to_open.push(key_id.clone()), + } + } + + (to_open, carried) +} + +/// Whether a live grant is already at least as strong as what was asked for. +/// +/// The rules are deliberately blunt, so the answer never depends on clock drift +/// or on comparing two windows measured from different starting points: +/// +/// - a `session` grant covers anything, since it is the longest thing on offer +/// - a `duration` grant covers a `once` request, and covers another `duration` +/// request only if the window already granted reaches past the new one +/// - a `once` grant covers only another `once` request +/// +/// Anything else counts as an upgrade and is worth asking about again. +fn covers( + live: &SessionGrantInfo, + requested_scope: SessionGrantScope, + requested_duration_ms: Option, +) -> bool { + if live.remaining_ms <= 0 { + return false; + } + match live.scope { + SessionGrantScope::Session => true, + SessionGrantScope::Duration => match requested_scope { + SessionGrantScope::Once => true, + SessionGrantScope::Duration => { + let window = requested_duration_ms.unwrap_or(MAX_GRANT_MS).min(MAX_GRANT_MS); + live.remaining_ms >= window + } + SessionGrantScope::Session => false, + }, + SessionGrantScope::Once => requested_scope == SessionGrantScope::Once, + } +} + +/// Plain, informative copy for the platform's presence prompt. +fn unlock_reason(identity_id: &str, key_ids: &[String], requester: Option<&str>) -> String { + let mut sorted = key_ids.to_vec(); + sorted.sort(); + let key_list = sorted.join(", "); + + let mut reason = if identity_id == super::identity_store::DEFAULT_IDENTITY_ID { + format!("unlock varlock encryption key {key_list}") + } else { + format!("unlock varlock identity \"{identity_id}\" with key {key_list}") + }; + if let Some(requester) = requester.filter(|line| !line.is_empty()) { + let trimmed: String = requester.chars().take(80).collect(); + reason.push_str(&format!(", {trimmed}")); + } + reason +} + +fn material_key(identity_id: &str, key_id: &str) -> String { + format!("{identity_id}\u{0}{key_id}") +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.filter(|text| !text.is_empty()) +} + +fn decode_base64(value: &str) -> Option> { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + BASE64.decode(value).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity_sessions::clock::ClockReading; + use crate::test_support::TempDir; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering}; + use std::sync::Arc; + use zeroize::Zeroize; + + /// A device key that lives entirely in the test: it wraps and unwraps with + /// the same software ECIES the real backends use, and can be told to demand + /// presence or to refuse it. + struct FakeCustody { + device_key: crypto::KeyPair, + requires_presence: bool, + presence_answer: Result, + presence_calls: Arc, + unwrap_calls: Arc, + } + + impl FakeCustody { + fn new() -> Self { + Self { + device_key: crypto::generate_key_pair().unwrap(), + requires_presence: true, + presence_answer: Ok(true), + presence_calls: Arc::new(AtomicUsize::new(0)), + unwrap_calls: Arc::new(AtomicUsize::new(0)), + } + } + + /// A wrap blob of the given identity scalar, as the TS side would write. + fn wrap(&self, identity_pkcs8_der: &[u8]) -> String { + let device_pub = BASE64.decode(&self.device_key.public_key).unwrap(); + let payload = crypto::encrypt_to_public_key( + &device_pub, + identity_pkcs8_der, + crypto::DEVICE_PAYLOAD_VERSION, + ) + .unwrap(); + BASE64.encode(&payload) + } + } + + impl CustodyBackend for FakeCustody { + fn unwrap_identity_scalar( + &self, + _key_id: &str, + wrap: &[u8], + ) -> Result { + self.unwrap_calls.fetch_add(1, Ordering::SeqCst); + let der = BASE64.decode(&self.device_key.private_key).unwrap(); + let secret = crypto::secret_key_from_pkcs8(&der).unwrap(); + let mut identity_der = + crypto::decrypt_payload(&secret, wrap, &[crypto::DEVICE_PAYLOAD_VERSION]) + .map_err(SessionError::Crypto)?; + let scalar = crate::key_store::scalar::pkcs8_to_raw_scalar(&identity_der) + .ok_or_else(|| SessionError::Crypto("not a P-256 PKCS8 key".into()))?; + identity_der.zeroize(); + Ok(GuardedBuffer::from_slice(&scalar)) + } + + fn requires_presence(&self, _key_id: &str) -> bool { + self.requires_presence + } + + fn verify_presence(&self, _reason: &str) -> Result { + self.presence_calls.fetch_add(1, Ordering::SeqCst); + self.presence_answer.clone() + } + } + + struct Fixture { + _dir: TempDir, + manager: IdentitySessionManager, + paths: SessionPaths, + identity_plaintext_payload: Vec, + presence_calls: Arc, + unwrap_calls: Arc, + clock: TestClock, + } + + #[derive(Clone)] + struct TestClock { + wall: Arc, + monotonic: Arc, + } + + impl TestClock { + fn start() -> Self { + Self { + wall: Arc::new(AtomicI64::new(1_700_000_000_000)), + monotonic: Arc::new(AtomicI64::new(5_000)), + } + } + fn reading(&self) -> ClockReading { + ClockReading { + wall: self.wall.load(Ordering::SeqCst), + monotonic: self.monotonic.load(Ordering::SeqCst), + } + } + fn advance(&self, ms: i64) { + self.wall.fetch_add(ms, Ordering::SeqCst); + self.monotonic.fetch_add(ms, Ordering::SeqCst); + } + } + + const SECRET: &str = "sk-live-do-not-log-this-🔐"; + + fn build(configure: impl FnOnce(&mut FakeCustody)) -> Fixture { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + + let mut custody = FakeCustody::new(); + configure(&mut custody); + let presence_calls = custody.presence_calls.clone(); + let unwrap_calls = custody.unwrap_calls.clone(); + + // An identity key, wrapped to the fake device key, exactly as the TS + // side writes it. + let identity_key = crypto::generate_key_pair().unwrap(); + let identity_der = BASE64.decode(&identity_key.private_key).unwrap(); + let wrap = custody.wrap(&identity_der); + + std::fs::create_dir_all(paths.identity_file("default").parent().unwrap()).unwrap(); + std::fs::write( + paths.identity_file("default"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "id": "default", + "publicKey": identity_key.public_key, + "wraps": { "varlock-default": wrap, "second-key": custody.wrap(&identity_der) }, + "createdAt": "2026-01-01T00:00:00.000Z", + })) + .unwrap(), + ) + .unwrap(); + + // A value encrypted to that identity, which is what a decrypt serves. + let identity_pub = BASE64.decode(&identity_key.public_key).unwrap(); + let identity_plaintext_payload = crypto::encrypt_to_public_key( + &identity_pub, + SECRET.as_bytes(), + crypto::IDENTITY_PAYLOAD_VERSION, + ) + .unwrap(); + + let manager = IdentitySessionManager::new(paths.clone(), Box::new(custody)); + let clock = TestClock::start(); + let clock_for_table = clock.clone(); + manager.set_grant_table(SessionGrantTable::with_clock(move || { + clock_for_table.reading() + })); + + Fixture { + _dir: dir, + manager, + paths, + identity_plaintext_payload, + presence_calls, + unwrap_calls, + clock, + } + } + + fn unlock_request<'a>(scope: SessionGrantScope) -> UnlockRequest<'a> { + UnlockRequest { + session_id: Some("tty:1:2"), + key_ids: vec!["varlock-default".into()], + identity_id: "default".into(), + scope, + duration_ms: None, + lock_on_override: None, + requester: Some("cargo test (pid 1)".into()), + } + } + + fn audit_lines(fixture: &Fixture) -> Vec { + let path = fixture.paths.audit_dir().join("authorizations.jsonl"); + let Ok(contents) = std::fs::read_to_string(path) else { return Vec::new() }; + contents + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + // ── Unlock ─────────────────────────────────────────────────── + + #[test] + fn an_unlock_opens_a_grant_and_costs_one_presence_check() { + let fixture = build(|_| {}); + let outcome = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect("unlock should succeed"); + + assert_eq!(outcome.grants.len(), 1); + assert_eq!(outcome.policy, UnlockPolicy::Biometrics); + assert_eq!(outcome.lock_on, SessionLockPolicy::Sleep); + assert_eq!(outcome.lock_on_source, LockPolicySource::BuiltInDefault); + assert!(outcome.prompted); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn one_unlock_covers_several_keys_with_a_single_check() { + let fixture = build(|_| {}); + let mut request = unlock_request(SessionGrantScope::Session); + request.key_ids = vec!["varlock-default".into(), "second-key".into()]; + + let outcome = fixture.manager.unlock(request).expect("unlock should succeed"); + assert_eq!(outcome.grants.len(), 2); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.unwrap_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn a_second_unlock_of_a_covered_key_asks_nothing() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + + let outcome = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect("second unlock should succeed"); + assert!(!outcome.prompted); + assert_eq!(outcome.grants.len(), 1); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.unwrap_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn asking_for_a_stronger_scope_than_is_held_asks_again() { + let fixture = build(|_| {}); + let mut once = unlock_request(SessionGrantScope::Once); + once.scope = SessionGrantScope::Once; + fixture.manager.unlock(once).unwrap(); + + let outcome = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect("upgrade should succeed"); + assert!(outcome.prompted); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn a_key_with_no_presence_gate_unlocks_silently() { + let fixture = build(|custody| custody.requires_presence = false); + let outcome = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect("unlock should succeed"); + assert!(!outcome.prompted); + assert_eq!(outcome.policy, UnlockPolicy::NoPresenceRequired); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_declined_presence_check_opens_nothing() { + let fixture = build(|custody| custody.presence_answer = Ok(false)); + let err = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect_err("a declined check must not unlock"); + assert_eq!(err.code(), Some("BIOMETRIC_FAILED")); + assert_eq!(fixture.unwrap_calls.load(Ordering::SeqCst), 0); + assert!(!fixture.manager.has_live_sessions()); + assert!(audit_lines(&fixture).is_empty()); + } + + #[test] + fn a_failed_presence_check_reports_the_platform_message() { + let fixture = build(|custody| { + custody.presence_answer = Err("Windows Hello device busy".into()) + }); + let err = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .unwrap_err(); + assert_eq!(err.code(), Some("BIOMETRIC_FAILED")); + assert!(err.to_string().contains("device busy")); + } + + #[test] + fn an_unlock_with_no_session_identity_is_refused() { + let fixture = build(|_| {}); + let mut request = unlock_request(SessionGrantScope::Session); + request.session_id = None; + assert_eq!( + fixture.manager.unlock(request).unwrap_err().code(), + Some("NO_SESSION_IDENTITY") + ); + + let mut request = unlock_request(SessionGrantScope::Session); + request.session_id = Some(""); + assert_eq!( + fixture.manager.unlock(request).unwrap_err().code(), + Some("NO_SESSION_IDENTITY") + ); + } + + #[test] + fn a_key_the_identity_has_no_wrap_for_fails_before_prompting() { + let fixture = build(|_| {}); + let mut request = unlock_request(SessionGrantScope::Session); + request.key_ids = vec!["a-key-from-another-machine".into()]; + + let err = fixture.manager.unlock(request).unwrap_err(); + assert_eq!(err.code(), Some("IDENTITY_NO_WRAP_FOR_KEY")); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn an_unknown_identity_is_reported_as_not_found() { + let fixture = build(|_| {}); + let mut request = unlock_request(SessionGrantScope::Session); + request.identity_id = "work".into(); + assert_eq!( + fixture.manager.unlock(request).unwrap_err().code(), + Some("IDENTITY_NOT_FOUND") + ); + } + + #[test] + fn the_machine_config_sets_the_lock_policy_and_is_read_fresh() { + let fixture = build(|_| {}); + std::fs::write( + fixture.paths.machine_config_file(), + br#"{"sessions":{"lockOn":"none"}}"#, + ) + .unwrap(); + + let outcome = fixture + .manager + .unlock(unlock_request(SessionGrantScope::Session)) + .unwrap(); + assert_eq!(outcome.lock_on, SessionLockPolicy::None); + assert_eq!(outcome.lock_on_source, LockPolicySource::MachineConfig); + } + + #[test] + fn an_unlock_override_beats_the_machine_config() { + let fixture = build(|_| {}); + std::fs::write( + fixture.paths.machine_config_file(), + br#"{"sessions":{"lockOn":"none"}}"#, + ) + .unwrap(); + + let mut request = unlock_request(SessionGrantScope::Session); + request.lock_on_override = Some("screenLock"); + let outcome = fixture.manager.unlock(request).unwrap(); + assert_eq!(outcome.lock_on, SessionLockPolicy::ScreenLock); + assert_eq!(outcome.lock_on_source, LockPolicySource::SessionOverride); + } + + // ── Decrypt ────────────────────────────────────────────────── + + #[test] + fn a_decrypt_under_a_live_grant_returns_the_plaintext() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + + let (plaintexts, grant) = fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + Some("cargo test".into()), + ) + .expect("decrypt should succeed"); + + assert_eq!(plaintexts, vec![SECRET.to_string()]); + assert_eq!(grant.use_count, 1); + } + + #[test] + fn a_batch_is_one_grant_use_however_many_payloads() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Once)).unwrap(); + + let batch = vec![ + fixture.identity_plaintext_payload.clone(), + fixture.identity_plaintext_payload.clone(), + fixture.identity_plaintext_payload.clone(), + ]; + let (plaintexts, grant) = fixture + .manager + .decrypt_v2(Some("tty:1:2"), "varlock-default", "default", &batch, None) + .expect("decrypt should succeed"); + assert_eq!(plaintexts.len(), 3); + assert_eq!(grant.use_count, 1); + + // and the once grant is now spent + let err = fixture + .manager + .decrypt_v2(Some("tty:1:2"), "varlock-default", "default", &batch, None) + .unwrap_err(); + assert_eq!(err.code(), Some("NO_SESSION_GRANT")); + } + + #[test] + fn a_decrypt_without_an_unlock_is_refused() { + let fixture = build(|_| {}); + let err = fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .unwrap_err(); + assert_eq!(err.code(), Some("NO_SESSION_GRANT")); + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn another_session_cannot_use_this_sessions_grant() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + + let err = fixture + .manager + .decrypt_v2( + Some("tty:9:9"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .unwrap_err(); + assert_eq!(err.code(), Some("NO_SESSION_GRANT")); + } + + #[test] + fn an_expired_grant_says_so_rather_than_claiming_it_never_existed() { + let fixture = build(|_| {}); + fixture + .manager + .unlock(UnlockRequest { + duration_ms: Some(1_000), + ..unlock_request(SessionGrantScope::Duration) + }) + .unwrap(); + + fixture.clock.advance(2_000); + let err = fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .unwrap_err(); + assert_eq!(err.code(), Some("SESSION_GRANT_EXPIRED")); + assert_eq!(fixture.manager.held_session_count(), 0, "key material should be gone"); + } + + #[test] + fn a_decrypt_never_prompts() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + let before = fixture.presence_calls.load(Ordering::SeqCst); + + for _ in 0..5 { + fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .unwrap(); + } + assert_eq!(fixture.presence_calls.load(Ordering::SeqCst), before); + } + + // ── Invalidation and lock events ───────────────────────────── + + #[test] + fn invalidating_erases_the_held_key() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + assert_eq!(fixture.manager.held_session_count(), 1); + + assert_eq!(fixture.manager.invalidate(None, None, None), 1); + assert_eq!(fixture.manager.held_session_count(), 0); + assert!(!fixture.manager.has_live_sessions()); + } + + #[test] + fn a_lock_event_only_ends_the_sessions_that_asked_for_it() { + let fixture = build(|_| {}); + let mut request = unlock_request(SessionGrantScope::Session); + request.lock_on_override = Some("none"); + fixture.manager.unlock(request).unwrap(); + + assert_eq!(fixture.manager.handle_lock_event(SessionLockEvent::Sleep), 0); + assert!(fixture.manager.has_live_sessions()); + assert_eq!(fixture.manager.lock_policy("tty:1:2"), Some(SessionLockPolicy::None)); + + // and an explicit lock always erases, whatever the policy says + assert_eq!(fixture.manager.invalidate(None, None, None), 1); + assert!(!fixture.manager.has_live_sessions()); + } + + #[test] + fn sleep_ends_a_default_session() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + assert_eq!(fixture.manager.handle_lock_event(SessionLockEvent::ScreenLock), 0); + assert_eq!(fixture.manager.handle_lock_event(SessionLockEvent::Sleep), 1); + assert_eq!(fixture.manager.held_session_count(), 0); + } + + // ── The death invariant ────────────────────────────────────── + + #[test] + fn a_daemon_restart_loses_every_grant() { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + + // Build the identity and its wrap once, then run two managers over the + // same user directory: the second one stands in for a restarted daemon. + let custody = FakeCustody::new(); + let identity_key = crypto::generate_key_pair().unwrap(); + let identity_der = BASE64.decode(&identity_key.private_key).unwrap(); + std::fs::create_dir_all(paths.identity_file("default").parent().unwrap()).unwrap(); + std::fs::write( + paths.identity_file("default"), + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "id": "default", + "publicKey": identity_key.public_key, + "wraps": { "varlock-default": custody.wrap(&identity_der) }, + "createdAt": "2026-01-01T00:00:00.000Z", + })) + .unwrap(), + ) + .unwrap(); + let device_key = crypto::KeyPair { + public_key: custody.device_key.public_key.clone(), + private_key: custody.device_key.private_key.clone(), + }; + + let first = IdentitySessionManager::new(paths.clone(), Box::new(custody)); + first.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + assert!(first.has_live_sessions()); + + // The daemon goes away. + drop(first); + + let second_custody = FakeCustody { + device_key, + requires_presence: true, + presence_answer: Ok(true), + presence_calls: Arc::new(AtomicUsize::new(0)), + unwrap_calls: Arc::new(AtomicUsize::new(0)), + }; + let second = IdentitySessionManager::new(paths.clone(), Box::new(second_custody)); + + assert!(!second.has_live_sessions(), "a restart must not inherit grants"); + assert!(second.list_grants().is_empty()); + let err = second + .decrypt_v2(Some("tty:1:2"), "varlock-default", "default", &[vec![0u8; 100]], None) + .unwrap_err(); + assert_eq!(err.code(), Some("NO_SESSION_GRANT")); + } + + #[test] + fn nothing_about_a_session_is_written_to_disk() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .unwrap(); + + // Everything under the user dir has to be one of the files that were + // already there, plus the append-only log. A new file would mean session + // state outliving the process. + let mut found: Vec = Vec::new(); + collect_files(fixture.paths.user_dir(), fixture.paths.user_dir(), &mut found); + found.sort(); + assert_eq!( + found, + vec![ + "audit/authorizations.jsonl".to_string(), + "identities/default.json".to_string(), + ] + ); + + // and the log itself carries no secret material + let log = std::fs::read_to_string( + fixture.paths.audit_dir().join("authorizations.jsonl"), + ) + .unwrap(); + assert!(!log.contains(SECRET)); + } + + fn collect_files(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files(root, &path, out); + } else if let Ok(relative) = path.strip_prefix(root) { + out.push(relative.to_string_lossy().replace('\\', "/")); + } + } + } + + // ── Audit before release ───────────────────────────────────── + + #[test] + fn every_authorization_is_recorded() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + &[ + fixture.identity_plaintext_payload.clone(), + fixture.identity_plaintext_payload.clone(), + ], + Some("node (pid 7)".into()), + ) + .unwrap(); + fixture.manager.invalidate(None, None, Some("node (pid 7)".into())); + + let records = audit_lines(&fixture); + let events: Vec<&str> = records + .iter() + .map(|record| record["event"].as_str().unwrap()) + .collect(); + assert_eq!(events, vec!["unlock-session", "decrypt-v2", "invalidate-session"]); + + assert_eq!(records[1]["payloadCount"], serde_json::json!(2)); + assert_eq!(records[1]["scope"], serde_json::json!("session")); + assert_eq!(records[1]["requester"], serde_json::json!("node (pid 7)")); + assert_eq!(records[1]["sessionId"], serde_json::json!("tty:1:2")); + } + + #[test] + fn a_decrypt_is_refused_when_its_authorization_cannot_be_recorded() { + let fixture = build(|_| {}); + fixture.manager.unlock(unlock_request(SessionGrantScope::Session)).unwrap(); + + // Make the log unwritable by putting a file where its directory is. The + // unlock has already created the directory, so remove it first. + let audit_dir = fixture.paths.audit_dir(); + std::fs::remove_dir_all(&audit_dir).unwrap(); + std::fs::write(&audit_dir, b"not a directory").unwrap(); + + let err = fixture + .manager + .decrypt_v2( + Some("tty:1:2"), + "varlock-default", + "default", + std::slice::from_ref(&fixture.identity_plaintext_payload), + None, + ) + .expect_err("a decrypt with no record must not return plaintext"); + assert_eq!(err.code(), Some("AUDIT_WRITE_FAILED")); + assert!(err.to_string().contains("Refusing to release secrets")); + } + + #[test] + fn an_unlock_that_cannot_be_recorded_hands_its_keys_back() { + let dir = TempDir::new(); + let paths = SessionPaths::with_user_dir(dir.path()); + + let custody = FakeCustody::new(); + let identity_key = crypto::generate_key_pair().unwrap(); + let identity_der = BASE64.decode(&identity_key.private_key).unwrap(); + std::fs::create_dir_all(paths.identity_file("default").parent().unwrap()).unwrap(); + std::fs::write( + paths.identity_file("default"), + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "id": "default", + "publicKey": identity_key.public_key, + "wraps": { "varlock-default": custody.wrap(&identity_der) }, + "createdAt": "2026-01-01T00:00:00.000Z", + })) + .unwrap(), + ) + .unwrap(); + + // A file where the audit directory belongs, from the very start. + std::fs::write(paths.audit_dir(), b"not a directory").unwrap(); + + let manager = IdentitySessionManager::new(paths, Box::new(custody)); + let err = manager + .unlock(unlock_request(SessionGrantScope::Session)) + .expect_err("an unrecordable unlock must not stand"); + assert_eq!(err.code(), Some("AUDIT_WRITE_FAILED")); + assert!(!manager.has_live_sessions()); + assert_eq!(manager.held_session_count(), 0, "the key must not still be held"); + } + + // ── Coverage rules ─────────────────────────────────────────── + + #[test] + fn coverage_rules_match_the_swift_planner() { + let info = |scope, remaining_ms| SessionGrantInfo { + session_id: "s".into(), + key_id: "k".into(), + identity_id: "default".into(), + scope, + granted_at: 0, + expires_at: 0, + remaining_ms, + last_used_at: None, + session_unlocked_at: 0, + session_expires_at: 0, + session_remaining_ms: remaining_ms, + lock_on: SessionLockPolicy::Sleep, + use_count: 0, + }; + + // a session grant covers everything + for scope in [ + SessionGrantScope::Once, + SessionGrantScope::Session, + SessionGrantScope::Duration, + ] { + assert!(covers(&info(SessionGrantScope::Session, 1_000), scope, Some(500))); + } + + // a once grant covers only another once request + assert!(covers(&info(SessionGrantScope::Once, 1_000), SessionGrantScope::Once, None)); + assert!(!covers(&info(SessionGrantScope::Once, 1_000), SessionGrantScope::Session, None)); + assert!(!covers( + &info(SessionGrantScope::Once, 1_000), + SessionGrantScope::Duration, + Some(10) + )); + + // a duration grant covers a once request, and a shorter duration request + assert!(covers(&info(SessionGrantScope::Duration, 1_000), SessionGrantScope::Once, None)); + assert!(covers( + &info(SessionGrantScope::Duration, 1_000), + SessionGrantScope::Duration, + Some(900) + )); + assert!(!covers( + &info(SessionGrantScope::Duration, 1_000), + SessionGrantScope::Duration, + Some(1_100) + )); + assert!(!covers( + &info(SessionGrantScope::Duration, 1_000), + SessionGrantScope::Session, + None + )); + + // and nothing with no time left covers anything + assert!(!covers(&info(SessionGrantScope::Session, 0), SessionGrantScope::Once, None)); + } + + #[test] + fn the_prompt_reason_names_the_keys_and_the_requester() { + let reason = unlock_reason( + "default", + &["b-key".into(), "a-key".into()], + Some("node (pid 7)"), + ); + assert_eq!( + reason, + "unlock varlock encryption key a-key, b-key, node (pid 7)" + ); + + let named = unlock_reason("work", &["k".into()], None); + assert_eq!(named, "unlock varlock identity \"work\" with key k"); + } +} diff --git a/packages/encryption-binary-rust/src/identity_sessions/mod.rs b/packages/encryption-binary-rust/src/identity_sessions/mod.rs new file mode 100644 index 000000000..b5780753c --- /dev/null +++ b/packages/encryption-binary-rust/src/identity_sessions/mod.rs @@ -0,0 +1,33 @@ +//! Identity-backed unlock sessions. +//! +//! Values are not encrypted to the device key directly. An identity key sits in +//! between: +//! +//! device key -> identity key -> values +//! +//! The identity is a software P-256 key pair whose private key is ECIES-wrapped +//! to one or more device keys, so unwrapping it goes through whatever gate the +//! device backend applies. Once unwrapped it has to be held somewhere for the +//! rest of a working session, or every value in an env file would cost its own +//! prompt. Holding it is what everything in this module exists to justify: +//! +//! - [`grants`] decides how long a hold may last, on two clocks at once +//! - [`lock_policy`] decides which system events end it early +//! - [`lock_events`] delivers those events from the platform +//! - [`audit`] records every authorization before any plaintext is released +//! - [`custody`] is the device-key half: unwrapping, and user presence +//! - [`manager`] ties them together and owns the held key material +//! +//! The protocol is the one the macOS daemon speaks (`unlock-session`, +//! `decrypt-v2`, `list-sessions`, `invalidate-session`), so a client cannot tell +//! which daemon it is talking to. Where the platforms genuinely differ, the +//! difference is documented at the point it appears rather than smoothed over. + +pub mod audit; +pub mod clock; +pub mod custody; +pub mod grants; +pub mod identity_store; +pub mod lock_events; +pub mod lock_policy; +pub mod manager; diff --git a/packages/encryption-binary-rust/src/ipc.rs b/packages/encryption-binary-rust/src/ipc.rs index cba133a55..5d0721035 100644 --- a/packages/encryption-binary-rust/src/ipc.rs +++ b/packages/encryption-binary-rust/src/ipc.rs @@ -18,10 +18,36 @@ use std::os::unix::net::{UnixListener, UnixStream}; const MAX_MESSAGE_SIZE: u32 = 10_000_000; // 10MB safety limit +/// What the daemon knows about who is connected, worked out from the connection +/// itself rather than from anything in the message. +/// +/// This distinction is the whole point of the type. The identity session ops +/// hand out held key material, so the session they act on has to be one the +/// caller cannot name: `session_id` is derived from the peer process, and +/// `claimed_session_id` is whatever the message said, kept separately and used +/// only by the older device-decrypt path that has always accepted it. +#[derive(Debug, Clone, Default)] +pub struct PeerContext { + /// Session identity resolved from the connecting process. + pub session_id: Option, + /// One line naming the peer, for the authorization log. + pub requester: Option, + /// The session id the message claimed. Never trusted for grants. + pub claimed_session_id: Option, +} +impl PeerContext { + /// The key the pre-identity `decrypt` path warms its biometric session + /// under. It has always fallen back to the client-reported value, which is + /// what makes WSL2 callers work at all, so that behaviour is preserved here + /// rather than tightened underneath them. + pub fn legacy_session_key(&self) -> Option { + self.session_id.clone().or_else(|| self.claimed_session_id.clone()) + } +} /// Message handler callback type. -pub type MessageHandler = Box) -> Value + Send + Sync>; +pub type MessageHandler = Box Value + Send + Sync>; /// IPC server that listens for length-prefixed JSON messages. pub struct IpcServer { @@ -129,11 +155,11 @@ impl IpcServer { continue; } - // Get peer session identity - let tty_id = get_peer_session_id(&stream); + // Who is connected, read off the connection + let peer = describe_unix_peer(&stream); std::thread::spawn(move || { - handle_client(stream, handler, on_activity, running, tty_id); + handle_client(stream, handler, on_activity, running, peer); }); } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { @@ -222,7 +248,6 @@ impl IpcServer { let handler = self.message_handler.clone(); let on_activity = self.on_activity.clone(); let running = self.running.clone(); - let tty_id: Option = None; // HANDLE is !Send, but it's safe to use from another thread // since we transfer exclusive ownership. Pass as raw pointer. @@ -241,7 +266,10 @@ impl IpcServer { return; } - handle_windows_client(pipe, handler, on_activity, running, tty_id); + // Who is connected, read off the pipe rather than the message + let peer = describe_pipe_peer(pipe); + + handle_windows_client(pipe, handler, on_activity, running, peer); unsafe { let _ = DisconnectNamedPipe(pipe); let _ = CloseHandle(pipe); @@ -273,7 +301,7 @@ fn handle_client( handler: Option>, on_activity: Option>, running: Arc, - tty_id: Option, + peer: PeerContext, ) { // Set blocking for reads let _ = stream.set_nonblocking(false); @@ -315,8 +343,14 @@ fn handle_client( // Handle message let id = message.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); + let mut peer = peer.clone(); + peer.claimed_session_id = message + .get("ttyId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let response = if let Some(ref handler) = handler { - handler(message, tty_id.clone()) + handler(message, peer) } else { serde_json::json!({"error": "No handler"}) }; @@ -400,20 +434,39 @@ fn verify_unix_client(_stream: &UnixStream) -> bool { // ── Peer session identity (Linux) ─────────────────────────────── +/// Everything the daemon can say about a Unix peer, from the socket alone. #[cfg(target_os = "linux")] -fn get_peer_session_id(stream: &UnixStream) -> Option { +fn describe_unix_peer(stream: &UnixStream) -> PeerContext { use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; use std::os::fd::AsFd; - let creds = getsockopt(&stream.as_fd(), PeerCredentials).ok()?; - let pid = creds.pid(); - - if pid <= 0 { - return None; + let pid = getsockopt(&stream.as_fd(), PeerCredentials) + .ok() + .map(|creds| creds.pid()) + .filter(|pid| *pid > 0) + .map(|pid| pid as u32); + + PeerContext { + session_id: pid.and_then(get_peer_session_id), + requester: pid.map(describe_requester), + claimed_session_id: None, } +} - let parent_session_id = get_parent_session_id(pid as u32); - let ai_session = get_ai_session_from_env(pid as u32); +/// macOS has no `SO_PEERCRED` equivalent wired up here, and runs the Swift +/// daemon in production anyway. The socket's 0600 permissions still restrict it +/// to the owning user; what is missing is a session identity, which means the +/// identity session ops answer `NO_SESSION_IDENTITY` on this build rather than +/// guessing one. +#[cfg(all(unix, not(target_os = "linux")))] +fn describe_unix_peer(_stream: &UnixStream) -> PeerContext { + PeerContext::default() +} + +#[cfg(target_os = "linux")] +fn get_peer_session_id(pid: u32) -> Option { + let parent_session_id = get_parent_session_id(pid); + let ai_session = get_ai_session_from_env(pid); match (ai_session, parent_session_id) { (Some((key, value)), Some(parent)) => Some(format!("env:{key}:{value}|{parent}")), @@ -423,6 +476,25 @@ fn get_peer_session_id(stream: &UnixStream) -> Option { } } +/// One line naming the connecting process, for the authorization log. +/// +/// Derived from the process itself, never from the message, so a line in the log +/// says who actually asked. Deliberately short: the log is a record of +/// authorizations, not a process dump. +#[cfg(target_os = "linux")] +fn describe_requester(pid: u32) -> String { + let name = std::fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .and_then(|path| path.file_name().map(|n| n.to_string_lossy().to_string())) + .or_else(|| { + std::fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|comm| comm.trim().to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + format!("{name} (pid {pid})") +} + #[cfg(target_os = "linux")] fn get_parent_session_id(pid: u32) -> Option { get_tty_session_id(pid).or_else(|| get_ptree_session_id(pid)) @@ -489,18 +561,18 @@ fn get_ptree_session_id(pid: u32) -> Option { Some(format!("ptree:{scope_pid}:{start_time}")) } +#[cfg(any(target_os = "linux", target_os = "windows", test))] /// Shells are one-shot command wrappers and should never scope a no-TTY session. -#[cfg(target_os = "linux")] const SHELL_RUNNER_NAMES: &[&str] = &["sh", "bash", "zsh", "dash", "fish", "ksh", "csh", "tcsh"]; +#[cfg(any(target_os = "linux", target_os = "windows", test))] /// Varlock CLI launchers are also one-shot; scope to the host that invoked them. -#[cfg(target_os = "linux")] const VARLOCK_LAUNCHER_NAMES: &[&str] = &["varlock", "varlock.exe", "varlock.cmd"]; /// Runtime/package-manager processes are wrappers only when their command line /// shows they are launching the Varlock CLI. A long-lived process like Vite may /// also be `node` or `bun`, and should remain a valid session scope. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows", test))] const PACKAGE_MANAGER_RUNNER_NAMES: &[&str] = &[ "bun", "node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", ]; @@ -524,7 +596,7 @@ fn parse_env_pairs<'a>(entries: impl Iterator) -> std::collecti .collect() } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows", test))] fn contains_runner_name(names: &[&str], name: &str) -> bool { let lower = name.to_ascii_lowercase(); names.contains(&lower.as_str()) @@ -549,14 +621,10 @@ fn process_command_line_launches_varlock(pid: u32) -> bool { process_args_launches_varlock(process_args(pid).iter().map(String::as_str)) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", test))] fn process_args_launches_varlock<'a>(args: impl Iterator) -> bool { args.into_iter().any(|arg| { - let name = std::path::Path::new(arg) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); + let name = file_name_of(arg).to_ascii_lowercase(); VARLOCK_LAUNCHER_NAMES.contains(&name.as_str()) || arg.contains("/node_modules/.bin/varlock") || arg.contains("/varlock/bin/cli.js") @@ -564,6 +632,17 @@ fn process_args_launches_varlock<'a>(args: impl Iterator) -> boo }) } +/// The last path component of an argument, splitting on both separators. +/// +/// `std::path::Path` only treats `\` as a separator when compiled for Windows, +/// and the arguments this reads can describe either platform's paths (a WSL +/// caller's command line names Windows paths). Doing it by hand keeps the answer +/// the same wherever the check runs. +#[cfg(any(target_os = "linux", test))] +fn file_name_of(arg: &str) -> &str { + arg.rsplit(['/', '\\']).next().unwrap_or(arg) +} + #[cfg(target_os = "linux")] fn is_ephemeral_runner(pid: u32) -> bool { let exe_path = std::fs::read_link(format!("/proc/{pid}/exe")).ok(); @@ -587,7 +666,7 @@ fn is_ephemeral_runner(pid: u32) -> bool { } /// Pick a stable scope PID from an ancestry chain (peer first, app root last). -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "windows", test))] fn select_scope_pid_from_chain( chain: &[u32], is_ephemeral: impl Fn(u32) -> bool, @@ -636,9 +715,211 @@ fn get_process_start_time(pid: u32) -> Option { fields.get(19)?.parse().ok() } -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -fn get_peer_session_id(_stream: &UnixStream) -> Option { - None +// ── Peer session identity (Windows) ───────────────────────────── + +/// Everything the daemon can say about a named-pipe peer. +/// +/// The pipe tells us the client's PID, which is the only trustworthy starting +/// point: the `ttyId` field a client may put in a message is kept separately in +/// [`PeerContext::claimed_session_id`] and never used to scope a grant. +#[cfg(windows)] +fn describe_pipe_peer(pipe: windows::Win32::Foundation::HANDLE) -> PeerContext { + let pid = client_process_id(pipe); + PeerContext { + session_id: pid.and_then(get_peer_session_id), + requester: pid.map(describe_requester), + claimed_session_id: None, + } +} + +#[cfg(windows)] +fn client_process_id(pipe: windows::Win32::Foundation::HANDLE) -> Option { + use windows::Win32::System::Pipes::GetNamedPipeClientProcessId; + let mut client_pid = 0u32; + // Safety: `pipe` is a connected named pipe handle owned by this thread. + let ok = unsafe { GetNamedPipeClientProcessId(pipe, &mut client_pid) }; + if ok.is_err() || client_pid == 0 { + return None; + } + Some(client_pid) +} + +/// Scope a Windows peer to a stable ancestor of its process tree. +/// +/// Windows has no controlling terminal, so there is no equivalent of the Linux +/// `tty:` scope: every session here is the process-tree kind. The chain walk and +/// the choice of which ancestor to scope to are the same shared code the Linux +/// path uses, so a shell wrapper or a `bun`/`npx` launcher is skipped +/// identically on both. +/// +/// Reading another process's environment on Windows needs `ReadProcessMemory` +/// against its PEB, so the agent-session environment variables the Linux path +/// prefers (`CLAUDE_CODE_SESSION_ID` and friends) are not consulted here. Two +/// agent sessions under one editor process therefore share a scope on Windows +/// where they would not on Linux. That is a coarser session, never a wider one: +/// the scope is still a single process tree on a single machine. +#[cfg(windows)] +fn get_peer_session_id(pid: u32) -> Option { + let mut chain: Vec = vec![pid]; + let mut current = pid; + + for _ in 0..64 { + let parent = get_parent_pid(current)?; + // 0 is "no parent recorded"; 4 is the System process, which is as far up + // as a user process's ancestry meaningfully goes. + if parent == 0 || parent == 4 || parent == current { + break; + } + // A recycled PID whose creation time is newer than its child's is not + // really the parent: Windows reuses PIDs freely, so an ancestry walk + // that ignored this could scope a session to an unrelated process. + if let (Some(parent_started), Some(child_started)) = + (get_process_start_time(parent), get_process_start_time(current)) + { + if parent_started > child_started { + break; + } + } + chain.push(parent); + current = parent; + } + + let scope_pid = select_scope_pid_from_chain(&chain, is_ephemeral_runner)?; + let start_time = get_process_start_time(scope_pid).unwrap_or(0); + Some(format!("ptree:{scope_pid}:{start_time}")) +} + +#[cfg(windows)] +fn describe_requester(pid: u32) -> String { + let name = process_image_name(pid).unwrap_or_else(|| "unknown".to_string()); + format!("{name} (pid {pid})") +} + +/// The executable file name for a process, without its path. +#[cfg(windows)] +fn process_image_name(pid: u32) -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // Safety: opening with the most limited access that answers the question. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; + + let mut buffer = [0u16; 1024]; + let mut length = buffer.len() as u32; + // Safety: `buffer` is large enough and `length` describes it. + let ok = unsafe { + QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut length, + ) + }; + // Safety: the handle came from a successful OpenProcess and is closed once. + unsafe { + let _ = CloseHandle(process); + } + if ok.is_err() || length == 0 { + return None; + } + + let path = String::from_utf16_lossy(&buffer[..length as usize]); + Some(path.rsplit('\\').next().unwrap_or(&path).to_string()) +} + +/// The command line of a process, so a `node`/`bun` ancestor can be recognised +/// as a varlock launcher rather than a long-lived app worth scoping to. +/// +/// Windows keeps the command line in the process's own PEB, which reading would +/// mean `ReadProcessMemory`. Rather than do that, this reports no arguments, +/// which makes [`is_ephemeral_runner`] treat a runtime process as a real scope. +/// The failure direction is a session that is scoped slightly too narrowly (an +/// extra unlock), never one shared with a process that should not have it. +#[cfg(windows)] +fn process_command_line_launches_varlock(_pid: u32) -> bool { + false +} + +#[cfg(windows)] +fn is_ephemeral_runner(pid: u32) -> bool { + let Some(name) = process_image_name(pid) else { return false }; + // Windows executables carry a .exe suffix that the shared name lists do not. + let name = name.strip_suffix(".exe").unwrap_or(&name).to_string(); + + contains_runner_name(SHELL_RUNNER_NAMES, &name) + || contains_runner_name(VARLOCK_LAUNCHER_NAMES, &name) + || (contains_runner_name(PACKAGE_MANAGER_RUNNER_NAMES, &name) + && process_command_line_launches_varlock(pid)) +} + +/// The parent PID, via a process snapshot. Windows has no `/proc`. +#[cfg(windows)] +fn get_parent_pid(pid: u32) -> Option { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + // Safety: a process-list snapshot of the whole system takes no input buffer. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; + + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + let mut parent = None; + // Safety: `entry` is sized as the API requires and the snapshot is live. + unsafe { + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == pid { + parent = Some(entry.th32ParentProcessID); + break; + } + if Process32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + parent +} + +/// Process creation time, in 100ns units. Pairs with the PID to identify one +/// run of a process rather than the number Windows may hand out again later. +#[cfg(windows)] +fn get_process_start_time(pid: u32) -> Option { + use windows::Win32::Foundation::{CloseHandle, FILETIME}; + use windows::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // Safety: opening with the most limited access that answers the question. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?; + + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + // Safety: four valid output parameters for a handle we just opened. + let ok = unsafe { + GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) + }; + // Safety: the handle came from a successful OpenProcess and is closed once. + unsafe { + let _ = CloseHandle(process); + } + if ok.is_err() { + return None; + } + + Some(((creation.dwHighDateTime as u64) << 32) | creation.dwLowDateTime as u64) } // ── Windows pipe security ─────────────────────────────────────── @@ -809,7 +1090,7 @@ fn handle_windows_client( handler: Option>, on_activity: Option>, running: Arc, - tty_id: Option, + peer: PeerContext, ) { use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile, FlushFileBuffers}; @@ -863,13 +1144,18 @@ fn handle_windows_client( let id = message.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); - // On Windows, use client-reported ttyId from the message (set by --via-daemon callers) - let effective_tty_id = tty_id.clone().or_else(|| { - message.get("ttyId").and_then(|v| v.as_str()).map(|s| s.to_string()) - }); + // WSL2 callers reach the daemon through a fresh `varlock-local-encrypt.exe` + // each time, and pass the session they believe they are in as `ttyId`. + // The legacy decrypt path still honours that; the identity session ops + // read `session_id` instead, which came off the pipe. + let mut peer = peer.clone(); + peer.claimed_session_id = message + .get("ttyId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let response = if let Some(ref handler) = handler { - handler(message, effective_tty_id) + handler(message, peer) } else { serde_json::json!({"error": "No handler"}) }; @@ -912,67 +1198,48 @@ fn send_windows_response( // ── Tests ─────────────────────────────────────────────────────── +/// The scope-selection rules, which are shared by every platform and depend on +/// nothing but the ancestry chain handed to them. #[cfg(test)] -#[cfg(target_os = "linux")] -mod tests { +mod scope_tests { use super::*; #[test] - fn test_parse_proc_stat_self() { - let fields = parse_proc_stat(std::process::id()).expect("should parse own /proc/stat"); - // Should have at least 20 fields (we read up to field 19 for starttime) - assert!(fields.len() >= 20, "expected >=20 fields, got {}", fields.len()); - // Field 0 is state (single char like R, S, etc.) - assert_eq!(fields[0].len(), 1); - // Field 1 is ppid (should be > 0) - let ppid: u32 = fields[1].parse().expect("ppid should be a number"); - assert!(ppid > 0); - } - - #[test] - fn test_get_parent_pid() { - let ppid = get_parent_pid(std::process::id()).expect("should get own ppid"); - assert!(ppid > 1, "test process ppid should be > 1"); - } - - #[test] - fn test_get_process_start_time() { - let st = get_process_start_time(std::process::id()).expect("should get own start time"); - assert!(st > 0); + fn a_peer_context_prefers_the_derived_session_over_the_claimed_one() { + let peer = PeerContext { + session_id: Some("ptree:50:900".into()), + requester: Some("node (pid 100)".into()), + claimed_session_id: Some("tty:someone-elses-session".into()), + }; + assert_eq!(peer.legacy_session_key().as_deref(), Some("ptree:50:900")); } #[test] - fn test_parse_env_pairs() { - let input = vec![ - b"CODEX_THREAD_ID=abc123".as_slice(), - b"FOO=bar".as_slice(), - b"INVALID".as_slice(), - ]; - - let parsed = parse_env_pairs(input.into_iter()); - assert_eq!(parsed.get("CODEX_THREAD_ID").map(String::as_str), Some("abc123")); - assert_eq!(parsed.get("FOO").map(String::as_str), Some("bar")); - assert!(!parsed.contains_key("INVALID")); + fn the_legacy_path_still_falls_back_to_the_claimed_session() { + // This is what keeps WSL2 callers working: the pipe cannot tell us which + // shell inside WSL asked, so the pre-identity decrypt path accepts what + // the message said. The identity ops read `session_id` and so refuse. + let peer = PeerContext { + session_id: None, + requester: None, + claimed_session_id: Some("tty:pts-3:900".into()), + }; + assert_eq!(peer.legacy_session_key().as_deref(), Some("tty:pts-3:900")); + assert_eq!(peer.session_id, None); } #[test] fn test_select_scope_pid_shallow_codex_tree() { // [bun, codex-app] let chain = [100u32, 50]; - assert_eq!( - select_scope_pid_from_chain(&chain, |_| false), - Some(50), - ); + assert_eq!(select_scope_pid_from_chain(&chain, |_| false), Some(50)); } #[test] fn test_select_scope_pid_grandchild_codex_tree() { // [bun, codex-worker, codex-app] let chain = [100u32, 80, 50]; - assert_eq!( - select_scope_pid_from_chain(&chain, |_| false), - Some(50), - ); + assert_eq!(select_scope_pid_from_chain(&chain, |_| false), Some(50)); } #[test] @@ -980,10 +1247,7 @@ mod tests { // [bun, sh, codex-worker, codex-app] let chain = [100u32, 99, 80, 50]; let is_shell = |pid: u32| pid == 99; - assert_eq!( - select_scope_pid_from_chain(&chain, is_shell), - Some(80), - ); + assert_eq!(select_scope_pid_from_chain(&chain, is_shell), Some(80)); } #[test] @@ -991,10 +1255,7 @@ mod tests { // [varlock-local-encrypt peer, bun, codex-worker, codex-app] let chain = [100u32, 99, 80, 50]; let is_package_manager = |pid: u32| pid == 99; - assert_eq!( - select_scope_pid_from_chain(&chain, is_package_manager), - Some(80), - ); + assert_eq!(select_scope_pid_from_chain(&chain, is_package_manager), Some(80)); } #[test] @@ -1016,10 +1277,7 @@ mod tests { // [node, zsh, claude, extension-host, cursor] let chain = [100u32, 99, 88, 77, 50]; let is_shell = |pid: u32| pid == 99; - assert_eq!( - select_scope_pid_from_chain(&chain, is_shell), - Some(88), - ); + assert_eq!(select_scope_pid_from_chain(&chain, is_shell), Some(88)); } #[test] @@ -1028,6 +1286,68 @@ mod tests { assert_eq!(select_scope_pid_from_chain(&chain, |_| false), None); } + #[test] + fn varlock_launchers_are_recognised_with_and_without_an_exe_suffix() { + assert!(process_args_launches_varlock(["/usr/local/bin/varlock"].into_iter())); + assert!(process_args_launches_varlock(["C:\\tools\\varlock.exe"].into_iter())); + assert!(process_args_launches_varlock( + ["node", "/repo/node_modules/.bin/varlock", "run"].into_iter() + )); + assert!(!process_args_launches_varlock(["node", "server.js"].into_iter())); + } +} + +/// The `/proc`-backed half, which only exists on Linux. +#[cfg(test)] +#[cfg(target_os = "linux")] +mod tests { + use super::*; + + #[test] + fn test_parse_proc_stat_self() { + let fields = parse_proc_stat(std::process::id()).expect("should parse own /proc/stat"); + // Should have at least 20 fields (we read up to field 19 for starttime) + assert!(fields.len() >= 20, "expected >=20 fields, got {}", fields.len()); + // Field 0 is state (single char like R, S, etc.) + assert_eq!(fields[0].len(), 1); + // Field 1 is ppid (should be > 0) + let ppid: u32 = fields[1].parse().expect("ppid should be a number"); + assert!(ppid > 0); + } + + #[test] + fn test_get_parent_pid() { + let ppid = get_parent_pid(std::process::id()).expect("should get own ppid"); + assert!(ppid > 1, "test process ppid should be > 1"); + } + + #[test] + fn test_get_process_start_time() { + let st = get_process_start_time(std::process::id()).expect("should get own start time"); + assert!(st > 0); + } + + #[test] + fn test_parse_env_pairs() { + let input = vec![ + b"CODEX_THREAD_ID=abc123".as_slice(), + b"FOO=bar".as_slice(), + b"INVALID".as_slice(), + ]; + + let parsed = parse_env_pairs(input.into_iter()); + assert_eq!(parsed.get("CODEX_THREAD_ID").map(String::as_str), Some("abc123")); + assert_eq!(parsed.get("FOO").map(String::as_str), Some("bar")); + assert!(!parsed.contains_key("INVALID")); + } + + + + + + + + #[test] fn test_get_ptree_session_id_self() { // The test runner process should have a deep enough chain diff --git a/packages/encryption-binary-rust/src/key_store/mod.rs b/packages/encryption-binary-rust/src/key_store/mod.rs index 12fdd1c5e..b6bf47baa 100644 --- a/packages/encryption-binary-rust/src/key_store/mod.rs +++ b/packages/encryption-binary-rust/src/key_store/mod.rs @@ -89,6 +89,18 @@ pub struct StoredKey { /// How the private key is protected pub protection: Protection, pub created_at: String, + /// Should using this key cost a user-presence check? + /// + /// False only for a key created with `--no-auth`, which is the CI case: the + /// key is still protected at rest, there is just nobody to ask. Key files + /// written before this field existed are read as `true`, so an upgrade never + /// silently drops a prompt. + #[serde(default = "default_require_auth")] + pub require_auth: bool, +} + +fn default_require_auth() -> bool { + true } /// Information about what key protection is available on this platform. @@ -414,7 +426,7 @@ pub fn list_keys() -> Vec { /// Generate a new key pair and store it with platform-specific protection. /// Returns the base64 public key. -pub fn generate_key(key_id: &str) -> Result { +pub fn generate_key(key_id: &str, require_auth: bool) -> Result { let key_pair = crate::crypto::generate_key_pair()?; // Decode the private key to protect it @@ -430,6 +442,7 @@ pub fn generate_key(key_id: &str) -> Result { protected_private_key: protected, protection, created_at: now_iso8601(), + require_auth, }; write_stored_key(&stored)?; @@ -541,57 +554,42 @@ pub fn load_key(key_id: &str) -> Result<(Vec, String), String> { /// Load just the public key (no protection needed). pub fn load_public_key(key_id: &str) -> Result { - let path = get_key_file_path(key_id); - let data = fs::read_to_string(&path).map_err(|_| format!("Key not found: {key_id}"))?; - let stored: StoredKey = - serde_json::from_str(&data).map_err(|e| format!("Corrupted key file: {e}"))?; - Ok(stored.public_key) + Ok(read_stored_key(key_id)?.public_key) } -fn now_iso8601() -> String { - // Simple ISO 8601 without external crate - let duration = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let secs = duration.as_secs(); - // Approximate UTC — good enough for metadata - let days = secs / 86400; - let time_of_day = secs % 86400; - let hours = time_of_day / 3600; - let minutes = (time_of_day % 3600) / 60; - let seconds = time_of_day % 60; - - // Calculate year/month/day from days since epoch (simplified) - let mut y = 1970i64; - let mut remaining_days = days as i64; - loop { - let days_in_year = if is_leap_year(y) { 366 } else { 365 }; - if remaining_days < days_in_year { - break; - } - remaining_days -= days_in_year; - y += 1; - } - let mut m = 1u32; - let days_in_months = if is_leap_year(y) { - [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - } else { - [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - }; - for dim in days_in_months { - if remaining_days < dim { - break; - } - remaining_days -= dim; - m += 1; - } - let d = remaining_days + 1; +/// Whether using this key should cost a user-presence check. +/// +/// A key this machine does not have answers `true`: refusing to prompt for a +/// key we cannot find would be the wrong default, and the caller fails on the +/// missing key a moment later anyway. +pub fn key_requires_auth(key_id: &str) -> bool { + read_stored_key(key_id).map(|stored| stored.require_auth).unwrap_or(true) +} + +/// Per-key metadata, for the `status` command. No secret material. +pub fn key_details() -> Vec { + list_keys() + .into_iter() + .filter_map(|key_id| { + let stored = read_stored_key(&key_id).ok()?; + Some(serde_json::json!({ + "keyId": stored.key_id, + "requireAuth": stored.require_auth, + "protection": stored.protection.to_string(), + "createdAt": stored.created_at, + })) + }) + .collect() +} - format!("{y:04}-{m:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +fn read_stored_key(key_id: &str) -> Result { + let path = get_key_file_path(key_id); + let data = fs::read_to_string(&path).map_err(|_| format!("Key not found: {key_id}"))?; + serde_json::from_str(&data).map_err(|e| format!("Corrupted key file: {e}")) } -fn is_leap_year(y: i64) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +fn now_iso8601() -> String { + crate::timefmt::now_iso8601_seconds() } #[cfg(test)] diff --git a/packages/encryption-binary-rust/src/main.rs b/packages/encryption-binary-rust/src/main.rs index e7cef066a..b32c13441 100644 --- a/packages/encryption-binary-rust/src/main.rs +++ b/packages/encryption-binary-rust/src/main.rs @@ -8,9 +8,13 @@ mod crypto; mod daemon; mod daemon_client; +mod identity_sessions; mod ipc; mod key_store; mod secure_mem; +#[cfg(test)] +mod test_support; +mod timefmt; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde_json::json; @@ -77,14 +81,18 @@ fn json_success(result: serde_json::Value) -> ! { fn cmd_generate_key(args: &[String]) { let key_id = get_key_id(args); + // CI mode: the key is still protected at rest, there is just nobody to ask + // for presence. Matches the Swift binary's flag of the same name. + let require_auth = !args.contains(&"--no-auth".to_string()); - match key_store::generate_key(&key_id) { + match key_store::generate_key(&key_id, require_auth) { Ok(public_key) => { let pub_bytes = BASE64.decode(&public_key).unwrap_or_default(); json_success(json!({ "keyId": key_id, "publicKey": public_key, "publicKeyBytes": pub_bytes.len(), + "requireAuth": require_auth, })); } Err(e) => json_error(&e), @@ -231,6 +239,9 @@ fn cmd_status() { "platform": std::env::consts::OS, "arch": std::env::consts::ARCH, "keys": keys, + // Per-key metadata, so the TS side can tell which keys carry a presence + // gate without opening any of them. + "keyDetails": key_store::key_details(), }); // Include setup hints for optional features. @@ -365,7 +376,9 @@ fn cmd_help() { let help = r#"varlock-local-encrypt - Cross-platform local encryption for Varlock COMMANDS: - generate-key [--key-id ] Create a new encryption key + generate-key [--key-id ] [--no-auth] + Create a new encryption key + (--no-auth: no user-presence check, for CI) rewrap-key [--key-id ] Re-wrap key with best available protection (optional; auto on decrypt) delete-key [--key-id ] Delete an encryption key list-keys List all Varlock encryption keys diff --git a/packages/encryption-binary-rust/src/secure_mem.rs b/packages/encryption-binary-rust/src/secure_mem.rs index 9b89ca1a5..8453d39ff 100644 --- a/packages/encryption-binary-rust/src/secure_mem.rs +++ b/packages/encryption-binary-rust/src/secure_mem.rs @@ -2,6 +2,14 @@ //! //! - Locks memory pages to prevent swapping to disk (VirtualLock / mlock) //! - Zeroizes memory on drop to prevent lingering secrets +//! - Keeps key bytes out of core dumps and out of another process's reach +//! +//! The daemon holds an identity key for as long as a session's grant lives, so +//! "in memory" here means minutes to hours rather than the microseconds a +//! one-shot decrypt needs. [`GuardedBuffer`] is the type that hold uses: a +//! fixed-size allocation that never grows (and so never leaves a stale copy +//! behind at an old address), locked, excluded from dumps, and zeroized the +//! moment the session ends. use zeroize::Zeroize; @@ -64,6 +72,143 @@ impl Drop for SecureString { } } +/// A fixed-size buffer for key material the daemon holds across calls. +/// +/// The size is fixed at construction and the allocation never moves, which is +/// the difference that matters against a `Vec` or a `String`: those reallocate +/// as they grow and leave the old, still-populated block behind for the +/// allocator to hand out. Everything else is defence in depth around that: +/// +/// - the pages are locked (`mlock` / `VirtualLock`) so the bytes cannot be +/// written to swap or to a hibernation file +/// - on Linux the range is marked `MADV_DONTDUMP`, so a core dump of the +/// daemon does not carry it +/// - dropping zeroizes before unlocking, so nothing is readable afterwards +/// +/// See [`harden_process`] for the process-wide half of this (no core dumps at +/// all, and no ptrace from a sibling process). +pub struct GuardedBuffer { + /// Boxed rather than a `Vec`: a boxed slice has no spare capacity and no + /// growth path, so the address and length we lock stay the ones we free. + bytes: Box<[u8]>, +} + +impl GuardedBuffer { + /// A zeroed buffer of exactly `len` bytes, locked and dump-excluded. + pub fn zeroed(len: usize) -> Self { + let bytes = vec![0u8; len].into_boxed_slice(); + let buffer = Self { bytes }; + buffer.protect(); + buffer + } + + /// Copy `data` into a guarded buffer of exactly its length. + /// + /// The source is the caller's problem: use [`GuardedBuffer::take_vec`] when + /// the bytes arrived in a `Vec` that should not outlive the copy. + pub fn from_slice(data: &[u8]) -> Self { + let mut buffer = Self::zeroed(data.len()); + buffer.as_mut_slice().copy_from_slice(data); + buffer + } + + /// Move the contents of a `Vec` into a guarded buffer, scrubbing the `Vec`. + /// + /// The `Vec` is where most key material arrives (a keyring read, a DPAPI + /// unprotect), and it is unguarded for its whole life. This does not undo + /// that, but it does make the unguarded copy as short-lived as it can be. + /// The borrow is deliberate: the caller keeps the `Vec` and can see that it + /// came back empty. + pub fn take_vec(data: &mut Vec) -> Self { + let buffer = Self::from_slice(data); + data.zeroize(); + buffer + } + + pub fn as_slice(&self) -> &[u8] { + &self.bytes + } + + pub fn as_mut_slice(&mut self) -> &mut [u8] { + &mut self.bytes + } + + fn protect(&self) { + if self.bytes.is_empty() { + return; + } + lock_memory(self.bytes.as_ptr(), self.bytes.len()); + exclude_from_dumps(self.bytes.as_ptr(), self.bytes.len()); + } +} + +impl Drop for GuardedBuffer { + fn drop(&mut self) { + if self.bytes.is_empty() { + return; + } + let ptr = self.bytes.as_ptr(); + let len = self.bytes.len(); + // Zeroize first, while the pages are still locked. + self.bytes.zeroize(); + unlock_memory(ptr, len); + } +} + +impl std::fmt::Debug for GuardedBuffer { + /// Never prints the contents. A stray `{:?}` on a key is a leak into a log. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GuardedBuffer({} bytes, redacted)", self.bytes.len()) + } +} + +/// Process-wide hardening, called once as the daemon starts. +/// +/// Buffer-level guards only cover the buffers we know about. These cover the +/// process: with core dumps disabled there is no file for anything to leak +/// into, and with `PR_SET_DUMPABLE` cleared another process running as the same +/// user cannot attach a debugger and read the address space out from under the +/// locks. Both are best effort: a platform that refuses either still runs, it +/// just runs with one less guard, so failures are reported rather than fatal. +pub fn harden_process() { + #[cfg(unix)] + { + // No core file, so a crash cannot write held key material to disk. + let limit = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + // Safety: a well-formed rlimit for a resource that always exists. + let rc = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &limit) }; + if rc != 0 { + eprintln!("varlock: could not disable core dumps; held keys could reach a crash dump"); + } + } + + #[cfg(target_os = "linux")] + { + // Clearing the dumpable flag also stops a same-user process from + // ptrace-attaching, which is the cheapest way to read a held key. + // + // The arguments are spelled as `c_ulong` on purpose: `prctl` is variadic, + // so an untyped `0` would be promoted as an `int` and the kernel reads + // these as `unsigned long`. + // Safety: prctl with a constant option and no output pointers. + let rc = unsafe { + libc::prctl( + libc::PR_SET_DUMPABLE, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + ) + }; + if rc != 0 { + eprintln!( + "varlock: could not clear PR_SET_DUMPABLE ({}); this daemon can be traced", + std::io::Error::last_os_error() + ); + } + } +} + // ── Platform-specific memory locking ──────────────────────────── #[cfg(target_os = "windows")] @@ -101,3 +246,91 @@ fn lock_memory(_ptr: *const u8, _len: usize) {} #[cfg(not(any(unix, target_os = "windows")))] fn unlock_memory(_ptr: *const u8, _len: usize) {} + +// ── Dump exclusion ────────────────────────────────────────────── + +/// Keep a range out of core dumps. +/// +/// Linux takes this per range through `madvise`, which insists on a +/// page-aligned start, so the request is widened down to the page boundary. +/// That can cover a few unrelated heap bytes in the same page, which costs +/// nothing: `MADV_DONTDUMP` only affects what a dump contains. +#[cfg(target_os = "linux")] +fn exclude_from_dumps(ptr: *const u8, len: usize) { + // Safety: sysconf with a constant name, no pointers involved. + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page_size <= 0 { + return; + } + let page_size = page_size as usize; + + let start = ptr as usize & !(page_size - 1); + let end = ptr as usize + len; + // Safety: the range covers a live allocation, widened only to the start of + // the page it begins in. madvise is advisory and cannot invalidate it. + unsafe { + libc::madvise(start as *mut libc::c_void, end - start, libc::MADV_DONTDUMP); + } +} + +/// Every other platform disables dumps process-wide instead: macOS and Windows +/// have no per-range equivalent, and [`harden_process`] already clears +/// `RLIMIT_CORE` where there is one. +#[cfg(not(target_os = "linux"))] +fn exclude_from_dumps(_ptr: *const u8, _len: usize) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_guarded_buffer_has_exactly_the_length_asked_for() { + let buffer = GuardedBuffer::zeroed(32); + assert_eq!(buffer.as_slice().len(), 32); + assert_eq!(buffer.as_slice(), &[0u8; 32]); + } + + #[test] + fn from_slice_copies_the_bytes() { + let buffer = GuardedBuffer::from_slice(b"a 32 byte-ish private scalar..."); + assert_eq!(buffer.as_slice(), b"a 32 byte-ish private scalar..."); + } + + #[test] + fn take_vec_scrubs_the_source() { + let mut source = vec![7u8; 48]; + let buffer = GuardedBuffer::take_vec(&mut source); + + assert_eq!(buffer.as_slice(), &[7u8; 48]); + assert!(source.is_empty(), "the source Vec still holds key bytes"); + } + + #[test] + fn an_empty_buffer_is_harmless() { + let buffer = GuardedBuffer::zeroed(0); + assert!(buffer.as_slice().is_empty()); + assert_eq!(buffer.as_slice(), b""); + } + + #[test] + fn a_mutable_buffer_can_be_filled_in_place() { + let mut buffer = GuardedBuffer::zeroed(4); + buffer.as_mut_slice().copy_from_slice(&[1, 2, 3, 4]); + assert_eq!(buffer.as_slice(), &[1, 2, 3, 4]); + } + + #[test] + fn debug_never_prints_the_contents() { + let buffer = GuardedBuffer::from_slice(b"super-secret"); + let rendered = format!("{buffer:?}"); + assert!(!rendered.contains("super-secret")); + assert!(rendered.contains("redacted")); + } + + #[test] + fn hardening_the_process_does_not_panic() { + // Running it twice is also what a restarted daemon effectively does. + harden_process(); + harden_process(); + } +} diff --git a/packages/encryption-binary-rust/src/test_support.rs b/packages/encryption-binary-rust/src/test_support.rs new file mode 100644 index 000000000..d9d6e1f2e --- /dev/null +++ b/packages/encryption-binary-rust/src/test_support.rs @@ -0,0 +1,44 @@ +//! Small helpers the unit tests share. +//! +//! Compiled only under `cfg(test)`, so nothing here ships in the binary. A +//! hand-rolled temporary directory keeps the dependency list to what the daemon +//! itself needs: adding a dev-dependency for four lines of `create_dir_all` +//! would put another crate in the supply chain of a binary whose whole job is +//! holding keys. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// A directory under the system temp dir, removed when the handle drops. +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub fn new() -> Self { + let unique = format!( + "varlock-test-{}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).expect("could not create a temp dir for the test"); + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/packages/encryption-binary-rust/src/timefmt.rs b/packages/encryption-binary-rust/src/timefmt.rs new file mode 100644 index 000000000..a991787e4 --- /dev/null +++ b/packages/encryption-binary-rust/src/timefmt.rs @@ -0,0 +1,132 @@ +//! ISO 8601 formatting, without pulling in a date crate. +//! +//! Two formats are produced here. Key metadata uses whole seconds, which is what +//! the key files have always carried. The authorization log uses milliseconds, +//! because that is what `ISO8601DateFormatter` with `.withFractionalSeconds` +//! writes on the Swift side, and the two daemons' logs have to be uniform enough +//! to concatenate and sort. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// `2026-08-30T12:34:56Z` +pub fn now_iso8601_seconds() -> String { + format_iso8601(epoch_ms_now(), false) +} + +/// `2026-08-30T12:34:56.789Z` +pub fn now_iso8601_millis() -> String { + format_iso8601(epoch_ms_now(), true) +} + +fn epoch_ms_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Format epoch milliseconds as UTC ISO 8601. +/// +/// UTC only, and proleptic-Gregorian, so no leap seconds and no zone database. +/// Timestamps before the epoch are clamped rather than formatted as negative +/// years: nothing here can legitimately produce one. +pub fn format_iso8601(epoch_ms: i64, with_millis: bool) -> String { + let epoch_ms = epoch_ms.max(0); + let total_secs = epoch_ms / 1000; + let millis = epoch_ms % 1000; + + let days = total_secs / 86_400; + let time_of_day = total_secs % 86_400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + let (year, month, day) = civil_from_days(days); + + if with_millis { + format!( + "{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z" + ) + } else { + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") + } +} + +/// Days since the epoch to a calendar date. +fn civil_from_days(days_since_epoch: i64) -> (i64, u32, i64) { + let mut year = 1970i64; + let mut remaining = days_since_epoch; + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if remaining < days_in_year { + break; + } + remaining -= days_in_year; + year += 1; + } + + let days_in_months: [i64; 12] = if is_leap_year(year) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut month = 1u32; + for days_in_month in days_in_months { + if remaining < days_in_month { + break; + } + remaining -= days_in_month; + month += 1; + } + + (year, month, remaining + 1) +} + +fn is_leap_year(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_the_epoch() { + assert_eq!(format_iso8601(0, false), "1970-01-01T00:00:00Z"); + assert_eq!(format_iso8601(0, true), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn formats_a_known_instant() { + // 2026-08-30T12:34:56.789Z + assert_eq!(format_iso8601(1_788_093_296_789, true), "2026-08-30T12:34:56.789Z"); + assert_eq!(format_iso8601(1_788_093_296_789, false), "2026-08-30T12:34:56Z"); + } + + #[test] + fn handles_a_leap_day() { + // 2024-02-29T00:00:00Z + assert_eq!(format_iso8601(1_709_164_800_000, false), "2024-02-29T00:00:00Z"); + } + + #[test] + fn handles_the_last_day_of_a_year() { + // 2023-12-31T23:59:59.999Z + assert_eq!(format_iso8601(1_704_067_199_999, true), "2023-12-31T23:59:59.999Z"); + } + + #[test] + fn sorts_lexicographically_in_the_order_time_runs() { + let earlier = format_iso8601(1_788_093_296_000, true); + let later = format_iso8601(1_788_093_296_001, true); + assert!(earlier < later); + } + + #[test] + fn now_is_after_2024() { + assert!(now_iso8601_millis().starts_with("20")); + assert!(now_iso8601_millis().as_str() > "2024-01-01T00:00:00.000Z"); + assert!(now_iso8601_seconds().ends_with('Z')); + } +} diff --git a/packages/encryption-binary-swift/README.md b/packages/encryption-binary-swift/README.md index aabed4212..178391c37 100644 --- a/packages/encryption-binary-swift/README.md +++ b/packages/encryption-binary-swift/README.md @@ -28,10 +28,786 @@ For our use case (programmatically granting VarlockEnclave access to items creat These APIs are only used in the "select existing item" picker flow when VarlockEnclave doesn't already have access. Items created by VarlockEnclave itself (via "Create New") don't need ACL modification. +## Identity unlock sessions + +Values can be encrypted to an identity key rather than straight to the device key +(see `packages/varlock/src/lib/local-encrypt/identity.ts`). The identity private key +is ECIES-wrapped to the device key, and only the daemon may unwrap it. Two enclave +keys are involved: + +- the **custody key** is the existing biometric device key. It holds the wrap at rest, + so opening a session always costs one user-presence check. +- the **session key** is created per unlock with `.privateKeyUsage` only (no presence). + It exists solely in daemon memory: no `.keydata` file is written. At unlock the + identity key is unwrapped once through the custody key and immediately re-wrapped + under the session key, and only that blob is kept. Later decrypts unwrap it silently. + +Ending a session scrubs the session key data, which crypto-erases every blob held under +it. Nothing is persisted, so a daemon restart loses all sessions on purpose: a +session-wrapped blob on disk plus a no-presence key would reopen silently after a +reboot, defeating the biometric gate. A restart just means the next use costs one scan. + +Daemon actions (IPC protocol version 3, reported by `ping`): + +| action | what it does | +| --- | --- | +| `unlock-session` | one approval and one presence check, however many key ids; records grants and returns them | +| `decrypt-v2` | decrypts a batch of v2 payloads under a live grant, no prompt | +| `list-sessions` | live grants: scope, granted keys, unlock time, remaining TTL | +| `invalidate-session` | no arguments drops everything; `sessionId` drops one session; both fields drop one grant | +| `request-approval` | asks a yes/no question on the panel and reports the answer; no key operation attached | + +Grants are keyed by (session x key), where the session comes from `SessionScoping`. +Scopes are `once`, `session`, and `duration`, and everything is capped at 12h from the +session's first unlock. + +A malformed message is refused rather than guessed at. `unlock-session` answers +`Missing payload` when there is no payload, and `NO_KEYS_REQUESTED` when it names no +key: there is no default key, because opening one the caller never asked for would +hand it a grant it did not request. Both `keyIds` and the singular `keyId` are +accepted, and blank entries are dropped before that check. + +Every deadline is recorded twice: once in wall-clock time, which is what `expiresAt` +reports and what a person reads, and once on `CLOCK_MONOTONIC_RAW`, which no clock +change can move. Whichever runs out first ends the grant, so setting the system clock +backwards cannot extend a session, and the cap still holds across a suspend because the +raw monotonic clock keeps counting while the machine sleeps. `expiresInMs` comes from +that pair rather than from the wall clock. + +### What ends a session + +Beyond the TTL and the 12h cap, a `lockOn` policy decides which system events erase a +session: + +| value | erased by | +| --- | --- | +| `screenLock` | screen lock and sleep | +| `sleep` | sleep only; survives the screen locking (default) | +| `none` | nothing but TTL expiry, the hard cap, or an explicit lock | + +Resolution order is per-session override, then machine config, then the built-in +default of `sleep`: + +- **per-session**: `unlock-session` takes an optional `lockOn`. It is stored on the + session, and the lock observers consult each session's own policy, so a `screenLock` + session can be erased by the same event a `none` session in the same daemon survives. + Re-unlocking a session is how it changes its policy. +- **machine config**: `sessions.lockOn` in the user-level config file varlock already + keeps at `/config.json` (the same file telemetry settings use): + + ```json + { "sessions": { "lockOn": "sleep" } } + ``` + + Read fresh at each unlock, so an edit applies to the next unlock with no restart. + Machine-level only, never project config: a project must not get to weaken how long + this machine holds keys. A missing file or section is not an error. A value that is + present and unrecognized is reported on stderr and skipped, falling through to the + next source rather than failing the unlock. + +The 12h hard cap is not configurable, and an explicit lock (`invalidate-session` with no +arguments, or the menu bar Lock All) always erases everything whatever the policy says. +Which macOS notification counts as which event: only `willSleepNotification` is `sleep`; +display sleep and fast user switching are `screenLock`, so a display that sleeps after a +couple of idle minutes does not read as the machine sleeping. + +The machine default is also editable from the menu bar, under "Lock Sessions On". That +writes the same `sessions.lockOn` field, keeping every other key in the file. + +## The menu bar + +The status item is the passive indicator: a closed lock when the daemon holds nothing, +an open one while any session is unlocked, with the session count next to it once there +is more than one. Both are SF Symbols templates, so they follow the menu bar's own +appearance. + +The menu is rebuilt each time it opens, and nothing in it ticks. Times are coarse for +that reason: "9h left" is still true an hour later, where a countdown would be wrong the +moment it was drawn. + +| item | what it does | +| --- | --- | +| one submenu per session | the terminal or process it belongs to, the keys it holds with scope and time left, how much of the 12h limit is left, and what will end it | +| Lock This Session | inside a session's submenu; drops that session's grants and nothing else | +| Lock All | erases every session and every cached biometric context, whatever their policy | +| Lock Sessions On | the machine default for new sessions: Screen lock, Sleep, or Only manually | +| Quit Daemon | stops the daemon, which also erases everything | + +Per-session policies are shown on their rows but are not editable there. A session's +policy was settled when the user approved its unlock, and re-unlocking is how it +changes; a menu that quietly rewrote it would be changing a decision after the fact. + +What the wording says is decided in `SessionMenuModel`, which has no AppKit in it and is +unit tested, so `StatusBarMenu` is only the translation into `NSMenuItem`s. + +## The authorization log + +Before `decrypt-v2` unwraps anything, the daemon appends a line to +`/audit/authorizations.jsonl` (0600, in a 0700 directory), flushes it +with `fsync`, and reads it back off the disk. If any of that fails the decrypt is refused +with `AUDIT_WRITE_FAILED` and no plaintext is produced. Unlocks are recorded the same way +and hand their keys straight back if the record fails, so the daemon never holds a +session that nothing says was opened. + +```json +{"event":"decrypt-v2","identityId":"default","keyIds":["varlock-default"],"payloadCount":12,"requester":"node ← claude ← zsh (ttys004)","scope":"session","sessionId":"tty:ttys004:1756...","ts":"2026-08-30T15:42:22.881Z"} +``` + +Invalidations are recorded too, but best effort: refusing to erase key material because a +log line would not write is the wrong way round, so those are reported on stderr and the +erase goes ahead. + +Records hold identifiers, counts, and a description of the calling process. No plaintext, +no ciphertext, and no key material ever goes in, which is what makes the file safe to read +and to hand to someone else. + +## Peer posture + +Beyond checking the connecting process's binary name, the daemon asks the kernel two +things about it: whether a debugger or tracer is attached (`CS_DEBUGGED` or `P_TRACED`), +and whether it is running with the Hardened Runtime (`CS_RUNTIME`). Each check has its own +stderr line and its own error code, `PEER_DEBUGGER_ATTACHED` and +`PEER_HARDENED_RUNTIME_MISSING`, with `PEER_POSTURE_UNREADABLE` when the status word +cannot be read at all. + +How hard they bite depends on the daemon's own code signature, read the same way, since a +daemon that is not hardened itself is in no position to demand it of anyone: + +| daemon | debugger attached | no Hardened Runtime | +| --- | --- | --- | +| development (`swift build`, ad-hoc signed) | reported | reported | +| signed release | rejected | reported | +| `sessions.peerPosture: "strict"` | rejected | rejected | +| `sessions.peerPosture: "warn"` | reported | reported | + +The hardening check only reports by default, even in release, because the processes that +legitimately connect are frequently not hardened: the standalone `varlock` binary is +ad-hoc signed by `bun build --compile`, and Homebrew's node and bun are ad-hoc signed too. +Turn it into a rejection here once the release pipeline signs the CLI with +`--options runtime`. Until then, anyone whose clients are all official builds can have it +today with `strict`. + +## The approval panel + +A gated key raises a panel before anything else happens. The daemon draws it, +because the daemon is the process that verified the peer and holds the keys, so it +is the only party that can say truthfully who is asking. + +The panel arms the presence check as soon as it is on screen and frontmost, so +**the scan is the approval**: the common case costs one gesture, with no separate +confirm click. The sensor is live about a fifth of a second after the panel +lands, which the arming check asserts. + +That wait used to be most of a second, for a reason that no longer applies: +arming summoned the system's own sheet, which covered the panel, so the delay was +the only thing standing between a user and approving something they never got to +read. The scan happens inside the panel now, so there is nothing to occlude and +nothing to wait for beyond the window finishing its appearance +(`ApprovalPanel.armingDelaySeconds`). + +**Setting Touch ID up is not approving anything.** The first time varlock uses +the sensor on a machine, and again after the enrolled fingerprints change, macOS +wants an interaction of its own. That happens first, alone, with nothing drawn +behind it and with wording that says what it is ("varlock is setting up Touch ID +approvals"). Its context is thrown away, so it cannot stand in for an approval. +The panel comes after, and its scan is the one that unlocks. First run therefore +costs two scans, on purpose. What has been set up is recorded next to the key +store (`.biometric-setup.json`, a hash of the enrolment), and +`_VARLOCK_BIOMETRIC_SETUP=0`/`1` overrides the decision if it ever misjudges a +machine. + +**The panel arms itself once, ever.** If the system sheet is dismissed, that is +not a refusal and not a failure: the panel stays up, unarmed, and says "Touch ID +canceled. Click Approve to scan again". Nothing re-presents the sheet without a +click. Choosing "Enter Password" inside the sheet moves the panel onto the +password path and waits for that click too. + +The panel is a window the daemon draws itself, not an `NSAlert`: the layout is the +message, and an alert can hold text and buttons and nothing else. **The scan +happens inside it.** `LAAuthenticationView`, bound to the context this approval +will run under, sits on its own line above the buttons; touching the sensor is the +approval and no system alert appears over the top of it. + +That view was written off once. An earlier arc concluded it rendered blank on +macOS 26 and shipped a drawn glyph plus the system's alert instead. Two +experiments settled it properly, and both are in `scripts/`: + +- `sign-probe.ts` signs the same binary several ways (ad-hoc, Developer ID with + the hardened runtime, and with entitlements) and runs the machine-checkable + probes against each. The view rendered in every variant, so the signature was + never the problem. It also established that entitlements the system will not + grant without a provisioning profile make the build unlaunchable (SIGKILL with + no output), and that `LARight` custody stays `-34018` whatever we sign with. +- `render-bisect.ts` then walked from the probe (which rendered) to the panel + (which did not), flipping one axis at a time and **measuring pixels** rather + than asking anyone: `WindowPixels` photographs the view's own area and counts + distinct greys, so a blank square and a drawn fingerprint are different numbers. + +The axis that flipped it was how the panel is presented from the IPC thread: + +| panel presented via | distinct greys | +| --- | --- | +| `DispatchQueue.main.sync` | 1 (blank) | +| `RunLoop.main.perform` | 51 (drawn) | + +The daemon answers IPC on a background queue, and the panel used to be drawn from +inside a main-QUEUE work item that stays in flight for as long as the modal is up. +LocalAuthentication needs the main queue to render into the view it was bound to, +and a blocked main queue starves it. Worse than blank: a bound view suppresses the +system alert, so nothing anywhere asks for a finger. That is the same starvation +that once stopped the check from arming, which is why `MainLoop` exists; the +presentation itself was the last place still doing it the old way. + +The fallback is still there for machines that cannot embed (no biometrics +enrolled, or the sensor locked out) and for `_VARLOCK_EMBEDDED_PROMPT=0`, and it +is the only path that raises a system dialog. + +The panel shows, top to bottom: + +- a **top bar**: the varlock mark, and one standing fact about approvals (what + ends a session and its 12h cap, or that unlocks are recorded). +- a **heading**: which keys, and which project asked. `varlock-default` is called + `local encryption` here; its id is an implementation detail nobody should have + to read off a panel. +- the **key box**: one row per key, with the vault it belongs to and how many + values it covers. A row opens to the value names, grouped by the file that + defined them, under a line saying they were reported by the client. A client + that sent no value names gets a row with no chevron that does not respond to a + click, because an affordance that opens nothing is a lie. They were: + the daemon has no way to know what an env value is called, and does not lend its + credibility to a string a caller sent. None of it is bound into the crypto. +- the **execution chain**: the line of processes leading to the caller, read off + the peer. The app that was launched sits at the top with its icon and the tty + it hosts ("iTerm2 · ttys004"), and the plumbing folds into "N more steps" on a + long chain. Without a launcher at the top (tmux, or a walk that ran out of + depth) the tty label goes to the topmost hop that really is on that tty, rather + than to whatever process happened to come first. + + One row can be **emphasised**, and it means one thing: the ACTOR, the program + the secrets are being loaded for. A script beats the interpreter running it + (`agent.ts`, not `bun`); a host that auto-loaded varlock (`next dev`, `vite`, a + test runner) is the actor because varlock ran on its behalf. varlock itself + never is: it is in every chain, so emphasising it would say nothing about this + request. Shells never are, since `zsh` is how a command was typed rather than + what it was typed for. The session root never is, having a treatment of its + own. When nothing qualifies, nothing is bold, which is the honest state for a + command a person typed: the values are for that command, and the command is + varlock. A bold row that always exists is a bold row that means nothing. + Opening the chain shows each hop's path and the word for its code-signing + posture. What a mark means is said under the hop it belongs to ("a script run + by bun: approval trusts this file, not the signed interpreter"), never in a + legend at the bottom that a reader would have to match back up to a row. + varlock's own hop is named plainly: `bunx varlock load` shows as `varlock` and + not as "varlock via bun", since bun is how varlock ships rather than who is + asking. + + **How varlock got here is always on screen**, never behind the expander: a + person typing a command and a program loading varlock as it starts are + different requests, and which one this is should not have to be inferred. + A typed command reads as one, with its command line (`$ varlock load`). An + auto-load says so and names the host's own command (`auto-loaded inside next + dev`). A `varlock run` also names the command that will receive the values + (`npm run build receives these values`), because that process does not exist + yet and is in no ancestry: a panel stopping at varlock would imply the values + do too. + + The command lines are read from the kernel's copy of each process's argv. The + MODE (cli, auto-load, sdk) is client-reported over the socket, because from + inside a spawned CLI an auto-load and a typed command are the same process with + the same arguments; it is a claim, and the wording never presents it as more. + Where the claim contradicts the chain (a peer that is not varlock's CLI at all, + or an auto-load claim with nothing above the CLI that could have loaded it) the + chain wins and the disagreement goes to the debug log as + `invocation-mode-overruled`. + + A varlock command line is trimmed to what a person needs to judge it: the + subcommand always, plus whatever changes what is being asked for (the `--` + target, `--env`, `--path`, a filter, and anything unrecognised, since staying + quiet about an unknown flag is the wrong default for evidence). Presentation + flags (`--format`, `--compact`, cache and verbosity switches) are dropped: a + shorter honest line gets read and a complete one does not. A line too long to + draw loses its middle rather than its tail, so the `--` target always survives. + + Colour on the chain means exactly one thing at a time. Green is a signature we + checked, amber is a script whose source is mutable, purple is a coding-agent + session, and the emphasised actor is marked in bright neutral rather than in an + accent colour: a coloured marker sitting next to a green dot reads as a verdict + on the process, and that marker is about structure. Red is not used here at + all, so it stays available for posture that is actually bad. +- the **session root**: the hop a "this session" grant actually attaches to, + drawn as a tinted row tagged "session root" at the place it really sits in the + ancestry. Every chain has exactly one, because every grant has one. The panel + offers "This session" as a scope, and a session nobody can point at is a + promise nobody can check. The hop comes from `SessionScoper.sessionAnchor`, + the same code that computes the identifier the grant is keyed by, so the row a + person reads and the identity they are granting to cannot drift apart. Usually + that is the outermost shell on the controlling terminal, drawn as "zsh" with + "Terminal ttys004" under it, which is the name the menu bar lists that session + under and the name ending it will use. With no terminal it is the stable + process-tree ancestor the scoper picks. A coding-agent session is DECORATION on + that row, applied when the agent turns out to be the anchoring process: the row + then carries the product, the session's own title, and when it started. The + title comes from the agent's own on-disk record, matched to the process by pid + and checked against its start time so a recycled pid cannot put somebody else's + session on the panel. No uuid is ever shown. The rail below the row is tinted + to match, so every process inside the session is a span you can see rather than + a relationship you have to work out, and that hop is never folded away. When + the walk stopped before reaching the anchor (an app bundle at the top, the + depth cap, the deadline) the mark goes on the topmost hop the chain does have, + which is the nearest thing to the anchor that was actually read. +- **for how long**: this session (the default), once, or a set time (1, 4, 8, or + 12 hours). The longest window on offer is the 12h cap itself, so a choice can + never be one the grant table would quietly clip. Selecting a segment changes + its colour and nothing else: a heavier selected label was a wider one, and the + control used to shift by a few points every time the user changed their mind. +- the **actions**: Deny, red with a stop mark, and the approve control beside it. + On a machine that can scan, that control IS the sensor: the system's own Touch + ID view, at the `small` control size (which is the 32pt one it actually draws, + rather than a 128pt one squeezed), with "Approve with Touch ID" beside it. + Touching the sensor approves, and clicking the words approves too. Nothing is + drawn behind it, and that is not a taste decision: `render-bisect.ts` measured + every arrangement, and any paint of ours behind the sensor blanks it (a layer + fill and a `draw(_:)` fill both go to 4 distinct greys), so the mock's solid + blue bar is not available at all, and the outline that stood in for it read as + neither a button nor a scan. Machines with no usable sensor get the plain blue + button, which is also what the panel switches to for the password path. On a + machine with no usable sensor the approve button is the password path itself + and there is no link offering it separately. +- **the password path is a password.** Where there is a sensor, "Use password..." + moves this same approval onto the device-password check, and puts a password + field on screen with no fingerprint step in front of it. That takes a detour. + `evaluatePolicy(.deviceOwnerAuthentication)` is the only policy that accepts a + password, and on a Mac with an enrolled sensor Apple documents it as drawing + the Touch ID sheet first, with the password behind that sheet's "Use + Password..." button: a finger asked of someone who has just said "not my + finger". No policy skips it. `evaluateAccessControl` does: an access control + constrained to `.devicePasscode` cannot be satisfied by biometry, so the system + goes straight to the field (measured on macOS 26.1). The context it hands back + is authenticated for device-owner presence, which is what the custody key's + `.userPresence` gate accepts; if a machine ever disagrees, the unlock asks + again through the policy rather than failing on a password the user did give. + +Reading the chain is best effort and bounded by a deadline +(`ExecutionChainBuilder`): a process that exits mid-walk, a signature that cannot +be checked, a session record that cannot be read, or a slow machine costs the +panel a detail, never its appearance. + +Icons follow the same rule (`PanelIcons`). A hop whose binary lives in an `.app` +gets that app's real icon, an agent session gets the agent's app icon where it is +installed, and a tool with no bundle to ask (bun, node, deno, python) gets a small +tile carrying its initial. Shells and anything unrecognised get the terminal +symbol. None of it is resolved on the path that draws the panel: every icon starts +as the generic mark and is filled in from the run loop, because a picture is never +worth delaying an approval for. Dropping a real icon at +`Contents/Resources/tool-icons/.png` in the app bundle overrides the +tile without a code change. + +Client-supplied context (project name and path, value names, vault labels) only +ever changes the wording. It can never change which keys are unlocked, which +scopes are offered, or whether a prompt happens at all. + +Nothing is modal over the panel while the prompt is armed, so the scope controls +stay live and **the scan approves whatever is selected at the moment the finger +lands**. Pick "Once", then scan, and you get Once. + +A scan that does not complete is not a refusal. The panel stays exactly as it was, +with a line saying what happened and a "Try again" button that arms the prompt +again. Nothing re-arms on its own, so a failing sensor cannot become a loop, and a +refusal is always something the user pressed. + +A second unlock in the same session asks only about what is new ("Also unlock +prod?"), and asks nothing at all when every key requested is already covered by a +live grant. + +When biometrics are unavailable, not enrolled, or locked out after too many +failures, there is nothing to embed. The panel falls back to its confirm button +raising the standard system dialog under `deviceOwnerAuthentication`, which still +accepts the device password. That fallback stays button-driven rather than arming +itself, since it is the shape this flow already had in the field. An ungated key +keeps the plain button flow, and shows no panel at all unless a prompt was forced. +`_VARLOCK_EMBEDDED_PROMPT=0` forces the fallback on a machine that could embed. + +Every path to a secret goes through this panel. The device-key format that +predates unlock sessions used to authenticate on its own, which on macOS is the +system sheet with nothing behind it: no statement of who was asking or what they +wanted, and after a lock it read as the machine demanding a fingerprint out of +nowhere. That path now draws the same panel. The only presence check that ever +happens without it is the setup step above, which says what it is. + +Two answers other than yes: + +- **`APPROVAL_DENIED`**: the user was asked and said no. +- **`NO_UI`**: there is no window server to ask on (an SSH session, a headless + runner). The daemon refuses rather than skipping the question, and the client is + expected to say so in the terminal. + +### Keys that ask every time + +A key created with `--auth-every-time` never receives a lasting grant. The panel +offers `once` alone for it, and every later batch of decrypts asks again. In a +mixed batch its row is marked "asks every time", and it takes a `once` grant no +matter which scope the rest of the batch was approved for. + +The policy is recorded next to the key, in `/.policy.json`, which +`generate-key` now always writes. It carries two separate things: `authMode`, which +is this policy, and `requireAuth`, which records whether the key was created with +`--no-auth` and so carries no presence gate at all. The second cannot be read back +off a stored enclave key, so the file is the only record of it, and `status` reports +both per key in `keyDetails` for the TypeScript side to route on. + +A key with no such file, which is anything created before the file existed, reads as +gated and normal. That includes keys made with `--no-auth` back then: they keep the +routing they always had until they are regenerated, which is the safe way to be wrong. + +### `request-approval` + +The same panel with a different subject: a title, some description lines, the +scopes it may offer, and optionally `requireBiometric` to add a presence check +after the approve click. Nothing is unlocked and nothing is recorded, so the caller +(the proxy) keeps its own account of what it may do. It answers +`{ decision, scope, durationMs? }`, where a denial is a normal result rather than +an error. + +### First run + +The first time a gated key is created on a machine, `generate-key` shows a short +"Setting up biometrics for varlock" panel before the key exists, so the first Touch +ID prompt a user ever sees has been introduced. It is informational, appears once +ever (tracked by a marker file next to the key store), is skipped for `--no-auth` +keys and on machines that already have keys, and closes itself after 20 seconds so +an unattended run cannot hang. + +### Seeing the panel + +`scripts/demo-panel.ts` puts the real panel on screen, one state at a time, +against a scratch config home and ungated keys. No fingerprint is needed: the +keys have no gate and the prompt is forced, so approving will not scan. The point +is the layout and the copy. + +```bash +swift build --package-path swift +bun run scripts/demo-panel.ts # every state in turn +bun run scripts/demo-panel.ts --only agent # just one +``` + +The states are `single` (one key, value names behind its row), `two` (a second +key in a team vault), `agent` (the same request sent by a script run by bun, +inside something that looks like an agent session, which is what the execution +chain is there to show), and `delta` (a second key while the session already +holds one). The agent state re-runs the demo as a child process on purpose: the +chain is read off whoever connects, so a faked one would prove nothing. + +For a still picture of a state, including ones that need hardware you do not +have, `panel-preview` renders the same view tree to a PNG without asking anyone +anything. Nothing is unlocked and no key is touched: + +```bash +./swift/.build/debug/VarlockEnclave panel-preview --payload state.json --out /tmp/panel.png +``` + +The payload is an `unlock-session` payload (`keyIds`, `display`, `lockOn`) plus +four fields only the preview understands: `mode` (`embedded`, `systemDialog`, or +`none`), `strictKeyIds`, `coveredKeyIds` for the delta state, and `expandChain` +to draw the chain opened, since a picture has nobody to click its expander. + +The panel is also what `varlock load` shows against a gated key, which is the +better final check: it exercises the same path a user actually meets. + +`scripts/e2e-panel-arming.ts` covers the part a person cannot see, which is that the +presence check is actually armed. Run it first; if it fails, the panel is inert and +there is no point looking at it: + +```bash +swift build --package-path swift +bun run scripts/e2e-panel-arming.ts +``` + +It creates a real gated key, so a Touch ID prompt appears for a moment. Nobody needs +to answer it. + +The first run on a machine with no keys yet has its own sequence worth watching once: +the "Setting up biometrics" panel appears first (Continue, or it closes itself after +20 seconds), and then the unlock panel must come to the front. If the system draws +its own authentication alert over the panel that is fine, but when that alert closes +the unlock panel has to be the thing in front, with its controls live. + +What to check by hand: + +- **there is a Touch ID glyph in the panel** (ours, the system's, or the system's + drawn over ours) and never an empty square +- **the glyph breathes while the sensor is armed**, and sits still and dim + whenever it is not. A pulsing glyph is a promise that a finger will be read, so + it must never be doing that in the button-driven modes, or after a failed scan + before Try again is pressed +- **a failed scan shakes the glyph**, then leaves it still next to Try again +- **an approval turns the glyph green with a small pop** and holds a moment before + the panel closes, so the unlock reads as finished rather than as the window + vanishing +- with Reduce Motion on (System Settings, Accessibility, Display) **nothing moves**: + the same four states are carried by colour and strength alone, and armed is still + distinguishable from idle +- **scanning approves the unlock** with no second gesture, whether the prompt is + inside the panel or in a separate system alert +- the chain names the process and the terminal you are actually typing in, and + the expander opens the folded hops with their paths and signatures +- "This session" is preselected; clicking "For a set time" opens the duration + menu, and clicking that segment again reopens it. The label follows the choice + ("For 4 hours"), and `list-sessions` reports a `duration` grant with the window + that was picked +- **nothing moves when you toggle scopes**: the three segments keep the same + size whichever one is selected +- **the scan approves the selected scope**: pick "Once" first, then scan, and + `list-sessions` reports a `once` grant rather than a session one +- cancelling the scan leaves the panel up with its controls live and a "Try again" + button, and is NOT reported as a denial +- Cancel comes back as `APPROVAL_DENIED`, and nothing is listed by `list-sessions` +- approving, then asking for the same key again, shows no second panel +- asking for a second key shows the "also unlock" wording and lists only that key +- a key made with `--auth-every-time` offers `once` alone and asks again next batch + +To check the refusing path without a screen, run the daemon with +`_VARLOCK_UI_MODE=headless`. Adding `_VARLOCK_FORCE_UNLOCK_PROMPT=1` makes even an +ungated key take the approval path, which is how the end-to-end script covers +`NO_UI`. Both variables only ever make the daemon stricter. + +### Seeing the menu + +The wording and grouping are unit tested, but the menu itself needs a person and a menu +bar. Run the daemon from the same scratch setup as the panel check, unlock something from +two different terminals, and open it. + +What to check by hand: + +- the icon is a closed lock before any unlock, and an open one after, with a "2" beside it + once two terminals have unlocked +- each session's submenu names the terminal you actually unlocked from, and the key rows + read like `varlock-default: this session, 11h left` +- "Lock This Session" on one of them leaves the other listed and still able to decrypt +- "Lock All" empties the list and puts the closed lock back +- "Lock Sessions On" starts with a checkmark on Sleep, and choosing another writes + `sessions.lockOn` into `$XDG_CONFIG_HOME/varlock/config.json` without disturbing + anything else in that file. Add an `anonymousId` to it first and check it survives +- with a `config.json` that is not valid JSON, choosing a policy says so in an alert and + leaves the file alone +- the times do not tick while the menu is open, and are right again the next time it is + opened + +### Checking the single-scan unlock + +The design depends on the daemon driving the biometric itself with +`LAContext.evaluatePolicy` and then handing that authenticated context to the enclave +operation, so the custody unwrap does not raise a second sheet. That is a claim about +a given machine and OS version, so it has a probe: + +```bash +swift build --package-path swift +./swift/.build/debug/VarlockEnclave probe-session-unlock --key-id varlock-default +``` + +It needs a real Mac with enrolled biometrics and asks for exactly one scan. Nobody has +to count sheets: `LAContext.interactionNotAllowed` makes any operation that still wants +UI fail instead of showing it, so a second prompt shows up as a failed phase. + +- `control-unauthenticated` must FAIL ("User interaction required"), which is what + proves the key is presence gated and the detection works +- `handoff-unwrap-1` / `handoff-unwrap-2` must SUCCEED, proving one scan covers + several enclave operations +- `session-key-silent-unwrap` must SUCCEED with no authentication at all + +A `"verdict": "single-scan"` means the handoff holds. `"double-prompt"` means it does +not, and sessions would have to keep their key across soft locks with a daemon-enforced +re-auth instead of crypto-erasing. Verified `single-scan` on macOS 26.1 (Apple silicon). + +To create a throwaway key rather than probing a real one: + +```bash +./swift/.build/debug/VarlockEnclave generate-key --key-id varlock-probe-session +./swift/.build/debug/VarlockEnclave probe-session-unlock --key-id varlock-probe-session +./swift/.build/debug/VarlockEnclave delete-key --key-id varlock-probe-session +``` + +### Checking the embedded prompt handoff + +`probe-session-unlock` settles that question for `evaluatePolicy` and its floating +system dialog. The panel arms an `LAAuthenticationView` instead, so the scan happens +inside our own window, and the same handoff has to hold for a context authenticated +that way. It is the assumption the one-gesture design rests on: if it did not hold, +an unlock would cost a scan in the panel and then a second prompt from the enclave, +which is worse than what it replaced. So it gets its own probe: + +```bash +swift build --package-path swift +./swift/.build/debug/VarlockEnclave generate-key --key-id varlock-probe-embedded +./swift/.build/debug/VarlockEnclave probe-embedded-unlock --key-id varlock-probe-embedded +./swift/.build/debug/VarlockEnclave delete-key --key-id varlock-probe-embedded +``` + +It opens a small window with the inline prompt in it and asks for exactly one scan. +`"verdict": "embedded-single-scan"` means a context authenticated through the +embedded view still opens the custody key with no further UI, which is what the +panel depends on. `"embedded-handoff-lost"` means it does not, and the panel would +have to go back to raising the system dialog. + +Verified `embedded-handoff-ok` on macOS 26.1 (Apple silicon, Touch ID), both from +an unsigned `swift build` binary with no bundle identifier and from the signed +`.app` bundle: one scan, then two custody unwraps under `interactionNotAllowed` +with no second prompt. + +That verdict is only about the handoff. It says nothing about where the prompt was +drawn, which is a separate question the probe cannot answer; see below. + +`--verbose` streams a timestamped lifecycle log to stderr, and the same log is in +the JSON either way. `--timeout ` shortens the wait. The log is the thing +to send when the prompt does not arm: it records `canEvaluatePolicy` and the +biometry type, whether the view is bound to the same context that gets evaluated, +the view's intrinsic and actual size, whether the window was key and the app +active, the exact moment `evaluatePolicy` was invoked, and its completion verbatim. +A heartbeat every two seconds adds the view's subview and layer counts, which is +how a prompt that is waiting for a finger can be told from one that never engaged. + +The one thing a program cannot check is that no separate dialog appeared. That is +what the person running it confirms: the Touch ID prompt should be inside the probe +window, with nothing else popping up. + +### Which prompt actually appears + +`LAAuthenticationView` is documented to render the prompt inline, in the view it is +bound to, rather than raising the standard alert. On this machine it does not. What +was observed by eye on macOS 26.1, from a `swift build` binary and again in the real +`varlock load` panel, is that the bound view stays **blank** and macOS presents its +own redesigned biometric alert as a separate window, where the scan happens. + +Two earlier failures are fixed and were real, but neither was the cause of that: + +- `evaluatePolicy` was being called before `NSApplication.run()`, so the evaluation + started with no run loop pumping. Both the probe and the panel now evaluate from + inside the running loop. +- the view reports no intrinsic size on either axis (`-1`), so a stack view is free + to collapse it to nothing. Both now pin it to a real size. + +**Detecting which presentation happened is unreliable.** Checking whether the bound +view drew anything of its own is a false positive: it builds internal layers and +subviews whether or not it presents, so it reported "inline" during a run that was +watched presenting a separate alert. Scanning on-screen windows for an +authentication agent is better but not dependable: it listed nothing during one run +that visibly presented an alert, and correctly caught `coreautha` during another. +The probe logs both as raw observations. Treat a positive window sighting as real +evidence and its absence as no evidence, and remember that only a person looking at +the screen can settle it. + +Presentation also appears to be **nondeterministic**: across rounds the same build +has produced the standard alert on some runs and nothing at all on others. + +Because of that, the panel does not depend on the answer. It draws its own Touch ID +glyph, with the system's view layered on top: if the system renders inline, its +animation covers ours; if it stays blank, ours shows through. The panel reads as a +self-contained prompt in the first case and as the details card accompanying the +system alert in the second. + +`_VARLOCK_EMBEDDED_PROMPT=0` on the daemon skips the inline view entirely and goes +back to the system dialog raised by the panel's own button. No blank area, one extra +gesture, and the check is never weakened. + +#### The panel must arm from the run loop, not the main queue + +The daemon reaches the panel from a background IPC thread, so it draws the panel +inside a `DispatchQueue.main.sync` work item and then spins a nested modal loop +there. The main queue is serial: anything posted to it with +`DispatchQueue.main.async` cannot run until that enclosing item returns, which does +not happen while the panel is up. The panel kept drawing, so it looked fine, while +the block that starts the Touch ID evaluation, the 120s timeout, and the +evaluation's own completion handler all sat in the queue unreached. + +That is why a panel could appear with a fingerprint glyph, a dead sensor, and no +system prompt anywhere: nothing was ever armed, and a bound `LAAuthenticationView` +suppresses the standard alert while its evaluation is not running. The probe never +hit it, because it owns its run loop through `NSApplication.run()`. + +Everything the panel schedules now goes through `MainLoop`, which posts run-loop +blocks and timers in the common and modal-panel modes rather than main-queue items. +`scripts/e2e-panel-arming.ts` asserts the arming happens, so this cannot regress +quietly. + +#### LARight does not appear to change the answer either + +WWDC22's "Streamline local authorization flows" presents `LARight` as the API whose +system-driven UI renders inside the application window, so it was worth a spike: +`probe-laright` drives `LARight.authorize` with an `LAAuthenticationView` on screen +to watch. On macOS 26.1, unsigned dev build, one run recorded: + +``` +authorize-completed authAgentWindows=coreautha error= state=2 inlineViewDrewSomething=false +``` + +The authorization succeeded, our view drew nothing, and the system authentication +agent had a window on screen. That is the standard alert again, not an inline +prompt. + +The custody half is blocked earlier still. A persisted right is what owns a key, and +creating one fails here: + +``` +custody-save-failed error=... (OSStatus error -34018 ...) +``` + +`-34018` is `errSecMissingEntitlement`: `LARightStore` needs a keychain access group +entitlement the current build does not carry. So an `LARight`-backed custody key is +not a small change. It would need entitlement work before the interesting question +(whether `LAPrivateKey.exchangeKeys` can serve our ECIES unwrap, which the headers +say it should) can even be asked on this machine. + +Worth knowing for whoever picks this up: the pieces do line up on paper. +`LAPersistedRight.key` is a Secure Enclave `LAPrivateKey`, and it advertises +`exchangeKeys(publicKey:algorithm:parameters:)` with +`kSecKeyAlgorithmECDHKeyExchangeCofactorX963SHA256`, which is exactly the ECDH our +wrap format performs. Migration would also be cheap in this architecture: an +identity can carry more than one wrap, so a right-backed key can be added as an +extra wrap and the old one retired later, with no stored value re-encrypted. + +#### Bundle identity is not the explanation + +The obvious suspect was process identity, since a bare `swift build` binary has no +bundle identifier and some system UI behaves differently for one. It does not hold +up: + +- the probe was run from the signed `.app` bundle (`dev.varlock.enclave.dev`, + Developer ID, hardened runtime) and behaved the same as the bare binary +- every macOS distribution shape already ships that bundle. The npm helper + publishes `/VarlockEnclave.app`, the standalone CLI archives copy the same bundle + in, and the resolver looks for `VarlockEnclave.app`. So `varlock load` already + runs bundled, and the blank affordance was seen there too + +There is therefore no packaging change to chase: production is bundled today, and +being bundled does not appear to change the presentation. + +### End-to-end check (no human needed) + +`scripts/e2e-identity-session.ts` drives the whole protocol over the real socket +against a throwaway `XDG_CONFIG_HOME`. Its custody key is created with `--no-auth`, so +the unlock finds no presence requirement and never prompts. It also starts a second +daemon that cannot draw anything, and checks that every path needing approval +answers `NO_UI` there instead of proceeding. + +It covers the authorization log as well (written, growing, holding no secrets, and +denying decrypts and unlocks while it cannot be written), and restarts the daemon +mid-run to prove that no grant survives it, which is the memory-only promise the design +rests on: + +```bash +swift build --package-path swift +bun run scripts/e2e-identity-session.ts +``` + +It needs a Mac with a Secure Enclave, so it is a local check rather than a CI one. + ## Structure - `swift/` — Swift Package Manager project (`VarlockEnclave` executable) +- `swift/Sources/IdentitySessions/`: ECIES wire format, the grant table and its deadlines, the approval decision logic (what to ask, which scopes to offer, what the panel says), the menu bar's wording, the authorization log writer, and the peer posture policy, in a library target so all of it is unit tested with no window server +- `swift/Sources/SessionScoping/`: process inspection: session identity, the requester description the panel and the log use, and the code-signing facts behind the posture checks - `scripts/build-swift.ts` — Two-phase build: compile (cacheable) + bundle (mode-specific `.app` wrapping + codesign) +- `scripts/generate-ecies-fixture.ts`: regenerates the cross-implementation ECIES vector the Swift tests pin +- `scripts/e2e-identity-session.ts`: headless end-to-end run of the identity session actions - `resources/` — App icon and other bundle resources ## Building diff --git a/packages/encryption-binary-swift/scripts/demo-panel.ts b/packages/encryption-binary-swift/scripts/demo-panel.ts new file mode 100644 index 000000000..8e715a02d --- /dev/null +++ b/packages/encryption-binary-swift/scripts/demo-panel.ts @@ -0,0 +1,351 @@ +/** + * Puts the real unlock panel on screen, one state at a time, so it can be looked at. + * + * Everything is throwaway: a scratch XDG_CONFIG_HOME, keys created with + * `--no-auth`, and `_VARLOCK_FORCE_UNLOCK_PROMPT=1` so the panel is drawn even + * though an ungated key has nothing to check. That means no fingerprint is + * needed to see the panel, and approving it will not scan: the point here is the + * layout and the copy, not the enclave. + * + * The states it walks through: + * + * single one key, opening to two env files and the value cache + * two two keys, one of them tagged as a team vault + * agent the same request sent by a script running under an interpreter, + * inside something that looks like a Claude Code session, which is + * what the execution chain is there to show + * delta asking for a second key while the session already holds one + * + * Run it after building the daemon: + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/demo-panel.ts + * bun run packages/encryption-binary-swift/scripts/demo-panel.ts --only agent + * + * Each panel waits for a real answer, so approve or deny to move to the next one. + * + * `--gated` makes the scratch keys presence-gated instead, which is the only way + * to see the embedded Touch ID view: an ungated key has nothing to scan for. The + * keys still live in the scratch config home and go with it. + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const binary = path.resolve(here, '../swift/.build/debug/VarlockEnclave'); + +/** Frame one message the way the daemon's socket expects it */ +function frame(message: Record): Buffer { + const body = Buffer.from(JSON.stringify(message), 'utf-8'); + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(body.length, 0); + return Buffer.concat([prefix, body]); +} + +/** + * A one-shot client: connect, send, print the answer, exit. + * + * The demo re-runs itself in this mode to produce the agent state, because the + * chain the panel draws is read off the process that actually connected. A faked + * chain would prove nothing; a script run by bun under an agent's environment is + * the real thing. + */ +async function runAsClient(socketPath: string, requestPath: string) { + const request = JSON.parse(fs.readFileSync(requestPath, 'utf-8')); + const socket = net.createConnection(socketPath); + await new Promise((resolve) => { + socket.once('connect', resolve); + }); + socket.write(frame({ id: 'demo-agent', action: 'unlock-session', payload: request })); + + let buffer = Buffer.alloc(0); + await new Promise((resolve) => { + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const length = buffer.readUInt32LE(0); + if (buffer.length < 4 + length) return; + console.log(` answer: ${buffer.subarray(4, 4 + length).toString('utf-8').slice(0, 200)}`); + resolve(); + }); + }); + socket.destroy(); +} + +const clientSocket = process.argv[process.argv.indexOf('--client-socket') + 1]; +if (process.argv.includes('--client-socket')) { + await runAsClient(clientSocket, process.argv[process.argv.indexOf('--client-request') + 1]); + process.exit(0); +} + +if (!fs.existsSync(binary)) { + throw new Error(`daemon not built at ${binary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +const only = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : undefined; +/** Real presence-gated keys, so the panel carries a real scan. */ +const gated = process.argv.includes('--gated'); + +// -- a throwaway world: scratch config home, ungated keys, no real secrets -- + +const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-panel-demo-')); +// Bun hands a child the environment the parent STARTED with unless it is passed +// explicitly, so every spawn below gets this object by name. +const env = { ...process.env, XDG_CONFIG_HOME: configHome }; +const socketPath = path.join(configHome, 'demo.sock'); +console.log(`scratch config home: ${configHome}`); + +function runBinary(args: Array): any { + return JSON.parse(execFileSync(binary, args, { env, encoding: 'utf-8' })); +} + +// Skip the one-time "setting up biometrics" panel: it is real and wanted, but it +// is not the panel this demo is about. +fs.mkdirSync(path.join(configHome, 'varlock', 'secure-enclave'), { recursive: true, mode: 0o700 }); +fs.writeFileSync(path.join(configHome, 'varlock', 'secure-enclave', '.setup-shown'), ''); + +const { createKeyPair } = await import('../../varlock/src/lib/local-encrypt/crypto'); + +const keyFlags = gated ? [] : ['--no-auth']; +runBinary(['generate-key', '--key-id', 'varlock-default', ...keyFlags]); +runBinary(['generate-key', '--key-id', 'prod', ...keyFlags]); +if (gated) console.log('gated keys: the panel will carry a real Touch ID scan'); + +const identityKeyPair = await createKeyPair(); +const wrapFor = (keyId: string) => runBinary([ + 'encrypt', + '--key-id', + keyId, + '--data', + Buffer.from(identityKeyPair.privateKey, 'utf-8').toString('base64'), +]).ciphertext; + +fs.mkdirSync(path.join(configHome, 'varlock', 'identities'), { recursive: true, mode: 0o700 }); +fs.writeFileSync( + path.join(configHome, 'varlock', 'identities', 'default.json'), + `${JSON.stringify({ + version: 1, + id: 'default', + publicKey: identityKeyPair.publicKey, + wraps: { 'varlock-default': wrapFor('varlock-default'), prod: wrapFor('prod') }, + createdAt: new Date().toISOString(), + }, null, 2)}\n`, + { mode: 0o600 }, +); + +// -- the daemon, with the panel forced so an ungated key still draws one -- + +const daemon = spawn( + binary, + ['daemon', '--socket-path', socketPath, '--pid-path', `${socketPath}.pid`], + { + env: { + ...env, + // A gated key prompts on its own; forcing it is only for the ungated ones. + ...(gated ? {} : { _VARLOCK_FORCE_UNLOCK_PROMPT: '1' }), + // The setup step is its own question, and this demo is about the panel. + _VARLOCK_BIOMETRIC_SETUP: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, +); +daemon.stderr.on('data', (chunk) => process.stderr.write(`[daemon] ${chunk}`)); +await new Promise((resolve, reject) => { + let out = ''; + daemon.stdout.on('data', (chunk) => { + out += chunk.toString(); + if (out.includes('"ready"')) resolve(); + }); + daemon.on('exit', (code) => reject(new Error(`daemon exited early with code ${code}: ${out}`))); + setTimeout(() => reject(new Error('daemon did not become ready')), 10_000); +}); + +// Ctrl-C in the middle of a panel still has to leave the machine as it was: a +// daemon holding a scratch key and a temp directory full of key handles are not +// things to leave behind. +function cleanUp() { + daemon.kill('SIGKILL'); + fs.rmSync(configHome, { recursive: true, force: true }); +} +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + cleanUp(); + process.exit(1); + }); +} + +class Client { + private socket!: net.Socket; + private buffer = Buffer.alloc(0); + private pending = new Map void; reject: (e: Error) => void }>(); + + async connect() { + await new Promise((resolve, reject) => { + this.socket = net.createConnection(socketPath); + this.socket.once('connect', resolve); + this.socket.once('error', reject); + }); + this.socket.on('data', (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32LE(0); + if (this.buffer.length < 4 + length) break; + const message = JSON.parse(this.buffer.subarray(4, 4 + length).toString('utf-8')); + this.buffer = this.buffer.subarray(4 + length); + const waiting = this.pending.get(message.id); + if (!waiting) continue; + this.pending.delete(message.id); + if (message.error) waiting.reject(Object.assign(new Error(message.error), { code: message.errorCode })); + else waiting.resolve(message.result); + } + }); + } + + /** Args go under `payload`; the daemon refuses a message without one. */ + send(action: string, payload: Record = {}): Promise { + const id = Math.random().toString(36).slice(2); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.write(frame({ id, action, payload })); + }); + } + + close() { this.socket?.destroy(); } +} + +const projectPath = `${os.homedir()}/dev/acme-api`; + +const names = (...values: Array) => values.map((name) => ({ name })); + +/** + * The default key, holding two env files and the value cache. + * + * The cache is a line in the same list the files are in, under the key that + * encrypts it, because one grant on that key opens all three. Listing it apart + * from them would suggest the files were the whole story. + */ +const defaultKeyDisplay = { + valueCount: 24, + sources: [ + { + path: '.env', + entries: names( + 'DATABASE_URL', + 'STRIPE_TEST_KEY', + 'SENTRY_DSN', + 'REDIS_URL', + 'SMTP_PASS', + 'JWT_SECRET', + 'S3_KEY', + 'S3_SECRET', + ), + }, + { + path: '.env.local', + entries: names('OPENAI_API_KEY', 'GH_TOKEN', 'LOCAL_DB_URL', 'NGROK_TOKEN'), + }, + { + kind: 'cache', + itemCount: 12, + entries: [{ name: '1password', count: 8 }, { name: '.env.local', count: 4 }], + }, + ], +}; + +const prodKeyDisplay = { + valueCount: 3, + vaultLabel: 'acme-team vault', + vaultColor: '#b48ce8', + sources: [{ path: '.env.production', entries: names('PROD_DB_URL', 'PROD_STRIPE_KEY', 'PROD_JWT') }], +}; + +const client = new Client(); +await client.connect(); + +async function show(label: string, description: string, payload: Record) { + if (only && only !== label) return; + console.log(`\n>>> ${label}: ${description}`); + try { + const result = await client.send('unlock-session', payload); + console.log(` approved: ${JSON.stringify(result.grants?.map((g: any) => ({ keyId: g.keyId, scope: g.scope })))}`); + } catch (err: any) { + console.log(` denied or timed out: ${err.code ?? err.message}`); + } +} + +try { + await show('single', 'one key, open a row to see the value names', { + keyIds: ['varlock-default'], + scope: 'session', + display: { + projectName: 'acme-api', + projectPath, + keys: { 'varlock-default': defaultKeyDisplay }, + }, + }); + + await show('two', 'two keys, one of them in a team vault', { + keyIds: ['varlock-default', 'prod'], + scope: 'session', + lockOn: 'screenLock', + display: { + projectName: 'acme-api', + projectPath, + keys: { 'varlock-default': defaultKeyDisplay, prod: prodKeyDisplay }, + }, + }); + + if (!only || only === 'agent') { + // The chain is read off whoever connects, so this is a real script under a + // real interpreter, in an environment that looks like an agent session. + console.log('\n>>> agent: the request comes from a script run by bun, inside an agent session'); + const requestPath = path.join(configHome, 'agent-request.json'); + fs.writeFileSync(requestPath, JSON.stringify({ + keyIds: ['prod'], + scope: 'session', + display: { projectName: 'acme-api', projectPath, keys: { prod: prodKeyDisplay } }, + })); + const agent = spawn( + 'bun', + ['run', fileURLToPath(import.meta.url), '--client-socket', socketPath, '--client-request', requestPath], + { + env: { + ...env, + CLAUDECODE: '1', + CLAUDE_CODE_ENTRYPOINT: 'cli', + }, + stdio: ['ignore', 'inherit', 'inherit'], + }, + ); + await new Promise((resolve) => { + agent.once('exit', resolve); + }); + } + + await show('delta', 'a second key while the session already holds one', { + keyIds: ['varlock-default', 'prod'], + scope: 'session', + display: { + projectName: 'acme-api', + projectPath, + keys: { 'varlock-default': defaultKeyDisplay, prod: prodKeyDisplay }, + }, + }); +} finally { + client.close(); + daemon.kill('SIGTERM'); + await new Promise((resolve) => { + daemon.once('exit', resolve); + setTimeout(resolve, 3_000); + }); + // The key store is a directory under XDG_CONFIG_HOME, so removing the scratch + // home is the whole cleanup: the real keys live in the real config home and + // were never in scope here. + fs.rmSync(configHome, { recursive: true, force: true }); + console.log('\ncleaned up the scratch config home'); +} diff --git a/packages/encryption-binary-swift/scripts/e2e-identity-session.ts b/packages/encryption-binary-swift/scripts/e2e-identity-session.ts new file mode 100644 index 000000000..ed742c7d3 --- /dev/null +++ b/packages/encryption-binary-swift/scripts/e2e-identity-session.ts @@ -0,0 +1,546 @@ +/** + * End-to-end check of the daemon's identity session protocol, with no human in + * the loop. + * + * Everything runs against a throwaway `XDG_CONFIG_HOME`, and the custody key is + * created with `--no-auth`, so the daemon's unlock finds no presence requirement + * to satisfy and never prompts. That exercises the whole path (identity file, + * unlock-session, decrypt-v2, list-sessions, invalidate-session) while leaving + * the biometric handoff itself to `probe-session-unlock`, which does need a real + * finger. + * + * It also covers the authorization log (that it is written, that it grows, that + * it holds no secrets, and that a decrypt is denied when it cannot be written), + * and restarts the daemon to prove that no grant survives it. + * + * The approval panel is covered here only in its refusing form. A second daemon + * runs with `_VARLOCK_UI_MODE=headless` and `_VARLOCK_FORCE_UNLOCK_PROMPT=1`, + * which together say "a question is required and there is no screen to ask on", + * and every path that needs approval must answer NO_UI rather than proceeding. + * Both env vars can only make the daemon stricter, never more permissive. Seeing + * the panel itself needs a person; the package README says how. + * + * Needs a Mac with a Secure Enclave. Run it after building the binary: + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/e2e-identity-session.ts + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { createKeyPair, encrypt, IDENTITY_PAYLOAD_VERSION } from '../../varlock/src/lib/local-encrypt/crypto'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const binary = path.resolve(here, '../swift/.build/debug/VarlockEnclave'); + +if (!fs.existsSync(binary)) { + throw new Error(`binary not built at ${binary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +const KEY_ID = 'varlock-e2e-identity'; +const IDENTITY_ID = 'default'; + +const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-e2e-')); +const env = { ...process.env, XDG_CONFIG_HOME: configHome }; +const socketPath = path.join(configHome, 'daemon.sock'); +const auditPath = path.join(configHome, 'varlock', 'audit', 'authorizations.jsonl'); + +/** Permission bits as a three-digit octal string, e.g. "600". */ +function permissionsOf(target: string): string { + return (fs.statSync(target).mode % 0o1000).toString(8).padStart(3, '0'); +} + +/** Every authorization record written so far, oldest first. */ +function readAudit(): Array { + if (!fs.existsSync(auditPath)) return []; + return fs.readFileSync(auditPath, 'utf-8') + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +} + +let failures = 0; +function check(label: string, condition: boolean, detail?: unknown) { + if (condition) { + console.log(` ok ${label}`); + } else { + failures++; + console.log(` FAIL ${label}${detail === undefined ? '' : ` -> ${JSON.stringify(detail)}`}`); + } +} + +function runBinary(args: Array): any { + return JSON.parse(execFileSync(binary, args, { env, encoding: 'utf-8' })); +} + +// -- minimal IPC client (4-byte LE length prefix + JSON) -- + +class Client { + private socket!: net.Socket; + private buffer = Buffer.alloc(0); + private pending = new Map void; reject: (e: Error) => void }>(); + + constructor(private readonly connectTo: string = socketPath) {} + + async connect() { + await new Promise((resolve, reject) => { + this.socket = net.createConnection(this.connectTo); + this.socket.once('connect', resolve); + this.socket.once('error', reject); + }); + this.socket.on('data', (chunk) => this.onData(chunk)); + } + + private onData(chunk: Buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32LE(0); + if (this.buffer.length < 4 + length) break; + const body = this.buffer.subarray(4, 4 + length); + this.buffer = this.buffer.subarray(4 + length); + const message = JSON.parse(body.toString()); + const waiter = this.pending.get(message.id); + if (!waiter) continue; + this.pending.delete(message.id); + if (message.error) waiter.reject(Object.assign(new Error(message.error), { code: message.errorCode })); + else waiter.resolve(message.result); + } + } + + /** + * `timeoutMs` is per call. Anything that must answer without drawing UI gets a + * short one: a check that only ever passes because it timed out is a check + * that stopped testing anything. + */ + send(action: string, payload?: Record, opts: { timeoutMs?: number } = {}): Promise { + const id = Math.random().toString(36).slice(2); + const body = Buffer.from(JSON.stringify({ id, action, payload }), 'utf-8'); + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(body.length, 0); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + setTimeout(() => { + if (this.pending.delete(id)) { + reject(Object.assign(new Error(`timed out waiting for ${action}`), { code: 'E2E_TIMEOUT' })); + } + }, opts.timeoutMs ?? 30_000); + this.socket.write(Buffer.concat([prefix, body])); + }); + } + + close() { + this.socket.end(); + } +} + +/** + * Every call here names the code it expects. Accepting any failure would let a + * timeout, a crashed daemon, or a dialog nobody can dismiss count as a pass. + */ +async function expectError(label: string, fn: () => Promise, expectedCode: string) { + try { + const result = await fn(); + check(label, false, { unexpectedSuccess: result }); + } catch (err: any) { + check(label, err.code === expectedCode, { expected: expectedCode, code: err.code, message: err.message }); + } +} + +/** For the shape errors that carry a message but no stable code. */ +async function expectErrorMessage(label: string, fn: () => Promise, expectedMessage: string) { + try { + const result = await fn(); + check(label, false, { unexpectedSuccess: result }); + } catch (err: any) { + check(label, err.message === expectedMessage, { expected: expectedMessage, message: err.message }); + } +} + +// -- setup -- + +console.log(`config home: ${configHome}`); +const generated = runBinary(['generate-key', '--key-id', KEY_ID, '--no-auth']); +check('custody key created', generated.ok === true); + +// Build an identity whose private key is wrapped to the custody key, exactly as +// the TS identity layer would write it. +const identityKeyPair = await createKeyPair(); +const wrapped = runBinary(['encrypt', '--key-id', KEY_ID, '--data', Buffer.from(identityKeyPair.privateKey, 'utf-8').toString('base64')]); +check('identity key wrapped to custody key', typeof wrapped.ciphertext === 'string'); + +fs.mkdirSync(path.join(configHome, 'varlock', 'identities'), { recursive: true, mode: 0o700 }); +fs.writeFileSync( + path.join(configHome, 'varlock', 'identities', `${IDENTITY_ID}.json`), + `${JSON.stringify({ + version: 1, + id: IDENTITY_ID, + publicKey: identityKeyPair.publicKey, + wraps: { [KEY_ID]: wrapped.ciphertext }, + createdAt: new Date().toISOString(), + }, null, 2)}\n`, + { mode: 0o600 }, +); + +const SECRETS = ['sk-first-value', 'sk-second-value-🔐', ''.padEnd(2048, 'x')]; +const payloads = await Promise.all( + SECRETS.map((secret) => encrypt(identityKeyPair.publicKey, secret, { version: IDENTITY_PAYLOAD_VERSION })), +); + +// -- daemon -- + +async function startDaemon(opts: { socket: string; label: string; extraEnv?: Record }) { + const daemon = spawn( + binary, + ['daemon', '--socket-path', opts.socket, '--pid-path', `${opts.socket}.pid`], + { env: { ...env, ...opts.extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + await new Promise((resolve, reject) => { + let out = ''; + daemon.stdout.on('data', (d) => { + out += d.toString(); + if (out.includes('"ready"')) resolve(); + }); + daemon.stderr.on('data', (d) => process.stderr.write(`[${opts.label}] ${d}`)); + daemon.on('exit', (code) => reject(new Error(`${opts.label} exited early with code ${code}: ${out}`))); + setTimeout(() => reject(new Error(`${opts.label} did not become ready`)), 10_000); + }); + return daemon; +} + +function isAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Stop a daemon and make sure it is really gone. + * + * A daemon left behind is holding enclave sessions on somebody's machine, with a + * config home about to be deleted underneath it, so this escalates rather than + * hoping. It also reports the escalation as a failed check: a daemon that does + * not answer SIGTERM is a bug in the daemon, not a tidiness problem here. + */ +async function stopDaemon(child: ReturnType, label: string) { + const exited = new Promise((resolve) => { + child.once('exit', () => resolve()); + }); + child.kill('SIGTERM'); + await Promise.race([ + exited, new Promise((resolve) => { + setTimeout(resolve, 5_000); + }), + ]); + + if (!isAlive(child.pid)) return; + child.kill('SIGKILL'); + await Promise.race([ + exited, new Promise((resolve) => { + setTimeout(resolve, 2_000); + }), + ]); + check(`${label} exits on SIGTERM`, false, { pid: child.pid, note: 'needed SIGKILL' }); +} + +let daemon = await startDaemon({ socket: socketPath, label: 'daemon' }); + +let client = new Client(); +await client.connect(); + +// A second daemon, started later, that cannot draw anything. +let headlessDaemon: ReturnType | undefined; +let headlessClient: Client | undefined; + +try { + console.log('\nping'); + const ping = await client.send('ping'); + check('reports protocol version 3', ping.protocolVersion === 3, ping); + const sessionId = ping.sessionId; + check('peer has a session identity', typeof sessionId === 'string' && sessionId.length > 0, ping); + + console.log('\ndecrypt-v2 without a grant'); + await expectError('refused before unlock', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + + console.log('\nunlock-session'); + const unlocked = await client.send('unlock-session', { + keyIds: [KEY_ID], + scope: 'session', + // Optional decoration. It may reach the panel's wording and nothing else. + display: { projectName: 'e2e-project', projectPath: configHome, itemCounts: { [KEY_ID]: 3 } }, + }); + check('no prompt needed for a --no-auth key', unlocked.policy === 'no-presence-required', unlocked); + check('reports that nobody was asked', unlocked.prompted === false, unlocked); + check('one grant returned', unlocked.grants?.length === 1, unlocked); + const sessionGrant = unlocked.grants?.[0] ?? {}; + check('grant is scoped to the requested key', sessionGrant.keyId === KEY_ID, unlocked); + check('grant carries a 12h cap', sessionGrant.expiresAt - sessionGrant.grantedAt === 12 * 60 * 60 * 1000, unlocked); + + check('default lock policy is sleep', unlocked.lockOn === 'sleep', unlocked); + check('default comes from the built-in default', unlocked.lockOnSource === 'built-in-default', unlocked); + check('policy is reported per grant', unlocked.grants?.[0]?.lockOn === 'sleep', unlocked.grants?.[0]); + + console.log('\ndecrypt-v2 batch'); + const decrypted = await client.send('decrypt-v2', { keyId: KEY_ID, ciphertexts: payloads }); + check('every payload decrypted', JSON.stringify(decrypted.plaintexts) === JSON.stringify(SECRETS), decrypted.plaintexts?.length); + check('batch counts as one use', decrypted.grant?.useCount === 1, decrypted.grant); + + const second = await client.send('decrypt-v2', { keyId: KEY_ID, ciphertext: payloads[0] }); + check('single-ciphertext form works', second.plaintexts?.[0] === SECRETS[0], second); + check('session grant survives repeated use', second.grant?.useCount === 2, second.grant); + + console.log('\nauthorization log'); + check('audit file exists after the first decrypt', fs.existsSync(auditPath), auditPath); + const auditAfterDecrypts = readAudit(); + check('records the unlock and both decrypts', auditAfterDecrypts.length >= 3, auditAfterDecrypts.length); + const decryptRecords = auditAfterDecrypts.filter((r) => r.event === 'decrypt-v2'); + check('decrypt records name the key and payload count', decryptRecords.some( + (r) => r.keyIds?.[0] === KEY_ID && r.payloadCount === SECRETS.length, + ), decryptRecords); + check('records carry the session identity', decryptRecords.every((r) => r.sessionId === sessionId), decryptRecords); + check('records carry the scope used', decryptRecords.every((r) => r.scope === 'session'), decryptRecords); + check('records describe the requester', decryptRecords.every( + (r) => typeof r.requester === 'string' && r.requester.length > 0, + ), decryptRecords); + check('unlock is recorded too', auditAfterDecrypts.some((r) => r.event === 'unlock-session'), auditAfterDecrypts); + check('audit file is owner-only', permissionsOf(auditPath) === '600', permissionsOf(auditPath)); + check( + 'audit directory is owner-only', + permissionsOf(path.dirname(auditPath)) === '700', + permissionsOf(path.dirname(auditPath)), + ); + + const rawAudit = fs.readFileSync(auditPath, 'utf-8'); + check('no plaintext in the log', !SECRETS.some((secret) => secret.length > 0 && rawAudit.includes(secret))); + check('no key material in the log', !rawAudit.includes(identityKeyPair.privateKey.slice(0, 24))); + check('no ciphertext in the log', !rawAudit.includes(payloads[0].slice(0, 24))); + + const beforeGrowth = readAudit().length; + await client.send('decrypt-v2', { keyId: KEY_ID, ciphertext: payloads[0] }); + check('the log grows with each authorization', readAudit().length === beforeGrowth + 1, readAudit().length); + + console.log('\nauthorization log that cannot be written'); + // Nothing is decrypted that cannot be accounted for, so making the log + // unwritable has to deny rather than degrade to silence. + fs.chmodSync(auditPath, 0o400); + await expectError('decrypt is denied when the record cannot be written', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'AUDIT_WRITE_FAILED'); + await expectError('unlock is denied too', () => client.send('unlock-session', { + keyIds: [KEY_ID], scope: 'session', + }), 'AUDIT_WRITE_FAILED'); + check('a denied unlock leaves no grant behind', (await client.send('list-sessions')).sessions.length === 0); + + fs.chmodSync(auditPath, 0o600); + await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + const recovered = await client.send('decrypt-v2', { keyId: KEY_ID, ciphertexts: [payloads[0]] }); + check('decrypt works again once the log does', recovered.plaintexts?.[0] === SECRETS[0], recovered); + + console.log('\nwrong key id'); + await expectError('refused for a key with no grant', () => client.send('decrypt-v2', { + keyId: 'some-other-key', ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + + console.log('\nlist-sessions'); + const listed = await client.send('list-sessions'); + check('lists the live grant', listed.sessions?.length === 1, listed); + check('reports the scope label', listed.sessions?.[0]?.scope === 'session', listed.sessions?.[0]); + check('reports remaining ttl', listed.sessions?.[0]?.expiresInMs > 0, listed.sessions?.[0]); + check('reports unlock time', typeof listed.sessions?.[0]?.sessionUnlockedAt === 'number', listed.sessions?.[0]); + check('never includes key material', !JSON.stringify(listed).includes(identityKeyPair.privateKey.slice(0, 24)), 'leak'); + + console.log('\ninvalidate-session (this session only)'); + const invalidated = await client.send('invalidate-session', { sessionId }); + check('one grant dropped', invalidated.invalidated === 1, invalidated); + check('nothing left to list', (await client.send('list-sessions')).sessions.length === 0); + await expectError('decrypt refused after invalidate', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + + console.log('\nonce-scoped grant'); + await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'once' }); + const onceResult = await client.send('decrypt-v2', { keyId: KEY_ID, ciphertexts: payloads }); + check('once grant serves its batch', onceResult.plaintexts?.length === SECRETS.length); + await expectError('once grant is spent afterwards', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + + console.log('\nduration-scoped grant'); + const durational = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'duration', durationMs: 1500 }); + const durationGrant = durational.grants[0]; + check('duration honored', durationGrant.expiresAt - durationGrant.grantedAt === 1500, durationGrant); + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + await expectError('expired duration grant is refused', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'SESSION_GRANT_EXPIRED'); + + console.log('\nprompt-secret shape'); + // The recipient key is checked before any dialog is drawn, so this has to come + // back immediately. The short timeout is the assertion: if a dialog ever gets + // in front of this call again, the check fails in a second instead of quietly + // passing on the timeout half a minute later. + await expectError('rejects a malformed identity public key without prompting', () => client.send('prompt-secret', { + identityPublicKey: 'not base64 at all!!', + message: 'e2e should never see this dialog', + }, { timeoutMs: 3_000 }), 'MALFORMED_PUBLIC_KEY'); + await expectError('rejects a well-formed base64 that is not a key', () => client.send('prompt-secret', { + identityPublicKey: Buffer.from('still not a p-256 point').toString('base64'), + message: 'e2e should never see this dialog', + }, { timeoutMs: 3_000 }), 'MALFORMED_PUBLIC_KEY'); + + console.log('\nunknown identity'); + await expectError('reports a missing identity clearly', () => client.send('unlock-session', { + keyIds: [KEY_ID], identityId: 'no-such-identity', scope: 'session', + }), 'IDENTITY_NOT_FOUND'); + + console.log('\nlock policy resolution'); + const overridden = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session', lockOn: 'none' }); + check('per-session override is honored', overridden.lockOn === 'none', overridden); + check('override is named as the source', overridden.lockOnSource === 'session-override', overridden); + check('override shows up in list-sessions', (await client.send('list-sessions')).sessions[0].lockOn === 'none'); + + // The daemon reads the config file fresh at each unlock, so no restart here. + const configPath = path.join(configHome, 'varlock', 'config.json'); + fs.writeFileSync(configPath, `${JSON.stringify({ anonymousId: 'e2e', sessions: { lockOn: 'screenLock' } }, null, 2)}\n`); + const fromConfig = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + check('machine config beats the default', fromConfig.lockOn === 'screenLock', fromConfig); + check('config is named as the source', fromConfig.lockOnSource === 'machine-config', fromConfig); + + const overrideWins = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session', lockOn: 'sleep' }); + check('override still beats the machine config', overrideWins.lockOn === 'sleep', overrideWins); + + fs.writeFileSync(configPath, `${JSON.stringify({ sessions: { lockOn: 'whenever-i-feel-like-it' } }, null, 2)}\n`); + const badConfig = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + check('invalid config value falls back to the default', badConfig.lockOn === 'sleep', badConfig); + check('and does not fail the unlock', badConfig.grants?.length === 1, badConfig); + + const badOverride = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session', lockOn: 'sometimes' }); + check('invalid override falls back too', badOverride.lockOn === 'sleep', badOverride); + + fs.writeFileSync(configPath, '{ not json at all'); + const brokenConfig = await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + check('unparseable config does not break unlock', brokenConfig.lockOn === 'sleep', brokenConfig); + fs.rmSync(configPath, { force: true }); + + console.log('\ninvalidate everything'); + await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + const all = await client.send('invalidate-session'); + check('drops remaining grants', all.invalidated === 1, all); + + // A caller that names no key has asked for nothing. Unlocking some default key + // on its behalf would hand it a grant it never requested, so this is refused + // the same way decrypt-v2 refuses a malformed message. + console.log('\nmalformed unlock-session'); + await expectErrorMessage('refuses a message with no payload', () => client.send('unlock-session'), 'Missing payload'); + await expectError('refuses when no key is named', () => client.send('unlock-session', { + scope: 'session', + }), 'NO_KEYS_REQUESTED'); + await expectError('refuses an empty key list', () => client.send('unlock-session', { + keyIds: [], scope: 'session', + }), 'NO_KEYS_REQUESTED'); + await expectError('refuses blank key ids', () => client.send('unlock-session', { + keyIds: ['', ' '], scope: 'session', + }), 'NO_KEYS_REQUESTED'); + check('a malformed unlock grants nothing', (await client.send('list-sessions')).sessions.length === 0); + + // -- approval paths, on a daemon that has no screen to ask on -- + + console.log('\nheadless daemon (nothing can be approved)'); + const headlessSocket = path.join(configHome, 'headless.sock'); + headlessDaemon = await startDaemon({ + socket: headlessSocket, + label: 'headless', + extraEnv: { _VARLOCK_UI_MODE: 'headless', _VARLOCK_FORCE_UNLOCK_PROMPT: '1' }, + }); + headlessClient = new Client(headlessSocket); + await headlessClient.connect(); + + await expectError('unlock refuses when nobody can be asked', () => headlessClient!.send('unlock-session', { + keyIds: [KEY_ID], scope: 'session', + }), 'NO_UI'); + await expectError('nothing was unlocked on the way out', () => headlessClient!.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + check('no grant was recorded', (await headlessClient.send('list-sessions')).sessions.length === 0); + + console.log('\nrequest-approval'); + await expectError('refuses when nobody can be asked', () => headlessClient!.send('request-approval', { + title: 'Use the deploy token?', + descriptionLines: ['POST https://api.example.com/deploy'], + allowedScopes: ['once', 'session'], + }), 'NO_UI'); + await expectError('needs a title', () => headlessClient!.send('request-approval', { + descriptionLines: ['no title here'], + }), 'APPROVAL_MISSING_TITLE'); + await expectError('needs at least one usable scope', () => headlessClient!.send('request-approval', { + title: 'Use the deploy token?', allowedScopes: ['forever'], + }), 'APPROVAL_NO_SCOPES'); + await expectErrorMessage( + 'refuses a message with no payload', + () => headlessClient!.send('request-approval'), + 'Missing payload', + ); + check('approval never touches the grant table', (await headlessClient.send('list-sessions')).sessions.length === 0); + + // -- sessions die with the daemon -- + + console.log('\ndaemon restart'); + // The whole design rests on session material being memory-only: a + // session-wrapped blob that survived a restart, plus a session key with no + // presence requirement, would reopen silently after a reboot. So the death is + // asserted rather than assumed. + await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + check('a grant is live before the restart', (await client.send('list-sessions')).sessions.length === 1); + const auditBeforeRestart = readAudit().length; + + client.close(); + await stopDaemon(daemon, 'daemon'); + + daemon = await startDaemon({ socket: socketPath, label: 'daemon-2' }); + client = new Client(); + await client.connect(); + + const afterRestart = await client.send('ping'); + check('the same session identity comes back', afterRestart.sessionId === sessionId, afterRestart); + check('no grant survived the restart', (await client.send('list-sessions')).sessions.length === 0); + await expectError('decrypt is refused after a restart', () => client.send('decrypt-v2', { + keyId: KEY_ID, ciphertexts: [payloads[0]], + }), 'NO_SESSION_GRANT'); + + // The sessions are gone; the record of them is not. + check('the authorization log survives the restart', readAudit().length >= auditBeforeRestart, readAudit().length); + + await client.send('unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + const reopened = await client.send('decrypt-v2', { keyId: KEY_ID, ciphertexts: [payloads[0]] }); + check('unlocking again works after a restart', reopened.plaintexts?.[0] === SECRETS[0], reopened); +} finally { + client.close(); + headlessClient?.close(); + // The audit file may have been left read-only by the denial checks, and the + // scratch directory has to be removable either way. + if (fs.existsSync(auditPath)) fs.chmodSync(auditPath, 0o600); + // Waited on rather than given a fixed moment to die. A daemon this script + // leaves running is holding enclave sessions on somebody's machine, with a + // config home that is about to be deleted out from under it. + await stopDaemon(daemon, 'daemon'); + if (headlessDaemon) await stopDaemon(headlessDaemon, 'headless daemon'); + try { + runBinary(['delete-key', '--key-id', KEY_ID]); + } catch { /* best effort */ } + fs.rmSync(configHome, { recursive: true, force: true }); +} + +console.log(failures === 0 ? '\nall identity session checks passed' : `\n${failures} check(s) failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/encryption-binary-swift/scripts/e2e-panel-arming.ts b/packages/encryption-binary-swift/scripts/e2e-panel-arming.ts new file mode 100644 index 000000000..e840ee975 --- /dev/null +++ b/packages/encryption-binary-swift/scripts/e2e-panel-arming.ts @@ -0,0 +1,414 @@ +/** + * Checks that the daemon's approval panel actually ARMS its user-presence check. + * + * The panel drawing is not the thing that can silently break. What broke, and what + * nobody could see, is the evaluation behind it never starting: the panel appeared + * with its Touch ID glyph, the sensor did nothing, and no system prompt came up + * either, because the bound view suppresses the standard alert while the + * evaluation is not running. There was no scan surface anywhere, and every visible + * part looked correct. + * + * The cause was scheduling. The IPC handler is on a background queue, so the panel + * is drawn from inside a `DispatchQueue.main.sync` work item, and the arming was + * posted with `DispatchQueue.main.async`. The main queue is serial, so that block + * could not run until the enclosing item returned, which it never does while the + * modal loop is up. The probe never hit this because it owns its run loop. + * + * So this asserts the one thing that proves the wiring is live, and that a person + * cannot check by looking: `evaluatePolicy` is invoked within a couple of seconds + * of the panel opening. Completing the scan still needs a finger. Arming does not. + * + * It also asserts the ORDER, which is the second bug this feature had: macOS puts + * its own sheet up the moment a policy is evaluated, so a check armed before the + * panel was readable meant the system sheet covered a panel nobody had read, and + * one finger approved something unseen. Two rules encode the fix: a scan is only + * ever armed while our panel is on screen, and the first-use setup scan happens + * on its own, before the panel exists. + * + * Needs a Mac with a Secure Enclave, enrolled biometrics, and a desktop session: + * it creates a REAL gated key, so macOS will put a Touch ID prompt on screen for a + * moment. Nobody has to answer it; the daemon is killed as soon as the assertion + * is made. Run it after building: + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/e2e-panel-arming.ts + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { createKeyPair } from '../../varlock/src/lib/local-encrypt/crypto'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const binary = path.resolve(here, '../swift/.build/debug/VarlockEnclave'); + +if (!fs.existsSync(binary)) { + throw new Error(`binary not built at ${binary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +/** How long the panel gets to arm before we call it broken. */ +const ARMING_DEADLINE_MS = 5_000; + +const KEY_ID = 'varlock-e2e-panel-arming'; +const IDENTITY_ID = 'default'; + +/** + * Says whether a run is about first use or about the normal case. + * + * A scratch config home is a fresh machine, so without this every run here would + * be testing first use. The daemon reads the same variable a person would reach + * for if the detection ever misjudged their machine. + */ +function setupEnv(firstUse: boolean): Record { + return { _VARLOCK_BIOMETRIC_SETUP: firstUse ? '1' : '0' }; +} + +const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-arming-')); +const env = { ...process.env, XDG_CONFIG_HOME: configHome }; + +let failures = 0; +function check(label: string, condition: boolean, detail?: unknown) { + if (condition) { + console.log(` ok ${label}`); + } else { + failures++; + console.log(` FAIL ${label}${detail === undefined ? '' : ` -> ${JSON.stringify(detail)}`}`); + } +} + +function runBinary(args: Array): any { + return JSON.parse(execFileSync(binary, args, { env, encoding: 'utf-8' })); +} + +function send(socket: net.Socket, action: string, payload?: Record) { + const body = Buffer.from(JSON.stringify({ id: Math.random().toString(36).slice(2), action, payload }), 'utf-8'); + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(body.length, 0); + socket.write(Buffer.concat([prefix, body])); +} + +// -- a real gated key, which is the whole point: an ungated one never prompts -- + +console.log(`config home: ${configHome}`); + +// Skip the one-time "setting up biometrics" panel. It is real and wanted, but it +// blocks `generate-key` on a human (or its own 20s auto-dismiss), and this script +// is about what happens after. The first-run sequence has its own manual check in +// the README. +fs.mkdirSync(path.join(configHome, 'varlock', 'secure-enclave'), { recursive: true, mode: 0o700 }); +fs.writeFileSync(path.join(configHome, 'varlock', 'secure-enclave', '.setup-shown'), ''); + +const generated = runBinary(['generate-key', '--key-id', KEY_ID]); +check('gated custody key created', generated.ok === true, generated); + +const identityKeyPair = await createKeyPair(); +const wrapped = runBinary([ + 'encrypt', + '--key-id', + KEY_ID, + '--data', + Buffer.from(identityKeyPair.privateKey, 'utf-8').toString('base64'), +]); +fs.mkdirSync(path.join(configHome, 'varlock', 'identities'), { recursive: true, mode: 0o700 }); +fs.writeFileSync( + path.join(configHome, 'varlock', 'identities', `${IDENTITY_ID}.json`), + `${JSON.stringify({ + version: 1, + id: IDENTITY_ID, + publicKey: identityKeyPair.publicKey, + wraps: { [KEY_ID]: wrapped.ciphertext }, + createdAt: new Date().toISOString(), + }, null, 2)}\n`, + { mode: 0o600 }, +); + +/** + * A scan may only ever be armed while our panel is up. + * + * Walks the debug log in order and checks that every `evaluatePolicy-invoked` + * has a live panel behind it: a `panel-shown` since the last `present-returned`. + * The setup scan is deliberately not one of these; it has its own note, and its + * own assertion that it happens before any panel. + */ +function assertNoScanWithoutAPanel(stderr: string) { + const events = [...stderr.matchAll(/varlock-panel \[\d+ms\] (\S+)/g)].map((m) => m[1]); + let panelIsUp = false; + let orphanScans = 0; + for (const event of events) { + if (event === 'panel-shown') panelIsUp = true; + else if (event === 'present-returned') panelIsUp = false; + else if (event === 'evaluatePolicy-invoked' && !panelIsUp) orphanScans++; + } + check('no biometric sheet was raised without the panel on screen', orphanScans === 0, { events }); +} + +async function armingRun( + label: string, + slug: string, + extraEnv: Record, + expectArming: boolean, + opts: { skipSetupMarker?: boolean; lockFirst?: boolean; settleMs?: number } = {}, +) { + console.log(`\n${label}`); + // Short, because a unix socket path has a hard length limit and the scratch + // directory already eats most of it. + const socket = path.join(configHome, `${slug}.sock`); + let stderr = ''; + const daemon = spawn( + binary, + ['daemon', '--socket-path', socket, '--pid-path', `${socket}.pid`], + { + env: { + ...env, + _VARLOCK_PANEL_DEBUG: '1', + ...setupEnv(opts.skipSetupMarker ?? false), + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + daemon.stderr.on('data', (d) => { + stderr += d.toString(); + }); + + try { + await new Promise((resolve, reject) => { + let out = ''; + daemon.stdout.on('data', (d) => { + out += d.toString(); + if (out.includes('"ready"')) resolve(); + }); + daemon.on('exit', (code) => reject(new Error(`daemon exited early with code ${code}: ${out}`))); + setTimeout(() => reject(new Error('daemon did not become ready')), 10_000); + }); + + const client = net.createConnection(socket); + await new Promise((resolve) => { + client.once('connect', resolve); + }); + + if (opts.lockFirst) { + // Open and immediately drop a session, the way the menu bar's Lock does, + // before anything asks again. + send(client, 'invalidate-session', {}); + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + } + + // Deliberately not awaited: with a gated key this call blocks on a human, and + // the human is the part we are doing without. + send(client, 'unlock-session', { keyIds: [KEY_ID], scope: 'session' }); + + const deadline = Date.now() + ARMING_DEADLINE_MS; + while (Date.now() < deadline && !stderr.includes('evaluatePolicy-invoked')) { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + + // The watch for a system alert samples for a couple of seconds after the + // scan is armed, so a run that asserts on it has to stay for the answer. + if (opts.settleMs) { + await new Promise((resolve) => { + setTimeout(resolve, opts.settleMs); + }); + } + + assertNoScanWithoutAPanel(stderr); + if (opts.skipSetupMarker) { + // First use is about the setup scan happening alone. The panel comes after + // it, and nobody is here to complete it, so there is nothing else to say. + client.destroy(); + return stderr; + } + + check('the panel was shown', stderr.includes('panel-shown'), stderr.slice(-400)); + check( + 'the panel was drawn before any biometric sheet was asked for', + !stderr.includes('evaluatePolicy-invoked') + || stderr.indexOf('panel-shown') < stderr.indexOf('evaluatePolicy-invoked'), + stderr.slice(-600), + ); + + // The panel now reads the peer's ancestry before it draws. That inspection + // touches other processes, so it is exactly the kind of work that could + // quietly grow into a delay nobody notices until an unlock feels slow. It + // has to happen, it has to finish, and it has to be over before the panel + // appears rather than racing it. + const chainNote = stderr.match(/requester-chain .*hops=(\d+).*ms=(\d+)/) + ?? stderr.match(/requester-chain .*ms=(\d+).*hops=(\d+)/); + check('the process chain behind the caller was read', Boolean(chainNote), stderr.slice(-400)); + if (chainNote) { + const [hops, ms] = stderr.includes('hops=') && stderr.indexOf('hops=') < stderr.indexOf('ms=') + ? [Number(chainNote[1]), Number(chainNote[2])] + : [Number(chainNote[2]), Number(chainNote[1])]; + check('the chain names at least the caller', hops >= 1, { hops }); + check('reading it did not hold up the panel', ms < 1_000, { ms }); + check( + 'it was read before the panel was drawn, not while it was up', + stderr.indexOf('requester-chain') < stderr.indexOf('panel-shown'), + stderr.slice(-400), + ); + } + + // Assert on the ORDER of the flow's effects rather than on what has or has + // not happened by a deadline. Someone sitting at this machine can press the + // panel's button while the script runs, and a timing-based check would call + // that a failure; the order is the thing that actually encodes the design. + const firstEffect = stderr.match(/flow-effect .*effect=(\w+)/)?.[1]; + if (expectArming) { + check('the check is armed as the panel opens', firstEffect === 'beginScan', { firstEffect }); + check( + `evaluatePolicy was invoked within ${ARMING_DEADLINE_MS}ms of the panel opening`, + stderr.includes('evaluatePolicy-invoked'), + stderr.slice(-600), + ); + } else { + check('the panel waits rather than arming on open', firstEffect === 'showControls', { firstEffect }); + // If a button did get pressed (a human at the keyboard, or a later round), + // arming still has to have gone through the flow rather than happening on + // its own. + if (stderr.includes('evaluatePolicy-invoked')) { + check( + 'the fallback arms only after the button, and does arm then', + /effect=showControls[\s\S]*effect=beginScan[\s\S]*evaluatePolicy-invoked/.test(stderr), + stderr.slice(-600), + ); + } + } + client.destroy(); + } finally { + daemon.kill('SIGKILL'); + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + } + return stderr; +} + +try { + // First use on this machine: setting Touch ID up is its own scan, with the + // panel not yet drawn, so one finger cannot do both jobs. + const firstUse = await armingRun('first use (setup step)', 'setup', {}, false, { skipSetupMarker: true }); + check('the setup scan was raised', firstUse.includes('setup-presence-begin'), firstUse.slice(-400)); + // Asserted as an ORDER, not an absence: somebody sitting at this machine can + // answer the setup prompt while the script runs, and the panel then appears + // exactly as designed. What must never happen is the panel being up while the + // setup prompt is, or a scan being armed before setup finished. + const setupDone = firstUse.indexOf('setup-presence-completed'); + const firstPanel = firstUse.indexOf('panel-shown'); + const firstScan = firstUse.indexOf('evaluatePolicy-invoked'); + check( + 'nothing was drawn behind the setup prompt', + firstPanel === -1 || (setupDone !== -1 && setupDone < firstPanel), + { setupDone, firstPanel }, + ); + check( + 'and no approval scan was armed until setup was finished', + firstScan === -1 || (setupDone !== -1 && setupDone < firstScan), + { setupDone, firstScan }, + ); + + // The shipped default: the check is armed once the panel has been readable + // for a beat, so the scan is the approval and the approval is legible. + const armed = await armingRun('embedded prompt (default)', 'embedded', {}, true, { settleMs: 3_500 }); + check( + 'setup is not repeated once it has been recorded', + !armed.includes('setup-presence-begin'), + armed.slice(-400), + ); + check( + 'the panel was readable before the scan was armed', + /panel-readable[\s\S]*arming-after-delay[\s\S]*evaluatePolicy-invoked/.test(armed), + armed.slice(-800), + ); + + // The sensor has to be live essentially when the panel lands. The wait used to + // be most of a second, for a reason that no longer exists (arming summoned a + // system sheet that covered the panel, so the delay was the only thing keeping + // a user from approving something unread). Now the panel IS the scan surface, + // and a late fingerprint is just a fingerprint nobody can use yet. + const stampFor = (event: string) => Number(armed.match(new RegExp(`\\[(\\d+)ms\\] ${event}`))?.[1] ?? -1); + const shownAt = stampFor('panel-shown'); + const armedAt = stampFor('evaluatePolicy-invoked'); + check( + 'the scan is armed within a moment of the panel appearing', + shownAt >= 0 && armedAt > shownAt && armedAt - shownAt < 1_000, + { shownAt, armedAt, gapMs: armedAt - shownAt }, + ); + + // The scan happens INSIDE the panel now. The system drawing its own alert over + // the top is the exact failure this path exists to avoid, and whether an + // authentication-agent window shows up while we are armed is the one part of + // that a machine can check. + check( + 'the embedded scan view was attached and sized before arming', + /panel-readable .*embeddedAttached=true/.test(armed) + && /panel-readable .*embeddedFrame=\d+x\d+/.test(armed) + && !/panel-readable .*embeddedFrame=0x0/.test(armed), + armed.match(/panel-readable[^\n]*/)?.[0] ?? armed.slice(-300), + ); + const agentScans = [...armed.matchAll(/auth-agent-scan .*windows=(\S*)/g)].map((match) => match[1]); + check('the armed panel was watched for a system alert', agentScans.length > 0, { agentScans }); + check( + 'no system authentication alert appeared during the embedded scan', + agentScans.every((windows) => windows === ''), + { agentScans }, + ); + + // The one that would have caught the original bug. A bound view suppresses the + // system alert, so a view that renders nothing leaves NOTHING anywhere asking + // for a finger: a panel that looks fine and cannot be answered. Counting the + // pixels in the view's own area is the only honest check, and it is the reason + // `WindowPixels` exists. + const greys = [...armed.matchAll(/scan-pixels .*distinctGreys=(\d+)/g)].map((match) => Number(match[1])); + const permitted = /scan-pixels .*screenCapturePermitted=true/.test(armed); + check('the scan area was photographed', greys.length > 0 && permitted, { greys, permitted }); + if (permitted) { + check( + 'the inline Touch ID view actually drew something', + greys.some((count) => count >= 8), + { greys, hint: 'a blank view means nothing is listening for a finger, and no alert appears either' }, + ); + } + check( + 'the presence attempt is bound to the context that gets evaluated', + /presence-attempt .*contextInstance=(\w+)/.test(armed) + && (() => { + const bound = armed.match(/presence-attempt .*contextInstance=(\w+)/)?.[1]; + const evaluated = armed.match(/evaluatePolicy-invoked .*contextInstance=(\w+)/)?.[1]; + return Boolean(bound) && bound === evaluated; + })(), + armed.slice(-600), + ); + + // The escape hatch, which people are told to reach for when the inline prompt + // misbehaves. It must wait for the button rather than arming on open, and it + // must still arm when that button is pressed. + await armingRun('system dialog fallback', 'fallback', { _VARLOCK_EMBEDDED_PROMPT: '0' }, false); + + // Locking must not make the machine ask for a fingerprint on its own: a + // re-request from a client that is still connected goes through the panel like + // any other, and nothing raises a sheet in between. + const afterLock = await armingRun('re-request after a lock', 'relock', {}, true, { lockFirst: true }); + assertNoScanWithoutAPanel(afterLock); + check( + 'locking raised no prompt of its own', + (afterLock.match(/evaluatePolicy-invoked/g) ?? []).length + <= (afterLock.match(/panel-shown/g) ?? []).length, + afterLock.slice(-600), + ); +} finally { + try { + runBinary(['delete-key', '--key-id', KEY_ID]); + } catch { /* best effort */ } + fs.rmSync(configHome, { recursive: true, force: true }); +} + +console.log(failures === 0 ? '\npanel arming checks passed' : `\n${failures} check(s) failed`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts b/packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts new file mode 100644 index 000000000..bfe779851 --- /dev/null +++ b/packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts @@ -0,0 +1,63 @@ +/** + * Generate the cross-implementation ECIES compat vector. + * + * The Swift daemon and the TypeScript library both implement the varlock ECIES + * wire format. This writes a payload produced by the TS implementation so the + * Swift tests can prove they read the same bytes. Regenerate only when the wire + * format changes on purpose: + * + * bun run packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts + * + * A Swift-side failure against a fixture that was NOT regenerated means the two + * implementations have drifted, which is the point of checking it in. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createKeyPair, decrypt, encrypt, + DEVICE_PAYLOAD_VERSION, IDENTITY_PAYLOAD_VERSION, +} from '../../varlock/src/lib/local-encrypt/crypto'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outPath = path.resolve(here, '../swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json'); + +const IDENTITY_PLAINTEXT = 'sk-varlock-compat-vector-🔐-multibyte'; +const DEVICE_PLAINTEXT = 'device payload, same wire format, different version byte'; + +const identityKeyPair = await createKeyPair(); +const deviceKeyPair = await createKeyPair(); + +const fixture = { + note: 'Generated by packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts. Do not hand-edit.', + generatedBy: 'varlock TypeScript crypto.ts', + hkdfSalt: 'varlock-ecies-v1', + identity: { + version: IDENTITY_PAYLOAD_VERSION, + publicKey: identityKeyPair.publicKey, + privateKeyPkcs8: identityKeyPair.privateKey, + plaintext: IDENTITY_PLAINTEXT, + payload: await encrypt(identityKeyPair.publicKey, IDENTITY_PLAINTEXT, { version: IDENTITY_PAYLOAD_VERSION }), + }, + device: { + version: DEVICE_PAYLOAD_VERSION, + publicKey: deviceKeyPair.publicKey, + privateKeyPkcs8: deviceKeyPair.privateKey, + plaintext: DEVICE_PLAINTEXT, + payload: await encrypt(deviceKeyPair.publicKey, DEVICE_PLAINTEXT, { version: DEVICE_PAYLOAD_VERSION }), + }, +}; + +// Sanity check the fixture round-trips in TS before asking Swift to read it +for (const entry of [fixture.identity, fixture.device]) { + const roundTripped = await decrypt(entry.privateKeyPkcs8, entry.publicKey, entry.payload); + if (roundTripped !== entry.plaintext) { + throw new Error('fixture failed to round-trip through the TS implementation'); + } +} + +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, `${JSON.stringify(fixture, null, 2)}\n`); +console.log(`wrote ${outPath}`); diff --git a/packages/encryption-binary-swift/scripts/panel-layout-check.ts b/packages/encryption-binary-swift/scripts/panel-layout-check.ts new file mode 100644 index 000000000..d34edcb6b --- /dev/null +++ b/packages/encryption-binary-swift/scripts/panel-layout-check.ts @@ -0,0 +1,175 @@ +/** + * Holds the approval panel's one layout promise: the action row does not move. + * + * The panel's approval controls change size with the answer. `Once` grants + * narrow and draws no breadth checkbox, so the content above the buttons is a + * row shorter than it is under every other answer. The old fix was to reserve + * the row and keep every panel the same total height, which bought a stable + * button position at the price of a visible band of nothing under `Once`. + * + * Equal heights were never the point. What matters is that Deny and the scan + * control stay where they are, because the sensor is armed the whole time the + * controls are live and a finger is already on its way. So the panel absorbs the + * difference above the buttons, and this asserts the property that replaced the + * old one: the action row sits the same distance above the bottom of the panel + * in every state, and the window is re-anchored to its bottom edge when the + * controls change, so that distance is a distance on screen too. + * + * The executable target has no test target, so this measures a real render: + * `panel-preview` draws the same view tree the modal puts on screen and reports + * where the action row landed in it. + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/panel-layout-check.ts + * + * Flags: + * --keep leave the rendered PNGs behind, with their paths printed + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const binary = path.resolve(here, '../swift/.build/debug/VarlockEnclave'); + +if (!fs.existsSync(binary)) { + throw new Error(`binary not built at ${binary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +const keep = process.argv.includes('--keep'); +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-panel-layout-')); + +let failures = 0; +function check(label: string, condition: boolean, detail?: unknown) { + if (condition) { + console.log(` ok ${label}`); + } else { + failures++; + console.log(` FAIL ${label}${detail === undefined ? '' : ` -> ${JSON.stringify(detail)}`}`); + } +} + +/** + * A request with several values behind one key, which is what gives the panel a + * breadth choice to draw. Without one there is no checkbox, and the state this + * is about would not exist. + */ +const request = { + keyIds: ['varlock-default'], + itemDigestCounts: { 'varlock-default': 12 }, + display: { + projectName: 'acme-api', + projectPath: '/Users/dev/acme-api', + keys: { + 'varlock-default': { + valueCount: 24, + sources: [ + { path: '.env', entries: [{ name: 'DATABASE_URL' }, { name: 'STRIPE_TEST_KEY' }] }, + { path: '.env.local', entries: [{ name: 'OPENAI_API_KEY' }, { name: 'GH_TOKEN' }] }, + ], + }, + }, + }, +}; + +type Render = { name: string; height: number; actionRowInsetFromBottom: number; path: string }; + +function render(name: string, payload: Record): Render { + const payloadPath = path.join(outDir, `${name}.json`); + const pngPath = path.join(outDir, `${name}.png`); + fs.writeFileSync(payloadPath, JSON.stringify({ ...request, ...payload })); + const result = JSON.parse(execFileSync( + binary, + ['panel-preview', '--payload', payloadPath, '--out', pngPath], + { encoding: 'utf-8' }, + )); + if (!result.ok) throw new Error(`panel-preview failed for ${name}: ${result.error}`); + return { + name, + height: result.height, + actionRowInsetFromBottom: result.actionRowInsetFromBottom, + path: pngPath, + }; +} + +console.log('rendering every answer the ladder offers, plus both disclosures open'); +// Every scope, the custom rung (which reveals a row of its own for the number +// and the unit toggle), and the same set again with the key box and the chain +// expanded: a disclosure changes the content above the buttons too, and it must +// not move them either. +// +// `custom` is a duration naming no preset, which is exactly what a remembered +// custom answer looks like coming back: the rung opens selected, wearing its +// value, with the field under it. +const CUSTOM_MS = 45 * 60 * 1000; +const renders = [ + render('once', { scope: 'once' }), + render('duration', { scope: 'duration' }), + render('custom', { scope: 'duration', durationMs: CUSTOM_MS }), + render('session', { scope: 'session' }), + render('once-expanded', { scope: 'once', expandKeys: true, expandChain: true }), + render('duration-expanded', { scope: 'duration', expandKeys: true, expandChain: true }), + render('custom-expanded', { + scope: 'duration', + durationMs: CUSTOM_MS, + expandKeys: true, + expandChain: true, + }), + render('session-expanded', { scope: 'session', expandKeys: true, expandChain: true }), +]; +for (const one of renders) { + console.log(` ${one.name}: height ${one.height}, action row ${one.actionRowInsetFromBottom} above the bottom`); +} + +const insets = new Set(renders.map((one) => one.actionRowInsetFromBottom)); +check( + 'the action row sits the same distance above the bottom in every state', + insets.size === 1, + renders.map((one) => [one.name, one.actionRowInsetFromBottom]), +); + +// The other half of the same change: `once` really is shorter now rather than +// padded out to match. A panel that went back to equal heights would be holding +// an empty row open again. +const once = renders.find((one) => one.name === 'once')!; +const session = renders.find((one) => one.name === 'session')!; +const custom = renders.find((one) => one.name === 'custom')!; +const duration = renders.find((one) => one.name === 'duration')!; +check( + 'once draws a shorter panel rather than reserving the hidden checkbox row', + once.height < session.height, + { once: once.height, session: session.height }, +); + +// And the same rule in the other direction, for the row the custom rung +// reveals: the panel GROWS for it rather than everything else reserving space +// against the day somebody picks it. +check( + 'custom draws a taller panel rather than every other answer reserving its row', + custom.height > duration.height, + { custom: custom.height, duration: duration.height }, +); + +// The device-key panel, which has no ladder at all and is the one most easily +// forgotten when the controls above it change. Checked on its own, because its +// content is a different builder and its height has no business matching. +console.log('\nrendering the legacy device-key panel'); +const legacy = render('legacy', { legacy: true }); +console.log(` ${legacy.name}: height ${legacy.height}, action row ${legacy.actionRowInsetFromBottom} above the bottom`); +check( + 'the legacy device-key panel keeps the same action row inset', + legacy.actionRowInsetFromBottom === once.actionRowInsetFromBottom, + { legacy: legacy.actionRowInsetFromBottom, rest: once.actionRowInsetFromBottom }, +); + +if (keep) { + console.log(`\nrenders left in ${outDir}`); +} else { + fs.rmSync(outDir, { recursive: true, force: true }); +} + +console.log(failures === 0 ? '\nPASS' : `\nFAIL (${failures})`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/encryption-binary-swift/scripts/render-bisect.ts b/packages/encryption-binary-swift/scripts/render-bisect.ts new file mode 100644 index 000000000..97b47727c --- /dev/null +++ b/packages/encryption-binary-swift/scripts/render-bisect.ts @@ -0,0 +1,269 @@ +/** + * Why does the inline Touch ID view render in the probe and not in the panel? + * + * Both bind an `LAAuthenticationView` to the context they evaluate, both attach + * it to a visible key window first, and one of them draws a fingerprint while the + * other draws nothing. Rather than guessing at the difference, this walks from + * the working environment to the broken one, flipping a single axis at a time. + * + * The measurement is pixels, not opinion: `WindowPixels` photographs the region + * the view occupies and counts distinct greys. A blank region is one flat colour; + * a rendered fingerprint is dozens. That is what makes this runnable without a + * person sitting here answering "did it draw" over and over. + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/render-bisect.ts + * + * Flags: + * --only run one case by name + * --keep leave the scratch config homes behind + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import net from 'node:net'; +import path from 'node:path'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, '..'); +const binary = path.join(packageRoot, 'swift', '.build', 'debug', 'VarlockEnclave'); +const KEY_ID = 'varlock-render-bisect'; + +if (!fs.existsSync(binary)) { + throw new Error(`binary not built at ${binary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +const only = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : undefined; +const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-render-bisect-')); + +const { createKeyPair } = await import('../../varlock/src/lib/local-encrypt/crypto'); + +/** + * A scratch config home with a real presence-gated key in it, and an identity + * wrapped to that key. + * + * The identity is what makes `unlock-session` reach the panel at all: without one + * the daemon refuses before drawing anything, which looks from the outside + * exactly like a panel that failed to render. + */ +async function freshConfigHome(caseId: string): Promise { + const configHome = path.join(scratchRoot, caseId); + fs.mkdirSync(path.join(configHome, 'varlock', 'secure-enclave'), { recursive: true }); + fs.writeFileSync(path.join(configHome, 'varlock', 'secure-enclave', '.setup-shown'), ''); + const env = { ...process.env, XDG_CONFIG_HOME: configHome }; + execFileSync(binary, ['generate-key', '--key-id', KEY_ID], { env, stdio: 'ignore' }); + + const identityKeyPair = await createKeyPair(); + const wrapped = JSON.parse(execFileSync(binary, [ + 'encrypt', + '--key-id', + KEY_ID, + '--data', + Buffer.from(identityKeyPair.privateKey, 'utf-8').toString('base64'), + ], { env, encoding: 'utf-8' })); + fs.mkdirSync(path.join(configHome, 'varlock', 'identities'), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(configHome, 'varlock', 'identities', 'default.json'), + `${JSON.stringify({ + version: 1, + id: 'default', + publicKey: identityKeyPair.publicKey, + wraps: { [KEY_ID]: wrapped.ciphertext }, + createdAt: new Date().toISOString(), + }, null, 2)}\n`, + { mode: 0o600 }, + ); + return configHome; +} + +type Measurement = { + caseId: string; + what: string; + greys: number; + permitted: boolean; + rect: string; + agentWindows: string; + extra: string; +}; + +function readSample(stderr: string): { greys: number; permitted: boolean; rect: string } { + const line = [...stderr.matchAll(/scan-pixels ([^\n]*)/g)].pop()?.[1] ?? ''; + return { + greys: Number(line.match(/distinctGreys=(\d+)/)?.[1] ?? -1), + permitted: /screenCapturePermitted=true/.test(line), + rect: line.match(/rect=([^\s]+ [^\s]+)/)?.[1] ?? '-', + }; +} + +function agentWindowsFrom(stderr: string): string { + const windows = [...stderr.matchAll(/(?:authAgentWindows|windows)=(\S*)/g)] + .map((match) => match[1]) + .filter((value) => value && value !== '""'); + return windows.length ? windows.join(',') : 'none'; +} + +/** The known-good environment: the probe's own window, evaluated from its own run loop. */ +async function measureProbe(caseId: string, what: string, env: Record): Promise { + const configHome = await freshConfigHome(caseId); + const result = spawnSync( + binary, + ['probe-embedded-unlock', '--key-id', KEY_ID, '--verbose', '--timeout', '6'], + { + env: { ...process.env, ...env, XDG_CONFIG_HOME: configHome }, + encoding: 'utf-8', + }, + ); + const stderr = result.stderr ?? ''; + const sample = readSample(stderr); + return { + caseId, + what, + ...sample, + agentWindows: agentWindowsFrom(stderr), + extra: stderr.match(/activation-policy-set policy=(\w+)/)?.[1] ?? '-', + }; +} + +/** The real thing: the daemon's approval panel, driven over its socket. */ +async function measurePanel(caseId: string, what: string, env: Record): Promise { + const configHome = await freshConfigHome(caseId); + const socketPath = path.join(configHome, 'd.sock'); + let stderr = ''; + const daemon = spawn(binary, ['daemon', '--socket-path', socketPath, '--pid-path', `${socketPath}.pid`], { + env: { + ...process.env, + ...env, + XDG_CONFIG_HOME: configHome, + _VARLOCK_PANEL_DEBUG: '1', + _VARLOCK_BIOMETRIC_SETUP: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + daemon.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + try { + await new Promise((resolve, reject) => { + let out = ''; + daemon.stdout.on('data', (chunk) => { + out += chunk.toString(); + if (out.includes('"ready"')) resolve(); + }); + daemon.on('exit', (code) => reject(new Error(`daemon exited early with code ${code}`))); + setTimeout(() => reject(new Error('daemon did not become ready')), 10_000); + }); + + const socket = net.createConnection(socketPath); + await new Promise((resolve) => { + socket.once('connect', resolve); + }); + const body = Buffer.from(JSON.stringify({ + id: 'bisect', action: 'unlock-session', payload: { keyIds: [KEY_ID], scope: 'session' }, + })); + const frame = Buffer.alloc(4 + body.length); + frame.writeUInt32LE(body.length, 0); + body.copy(frame, 4); + socket.write(frame); + + // Long enough for the panel to become readable, arm, and be photographed. + await new Promise((resolve) => { + setTimeout(resolve, 8_000); + }); + socket.destroy(); + } finally { + daemon.kill('SIGKILL'); + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + } + + const sample = readSample(stderr); + return { + caseId, + what, + ...sample, + agentWindows: agentWindowsFrom(stderr), + extra: stderr.match(/panel-readable [^\n]*embeddedFrame=(\S+)/)?.[1] ?? '-', + }; +} + +/** + * The walk, from the environment that works toward the one that does not. + * + * Each case changes one thing from the case above it. Whichever line the grey + * count collapses on is the axis that decides whether the inline view draws. + */ +const cases: Array<{ id: string; what: string; run: () => Promise }> = [ + { + id: 'probe', + what: 'probe window, regular app, own run loop', + run: () => measureProbe('probe', 'probe window, regular app, own run loop', {}), + }, + { + id: 'probe-accessory', + what: 'probe window, accessory app', + run: () => measureProbe('probe-accessory', 'probe window, accessory app', { + _VARLOCK_PROBE_ACTIVATION: 'accessory', + }), + }, + { + id: 'probe-panel-window', + what: "probe, but the panel's window class and level", + run: () => measureProbe('probe-panel-window', "probe, but the panel's window class and level", { + _VARLOCK_PROBE_WINDOW: 'panel', + }), + }, + { + id: 'probe-modal', + what: 'probe, but run inside a modal session', + run: () => measureProbe('probe-modal', 'probe, but run inside a modal session', { + _VARLOCK_PROBE_MODAL: '1', + }), + }, + { + id: 'probe-panel-window-modal', + what: "probe, panel's window class AND a modal session", + run: () => measureProbe('probe-panel-window-modal', "probe, panel's window class AND a modal session", { + _VARLOCK_PROBE_WINDOW: 'panel', + _VARLOCK_PROBE_MODAL: '1', + }), + }, + { + id: 'panel', + what: 'the real approval panel in the daemon', + run: () => measurePanel('panel', 'the real approval panel in the daemon', {}), + }, +]; + +const measurements: Array = []; +for (const testCase of cases) { + if (only && only !== testCase.id) continue; + console.log(`\n── ${testCase.id}: ${testCase.what}`); + const measurement = await testCase.run(); + measurements.push(measurement); + console.log(` distinct greys ${measurement.greys} (drawn if >= 8) rect ${measurement.rect}`); +} + +console.log('\n== render bisection ======================================================='); +console.log(['case'.padEnd(26), 'greys'.padEnd(7), 'drawn'.padEnd(7), 'coreautha'.padEnd(11), 'note'].join(' ')); +for (const measurement of measurements) { + console.log([ + measurement.caseId.padEnd(26), + String(measurement.greys).padEnd(7), + (measurement.greys >= 8 ? 'yes' : 'no').padEnd(7), + measurement.agentWindows.padEnd(11), + `${measurement.what} [${measurement.extra}]`, + ].join(' ')); +} +console.log('\nThe first line where "drawn" turns to no is the axis that decides it.'); +console.log('A greys of -1 means the sample never ran; 0 with permitted=false means the'); +console.log('system would not let us photograph the window, which is not the same as blank.'); + +if (process.argv.includes('--keep')) { + console.log(`\nscratch homes kept at ${scratchRoot}`); +} else { + fs.rmSync(scratchRoot, { recursive: true, force: true }); +} diff --git a/packages/encryption-binary-swift/scripts/sign-probe.ts b/packages/encryption-binary-swift/scripts/sign-probe.ts new file mode 100644 index 000000000..990a749ff --- /dev/null +++ b/packages/encryption-binary-swift/scripts/sign-probe.ts @@ -0,0 +1,658 @@ +/** + * Does a real signing identity change what LocalAuthentication will do for us? + * + * Two behaviours on macOS 26 have been blamed on our ad-hoc signature without + * anyone testing it: + * + * - `LAAuthenticationView` renders blank, so the scan falls back to the + * system's own coreautha alert instead of drawing inside our window + * - `LARight` custody fails with `errSecMissingEntitlement` (-34018), so an + * LARight-held key cannot hold our wrap + * + * Bundle identity alone was ruled out earlier (a bundled ad-hoc build behaves the + * same). The untested variable is a Developer ID signature with the hardened + * runtime, and entitlements on top of it. This signs the same binary several + * ways and runs the machine-checkable probes against each, so the question stops + * being a hypothesis. + * + * The research checklist for "LAAuthenticationView renders blank" is asserted + * rather than assumed: the framework being linked, the view having real area and + * being in a visible key window before the evaluation, a fresh context, and the + * evaluated context being the one the view was built around. Each is reported + * per variant, so the only unexplained difference between a working and a + * non-working row is the signature. + * + * What it does NOT do is decide whether the inline view draws. There is no + * reliable signal for that (see the README), so the last step arms a real panel + * from the best variant and asks a person to look. + * + * Everything happens in a fresh temp directory and a scratch XDG_CONFIG_HOME, so + * it is re-runnable and touches nothing of the user's except the keychain access + * codesign itself needs. + * + * swift build --package-path packages/encryption-binary-swift/swift + * bun run packages/encryption-binary-swift/scripts/sign-probe.ts + * + * Flags: + * --identity "" use this signing identity instead of the discovered one + * --keep leave the temp bundles behind for inspection + * --no-visual stop after the table, skip the panel presentation + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, '..'); +const builtBinary = path.join(packageRoot, 'swift', '.build', 'debug', 'VarlockEnclave'); + +const BUNDLE_ID = 'dev.varlock.enclave'; +const PROBE_KEY_ID = 'varlock-sign-probe'; +/** Long enough for the system to draw whatever it is going to draw, short enough to sit through. */ +const PROBE_TIMEOUT_SECONDS = 6; +/** With somebody watching, long enough to look properly and answer or scan. */ +const INTERACTIVE_TIMEOUT_SECONDS = 30; + +function getArg(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +if (!fs.existsSync(builtBinary)) { + throw new Error(`binary not built at ${builtBinary}; run: swift build --package-path packages/encryption-binary-swift/swift`); +} + +/** Whether a person is here to answer the question a machine cannot. */ +const interactive = !process.argv.includes('--yes') && !process.argv.includes('--no-visual'); + +/** + * Ask the watcher something, and wait. + * + * Rendering has no machine signal, so the only way this experiment produces an + * answer at all is by asking. One variant at a time, advanced by a keypress, + * because a run that fires four prompts in a row teaches nobody which was which. + */ +async function ask(question: string): Promise { + if (!interactive) return ''; + process.stdout.write(question); + return new Promise((resolve) => { + const onData = (chunk: Buffer) => { + process.stdin.pause(); + process.stdin.off('data', onData); + resolve(chunk.toString().trim().toLowerCase()); + }; + process.stdin.resume(); + process.stdin.on('data', onData); + }); +} + +/** y / n / anything else, for a question only eyes can answer. */ +async function askRendered(label: string): Promise { + if (!interactive) return 'not-asked'; + const answer = await ask(` did the Touch ID prompt draw INSIDE the ${label} window? [y/n/?] `); + if (answer.startsWith('y')) return 'inline'; + if (answer.startsWith('n')) return 'system-alert'; + return 'unclear'; +} + +type Run = { ok: boolean; stdout: string; stderr: string; failure?: string }; + +function run(command: string, args: Array, env?: NodeJS.ProcessEnv): Run { + const result = spawnSync(command, args, { + encoding: 'utf-8', + env: env ?? process.env, + }); + const stdout = result.stdout ?? ''; + // Kept even on success: codesign reports its facts on stderr and exits 0. + const stderr = (result.stderr ?? '').trim(); + if (result.status === 0) return { ok: true, stdout, stderr }; + + // A binary the kernel refuses to launch says nothing on stderr at all: it is + // killed before it runs. That is the shape of an entitlement with no + // provisioning profile behind it, and reporting it as an empty error would + // hide the most interesting result this experiment can produce. + const failure = result.signal + ? `killed by ${result.signal} on launch, with no output: the usual cause is an entitlement ` + + 'the system will not grant without a provisioning profile' + : `exit ${result.status}${stderr ? `: ${stderr.split('\n')[0]}` : ' with no output'}`; + return { + ok: false, stdout, stderr, failure, + }; +} + +// ── the signing identity ──────────────────────────────────────── + +/** + * What this machine can sign with. + * + * Prints everything it found before choosing, because "it picked the wrong + * certificate" is otherwise an invisible way for this whole experiment to be + * wrong. + */ +function discoverIdentity(): { name: string; teamId?: string } { + const explicit = getArg('--identity'); + const listed = run('security', ['find-identity', '-v', '-p', 'codesigning']).stdout; + console.log('signing identities on this machine:'); + console.log(listed.trim().split('\n').map((line) => ` ${line.trim()}`).join('\n')); + + const identities = [...listed.matchAll(/^\s*\d+\)\s+[0-9A-F]+\s+"([^"]+)"/gm)].map((m) => m[1]); + const chosen = explicit + ?? identities.find((name) => name.startsWith('Developer ID Application')) + ?? identities[0]; + if (!chosen) { + throw new Error('no codesigning identity found; a Developer ID Application certificate is what this tests'); + } + const teamId = chosen.match(/\(([A-Z0-9]{10})\)\s*$/)?.[1]; + console.log(`\nusing identity: ${chosen}${teamId ? ` (team ${teamId})` : ' (no team id in the name)'}`); + return { name: chosen, teamId }; +} + +/** + * A Developer ID provisioning profile, if the developer has one. + * + * Restricted entitlements (`com.apple.application-identifier`, keychain access + * groups outside the team prefix) are only honoured when the bundle carries a + * profile that grants them. Without one they are signed in and ignored, which + * looks exactly like "the entitlement did not help". + */ +function findProvisioningProfile(): string | undefined { + const directories = [ + path.join(os.homedir(), 'Library', 'MobileDevice', 'Provisioning Profiles'), + path.join(os.homedir(), 'Library', 'Developer', 'Xcode', 'UserData', 'Provisioning Profiles'), + ]; + for (const directory of directories) { + if (!fs.existsSync(directory)) continue; + const profile = fs.readdirSync(directory).find((name) => name.endsWith('.provisionprofile')); + if (profile) return path.join(directory, profile); + } + return undefined; +} + +// ── the bundle ────────────────────────────────────────────────── + +const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-sign-probe-')); + +function assembleBundle(variantId: string): string { + const appDir = path.join(scratchRoot, variantId, 'VarlockEnclave.app'); + const macosDir = path.join(appDir, 'Contents', 'MacOS'); + fs.mkdirSync(macosDir, { recursive: true }); + fs.copyFileSync(builtBinary, path.join(macosDir, 'VarlockEnclave')); + fs.chmodSync(path.join(macosDir, 'VarlockEnclave'), 0o755); + fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), ` + + + + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleName + Varlock + CFBundleDisplayName + Varlock + CFBundleExecutable + VarlockEnclave + CFBundlePackageType + APPL + CFBundleVersion + 0.0.0 + CFBundleShortVersionString + 0.0.0 + LSUIElement + + + +`); + return appDir; +} + +function writeEntitlements(variantId: string, entries: Record | boolean>): string { + const body = Object.entries(entries).map(([key, value]) => { + if (typeof value === 'boolean') return ` ${key}\n <${value}/>`; + if (Array.isArray(value)) { + const items = value.map((item) => ` ${item}`).join('\n'); + return ` ${key}\n \n${items}\n `; + } + return ` ${key}\n ${value}`; + }).join('\n'); + const filePath = path.join(scratchRoot, variantId, 'entitlements.plist'); + fs.writeFileSync(filePath, ` + + + +${body} + + +`); + return filePath; +} + +// ── the probes ────────────────────────────────────────────────── + +type Checklist = { + embeddedUiLinked?: boolean; + viewReadyBeforeEvaluate?: boolean; + viewFrame?: string; + viewAlpha?: number; + viewHidden?: boolean; + windowVisibleAndKey?: boolean; + freshContext?: boolean; + sameContextAsView?: boolean; + canEvaluate?: boolean; + canEvaluateError?: string; + evaluateErrorCode?: number; + evaluateErrorDomain?: string; + signatureValid?: boolean; + hardenedRuntime?: boolean; + screenScanPermitted?: boolean; + activationPolicy?: string; +}; + +type EmbeddedRun = { + policy: 'regular' | 'accessory'; + verdict: string; + authAgentWindows: string; + inlineDrew?: boolean; + /** What the watcher saw: inline, system-alert, unclear, or not-asked. */ + rendered: string; + checklist: Checklist; +}; + +type ProbeResult = { + custodyVerdict: string; + custodyStatus: string; + embedded: Array; + notes: Array; +}; + +/** + * Run both machine-checkable probes from inside the signed bundle. + * + * A scratch config home per variant, and a real presence-gated key made by the + * signed binary itself: a key created by one code identity is not evidence about + * another. + */ +async function runProbes(appDir: string, variant: Variant): Promise { + const variantId = variant.id; + const notes: Array = []; + const executable = path.join(appDir, 'Contents', 'MacOS', 'VarlockEnclave'); + const configHome = path.join(scratchRoot, variantId, 'config'); + fs.mkdirSync(path.join(configHome, 'varlock', 'secure-enclave'), { recursive: true }); + // Skip the one-time setup panel: it is real and wanted, and it is not what + // this experiment is about. + fs.writeFileSync(path.join(configHome, 'varlock', 'secure-enclave', '.setup-shown'), ''); + const env = { ...process.env, XDG_CONFIG_HOME: configHome }; + + const generated = run(executable, ['generate-key', '--key-id', PROBE_KEY_ID], env); + if (!generated.ok) { + notes.push(`this build does not run: ${generated.failure ?? generated.stderr.slice(0, 200)}`); + return { + custodyVerdict: 'not-run', custodyStatus: '-', embedded: [], notes, + }; + } + + // Question 2: can an LARight-held key take custody, or is it still -34018? + const custody = run(executable, ['probe-laright', '--custody-only', '--timeout', '5'], env); + let custodyVerdict = 'error'; + let custodyStatus = '-'; + try { + const parsed = JSON.parse(custody.stdout); + custodyVerdict = parsed.verdict ?? 'unknown'; + const error = parsed.custody?.error as string | undefined; + custodyStatus = error?.match(/(-\d{4,6})/)?.[1] ?? (parsed.custody?.saved ? 'saved' : '-'); + if (error) notes.push(`custody: ${error.slice(0, 120)}`); + } catch { + notes.push(`custody probe did not return JSON: ${(custody.stderr || custody.stdout).slice(0, 160)}`); + } + + // Question 1, the half a machine can answer: when the evaluation fires, does + // the system draw its own coreautha alert? Nobody has to answer it; the probe + // times out on its own. + // + // Run twice, because the probe has always been a `.regular` app and the daemon + // is an `.accessory` one. If the inline view behaves differently between them + // then the activation policy is the variable, not the certificate, and this is + // the cheapest place to find that out. + const embedded: Array = []; + for (const policy of ['regular', 'accessory'] as const) { + const label = `${variantId} / ${policy}`; + if (interactive) { + console.log(`\n next: ${label} (${variant.label})`); + await ask(' press Enter to open that probe window '); + } + const result = run( + executable, + [ + 'probe-embedded-unlock', + '--key-id', + PROBE_KEY_ID, + '--timeout', + String(interactive ? INTERACTIVE_TIMEOUT_SECONDS : PROBE_TIMEOUT_SECONDS), + ], + { + ...env, + _VARLOCK_PROBE_ACTIVATION: policy, + // The window says which run it is, in its title and across its top. + _VARLOCK_PROBE_LABEL: `${label}: ${variant.label}`, + }, + ); + const rendered = await askRendered(label); + try { + const parsed = JSON.parse(result.stdout); + const seen = parsed.authAgentWindowsSeen; + embedded.push({ + policy, + verdict: parsed.verdict ?? 'unknown', + authAgentWindows: Array.isArray(seen) ? (seen.join(',') || 'none') : String(seen ?? '-'), + inlineDrew: parsed.inlineViewDrewSomething, + rendered, + checklist: (parsed.checklist ?? {}) as Checklist, + }); + if (parsed.reason) notes.push(`embedded (${policy}): ${String(parsed.reason).slice(0, 110)}`); + } catch { + embedded.push({ + policy, verdict: 'error', authAgentWindows: '-', rendered, checklist: {}, + }); + notes.push(`embedded (${policy}) returned no JSON: ${(result.failure ?? result.stderr ?? '').slice(0, 140)}`); + } + } + + run(executable, ['delete-key', '--key-id', PROBE_KEY_ID], env); + return { + custodyVerdict, custodyStatus, embedded, notes, + }; +} + +// ── the matrix ────────────────────────────────────────────────── + +type Variant = { + id: string; + label: string; + entitlements?: Record | boolean>; + needsProfile?: boolean; + /// Signed with `-`, which is what every development build has been so far. + adHoc?: boolean; +}; + +const identity = discoverIdentity(); +const teamId = identity.teamId ?? 'TEAMID'; +const profile = findProvisioningProfile(); +console.log(profile + ? `provisioning profile found: ${profile}` + : 'no provisioning profile found (the profile variant will say so rather than guessing)'); + +const variants: Array = [ + { + // The control. Without it, "the inline view drew" is a fact about the probe + // rather than a fact about the signature. + id: '0-adhoc', + label: 'ad-hoc signature, bundled the same way (the control)', + adHoc: true, + }, + { + id: 'a-hardened', + label: 'Developer ID + hardened runtime, no entitlements', + }, + { + id: 'b-entitlements', + label: 'a + keychain-access-groups and application-identifier', + entitlements: { + 'keychain-access-groups': [`${teamId}.${BUNDLE_ID}`], + 'com.apple.application-identifier': `${teamId}.${BUNDLE_ID}`, + 'com.apple.developer.team-identifier': teamId, + }, + }, + { + id: 'c-profile', + label: 'b + embedded provisioning profile', + entitlements: { + 'keychain-access-groups': [`${teamId}.${BUNDLE_ID}`], + 'com.apple.application-identifier': `${teamId}.${BUNDLE_ID}`, + 'com.apple.developer.team-identifier': teamId, + }, + needsProfile: true, + }, +]; + +type VariantOutcome = { + variant: Variant; + appDir?: string; + signed: boolean; + signError?: string; + codesignFlags?: string; + probes?: ProbeResult; + skipped?: string; +}; + +const outcomes: Array = []; + +for (const variant of variants) { + console.log(`\n── ${variant.id}: ${variant.label} ─────────────────────`); + + if (variant.needsProfile && !profile) { + console.log(' skipped: no .provisionprofile on this machine.'); + console.log(' Restricted entitlements need a Developer ID provisioning profile from the'); + console.log(' developer portal (Certificates, Identifiers & Profiles), downloaded and'); + console.log(' placed in ~/Library/MobileDevice/Provisioning Profiles/.'); + outcomes.push({ variant, signed: false, skipped: 'no provisioning profile available' }); + continue; + } + + const appDir = assembleBundle(variant.id); + if (variant.needsProfile && profile) { + fs.copyFileSync(profile, path.join(appDir, 'Contents', 'embedded.provisionprofile')); + console.log(` embedded profile: ${path.basename(profile)}`); + } + + const args = ['--force', '--timestamp=none']; + // The hardened runtime is not available to an ad-hoc signature, and asking for + // it would make the control fail to sign rather than fail to work. + if (!variant.adHoc) args.push('--options', 'runtime'); + if (variant.entitlements) { + args.push('--entitlements', writeEntitlements(variant.id, variant.entitlements)); + } + args.push('--sign', variant.adHoc ? '-' : identity.name, appDir); + + const signed = run('codesign', args); + if (!signed.ok) { + // A rejected entitlement is a result, not a crash: record exactly what + // codesign said and keep going. + console.log(` codesign FAILED: ${signed.failure ?? signed.stderr.split('\n')[0]}`); + outcomes.push({ + variant, appDir, signed: false, signError: signed.stderr.split('\n').slice(0, 3).join(' | '), + }); + continue; + } + + const details = run('codesign', ['-dv', '--verbose=4', appDir]); + const flags = details.stderr.match(/flags=([^\s]+)/)?.[1] ?? '?'; + const signedEntitlements = run('codesign', ['-d', '--entitlements', '-', '--xml', appDir]).stdout; + console.log(` signed. flags=${flags}`); + if (variant.entitlements) { + const kept = Object.keys(variant.entitlements).filter((key) => signedEntitlements.includes(key)); + console.log(` entitlements that survived signing: ${kept.length ? kept.join(', ') : 'none'}`); + } + + if (!interactive) console.log(' running probes (a Touch ID sheet may appear; nobody has to answer it)'); + const probes = await runProbes(appDir, variant); + outcomes.push({ + variant, appDir, signed: true, codesignFlags: flags, probes, + }); +} + +// ── the table ─────────────────────────────────────────────────── + +function pad(text: string, width: number): string { + return text.length >= width ? text.slice(0, width) : text + ' '.repeat(width - text.length); +} + +function yesNo(value: boolean | undefined): string { + if (value === undefined) return '-'; + return value ? 'yes' : 'no'; +} + +console.log('\n\n== variant matrix ========================================================='); +console.log([ + pad('variant', 16), + pad('app policy', 11), + pad('signed', 7), + pad('LARight custody', 17), + pad('status', 8), + pad('embedded verdict', 22), + 'coreautha windows', +].join(' ')); + +for (const outcome of outcomes) { + const runs = outcome.probes?.embedded.length + ? outcome.probes.embedded + : [ + { + policy: '-' as const, verdict: '-', authAgentWindows: '-', checklist: {}, inlineDrew: undefined, + }, + ]; + const signedLabel = outcome.signed ? 'yes' : 'no'; + const custodyLabel = outcome.probes?.custodyVerdict ?? outcome.skipped ?? 'n/a'; + for (const [index, embeddedRun] of runs.entries()) { + // Variant-wide facts on the first of its rows only, so the eye reads down + // the policy column rather than across repeated text. + const firstRow = index === 0; + console.log([ + pad(firstRow ? outcome.variant.id : '', 16), + pad(embeddedRun.policy, 11), + pad(firstRow ? signedLabel : '', 7), + pad(firstRow ? custodyLabel : '', 17), + pad(firstRow ? (outcome.probes?.custodyStatus ?? '-') : '', 8), + pad(embeddedRun.verdict, 22), + embeddedRun.authAgentWindows, + ].join(' ')); + } +} + +for (const outcome of outcomes) { + const lines = [ + outcome.signError ? `codesign: ${outcome.signError}` : undefined, + ...(outcome.probes?.notes ?? []), + ].filter(Boolean); + if (!lines.length) continue; + console.log(`\n${outcome.variant.id}:`); + for (const line of lines) console.log(` ${line}`); +} + +// The checklist: every stock explanation for a blank inline view, answered. +console.log('\n== conditions asserted per variant ========================================='); +const conditions: Array<[string, (c: Checklist) => string]> = [ + ['EmbeddedUI linked', (c) => yesNo(c.embeddedUiLinked)], + ['view ready pre-eval', (c) => yesNo(c.viewReadyBeforeEvaluate)], + ['view frame', (c) => c.viewFrame ?? '-'], + ['view alpha / hidden', (c) => `${c.viewAlpha ?? '-'} / ${yesNo(c.viewHidden)}`], + ['window visible+key', (c) => yesNo(c.windowVisibleAndKey)], + ['fresh LAContext', (c) => yesNo(c.freshContext)], + ['same ctx as view', (c) => yesNo(c.sameContextAsView)], + ['canEvaluatePolicy', (c) => yesNo(c.canEvaluate)], + ['canEvaluate error', (c) => (c.canEvaluateError ?? '-').slice(0, 22)], + ['LAError on evaluate', (c) => (c.evaluateErrorCode ? `${c.evaluateErrorCode} (${c.evaluateErrorDomain ?? ''})` : 'none')], + ['signed / hardened', (c) => `${yesNo(c.signatureValid)} / ${yesNo(c.hardenedRuntime)}`], + ['window scan allowed', (c) => yesNo(c.screenScanPermitted)], + ['activation policy', (c) => c.activationPolicy ?? '-'], +]; +const ran = outcomes.flatMap((outcome) => (outcome.probes?.embedded ?? []).map((embeddedRun) => ({ + label: `${outcome.variant.id}/${embeddedRun.policy}`, + embeddedRun, +}))); +if (ran.length) { + console.log([pad('condition', 22), ...ran.map((entry) => pad(entry.label, 22))].join(' ')); + for (const [label, read] of conditions) { + console.log([ + pad(label, 22), + ...ran.map((entry) => pad(read(entry.embeddedRun.checklist), 22)), + ].join(' ')); + } + console.log([ + pad('inline view drew', 22), + ...ran.map((entry) => pad(yesNo(entry.embeddedRun.inlineDrew), 22)), + ].join(' ')); + console.log([ + pad('coreautha seen', 22), + ...ran.map((entry) => pad(entry.embeddedRun.authAgentWindows, 22)), + ].join(' ')); +} + +console.log('\nHow to read this:'); +console.log(' LARight custody "custody-available" (rather than custody-refused / -34018) means'); +console.log(' the entitlement gate moved, and an LARight-held key could hold our wrap.'); +console.log(' "coreautha windows" naming an auth agent means the system drew its own alert while'); +console.log(' the evaluation was live, which is the fallback we are trying to get away from.'); +console.log(' Every condition above answered the same way across variants means the signature is'); +console.log(' the only thing that differs, so any change in behaviour belongs to it. A condition'); +console.log(' answered "no" anywhere is a bug in the probe setup, not evidence about signing.'); +console.log(''); +console.log('Two columns cannot be trusted on their own, which is why the eyes step exists:'); +console.log(' "inline view drew" counts subviews and layer content, which the view has whether or'); +console.log(' not it renders anything a person can see.'); +console.log(' "coreautha windows" can only ever be empty without screen-recording permission'); +console.log(' ("window scan allowed" says whether it was granted), so empty is "cannot tell".'); + +// ── the part only a person can answer ─────────────────────────── + +/// A real signature wins a tie: it is the variant worth looking at, and the +/// ad-hoc row is only here as the control. +function score(outcome: VariantOutcome): number { + return (outcome.probes?.custodyVerdict === 'custody-available' ? 4 : 0) + + (outcome.probes?.embedded.some((embeddedRun) => embeddedRun.verdict === 'embedded-handoff-ok') ? 2 : 0) + + (outcome.variant.adHoc ? 0 : 1); +} + +if (process.argv.includes('--no-visual')) { + const kept = outcomes.filter((outcome) => outcome.signed).sort((a, b) => score(b) - score(a))[0]; + console.log(`\nSkipping the eyes step. The best-signed bundle is at:\n ${kept?.appDir ?? ''}`); +} else { + // The visual check runs the PROBE window, not the shipping panel. + // + // The panel stopped embedding `LAAuthenticationView` months ago (it drew + // nothing and the system alert appeared anyway), so looking at the panel can + // only ever show the system sheet and would answer nothing. The probe window + // is the one that still binds the view, so it is the only place where "does + // the inline prompt draw when properly signed" is a question at all. + const best = outcomes.filter((outcome) => outcome.signed && outcome.probes?.embedded.length) + .sort((a, b) => score(b) - score(a))[0]; + + if (!best?.appDir) { + console.log('\nNothing signed ran, so there is nothing to look at. Fix the identity and re-run.'); + } else { + console.log(`\n\n== the eyes step (${best.variant.id}) ======================================`); + console.log('Rendering has no reliable signal (see the table caveats), so this part is eyes only.'); + console.log('A probe window is about to open with the scan armed. What to look for:'); + console.log(' - a Touch ID prompt drawn INSIDE the probe window = the inline view works here'); + console.log(' - a separate system alert on top of it = it does not, whatever the signature says'); + console.log('Scan or let it time out; either way the probe reports and exits.\n'); + + const configHome = path.join(scratchRoot, best.variant.id, 'eyes-config'); + fs.mkdirSync(path.join(configHome, 'varlock', 'secure-enclave'), { recursive: true }); + fs.writeFileSync(path.join(configHome, 'varlock', 'secure-enclave', '.setup-shown'), ''); + const env = { ...process.env, XDG_CONFIG_HOME: configHome }; + const executable = path.join(best.appDir, 'Contents', 'MacOS', 'VarlockEnclave'); + run(executable, ['generate-key', '--key-id', PROBE_KEY_ID], env); + + const watched = spawnSync( + executable, + ['probe-embedded-unlock', '--key-id', PROBE_KEY_ID, '--verbose', '--timeout', '45'], + { env, stdio: ['ignore', 'inherit', 'inherit'], encoding: 'utf-8' }, + ); + console.log(`\nprobe exited ${watched.status ?? watched.signal}`); + run(executable, ['delete-key', '--key-id', PROBE_KEY_ID], env); + + console.log('\nTo run the control (same binary, same window, ad-hoc signature):'); + const control = outcomes.find((outcome) => outcome.variant.adHoc)?.appDir; + console.log(` XDG_CONFIG_HOME=$(mktemp -d) ${control ?? ''}/Contents/MacOS/VarlockEnclave \\`); + console.log(' probe-embedded-unlock --key-id varlock-sign-probe --timeout 45'); + console.log(' (re-run this script with --keep to hold on to both bundles)'); + } +} + +if (process.argv.includes('--keep')) { + console.log(`\nbundles kept at ${scratchRoot}`); +} else { + fs.rmSync(scratchRoot, { recursive: true, force: true }); + console.log('\ncleaned up the temp bundles (pass --keep to inspect them)'); +} diff --git a/packages/encryption-binary-swift/swift/Package.swift b/packages/encryption-binary-swift/swift/Package.swift index 7e2c64ed0..cae81d292 100644 --- a/packages/encryption-binary-swift/swift/Package.swift +++ b/packages/encryption-binary-swift/swift/Package.swift @@ -27,13 +27,25 @@ let package = Package( name: "SessionScoping", path: "Sources/SessionScoping" ), + // Identity-session logic: the varlock ECIES wire format against any P-256 + // key (software or enclave), and the grant table behind unlock sessions. + // Kept out of the executable so both can be unit tested, including the + // cross-implementation compat vector generated by the TypeScript side. + .target( + name: "IdentitySessions", + dependencies: ["SessionScoping"], + path: "Sources/IdentitySessions" + ), .executableTarget( name: "VarlockEnclave", - dependencies: ["KeychainLegacy", "SessionScoping"], + dependencies: ["KeychainLegacy", "SessionScoping", "IdentitySessions"], path: "Sources/VarlockEnclave", linkerSettings: [ .linkedFramework("Security"), .linkedFramework("LocalAuthentication"), + // The inline Touch ID affordance the approval panel embeds, so the + // scan happens in our own window instead of a separate system sheet. + .linkedFramework("LocalAuthenticationEmbeddedUI"), .linkedFramework("AppKit"), ] ), @@ -42,5 +54,11 @@ let package = Package( dependencies: ["SessionScoping"], path: "Tests/SessionScopingTests" ), + .testTarget( + name: "IdentitySessionsTests", + dependencies: ["IdentitySessions"], + path: "Tests/IdentitySessionsTests", + resources: [.copy("fixtures")] + ), ] ) diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalFlow.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalFlow.swift new file mode 100644 index 000000000..1aac21786 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalFlow.swift @@ -0,0 +1,222 @@ +import Foundation + +/// How an approval reaches a yes. +/// +/// The panel and the Touch ID prompt are one window, not two. An +/// `LAAuthenticationView` is armed as the panel opens, so the scan happens inside +/// the same window that says who is asking and what they get. The scan IS the +/// approval: there is no separate confirm gesture in the common case, and no +/// system dialog appears at all. +/// +/// Because nothing is modal over the panel, the scope controls stay live the whole +/// time the prompt is armed. So the answer takes whatever is selected at the moment +/// the finger lands, not whatever was selected when the panel opened. A user who +/// picks "Once" and then scans gets Once. +/// +/// Cancel on the panel is the only refusal. A scan that fails is not one: it leaves +/// the panel exactly as it was, with a line explaining what happened and the button +/// re-enabled to arm the prompt again. Nothing re-arms on its own, so a failing +/// sensor cannot turn into a loop, and a refusal is always something the user +/// actually pressed. +/// +/// This type holds all of that with no AppKit in sight, so the transitions can be +/// tested without a window server or an enclave. + +/// What kind of user-presence check this approval carries. +public enum ApprovalPresenceMode: Equatable { + /// No check at all: an ungated custody key, or a `request-approval` that did + /// not ask for a biometric. The confirm button is the answer. + case none + /// The inline affordance, armed as the panel opens. The scan is the answer. + case embedded + /// The standard system dialog, raised by the confirm button. Used when + /// biometrics are unavailable or locked out, so the panel keeps working with + /// the device password. + case systemDialog +} + +public enum ApprovalFlowState: Equatable { + /// A presence check is in flight. For `embedded` the prompt is live in the + /// panel; for `systemDialog` the system's own window is up. + case scanning + /// Waiting on the panel's buttons. + case awaitingInput + case finished(PanelDecision) +} + +public enum ApprovalFlowEvent: Equatable { + /// The user authenticated. + case scanSucceeded + /// The check did not complete. Not a refusal. + case scanFailed + /// The panel's confirm button: the answer itself when there is no presence + /// check, and otherwise "arm the prompt again". + case confirmPressed + /// The panel's Cancel button. The only refusal the user can express. + case cancelPressed + /// The whole interaction ran out of time. + case timedOut +} + +public enum ApprovalFlowEffect: Equatable { + /// Arm the presence check (embedded prompt, or the system dialog). + case beginScan + /// Leave the panel waiting on its buttons. + case showControls + /// Close the panel with this answer. + case finish(PanelDecision) +} + +public struct ApprovalFlow { + public let presenceMode: ApprovalPresenceMode + + public private(set) var state: ApprovalFlowState + /// What an approval right now would carry. Kept current with the controls, so + /// a scan takes what is on screen when the finger lands. + public private(set) var scope: SessionGrantScope + public private(set) var durationMs: Int64? + /// The other half of what an approval carries. Tracked exactly like the + /// scope, and for the same reason: with nothing modal over the panel, the + /// breadth pill is live right up to the moment the scan lands, so the answer + /// is what is on screen then rather than what the panel opened on. + public private(set) var breadth: SessionGrantBreadth + + /// What a yes right now would actually cover. + /// + /// Under `once` the panel draws no breadth control at all and the grant is + /// narrow, whatever the hidden checkbox happens to be set to. + /// + /// Breadth is not fully moot under `once`. A single batch can still carry a + /// ciphertext the panel did not list, through the gaps in what the run can + /// declare up front: an `@cache` condition resolved before the graph could + /// describe itself, a caller that never went through the graph at all, or a + /// fallback branch nobody took on the pass that built the inventory. So + /// there is a real distinction here, and this is a decision about which half + /// of it to keep. + /// + /// It keeps the narrow half, for two reasons. "Once" already means "just + /// this, right now" to anybody reading it, and a control that let you say + /// "once, but also anything else that turns up in this batch" contradicts + /// the word it sits under. And the case where the difference bites is + /// exactly the case where a prompt is the right answer: the batch contains + /// something the panel never showed, which is precisely when the user should + /// be asked again rather than quietly served. + public var effectiveBreadth: SessionGrantBreadth { + return scope == .once ? .listedItems : breadth + } + /// How many presence checks have failed. Reported so the panel can say + /// something more useful the second time around. + public private(set) var failedScans = 0 + + public init( + defaultScope: SessionGrantScope, + presenceMode: ApprovalPresenceMode, + defaultBreadth: SessionGrantBreadth = .wholeKey + ) { + self.presenceMode = presenceMode + self.scope = defaultScope + self.breadth = defaultBreadth + self.state = .awaitingInput + } + + public init(content: PanelContent, presenceMode: ApprovalPresenceMode) { + self.init( + defaultScope: content.defaultScope, + presenceMode: presenceMode, + defaultBreadth: content.defaultBreadth + ) + } + + /// Opening the panel. Only the embedded prompt arms itself; the other two modes + /// wait for a button, which is what keeps today's behaviour for an ungated key + /// and for the no-biometrics fallback. + public mutating func start() -> ApprovalFlowEffect { + guard presenceMode == .embedded else { + state = .awaitingInput + return .showControls + } + state = .scanning + return .beginScan + } + + /// The two duration fields as the one answer they are, which is the shape + /// the panel's ladder deals in. + public var window: GrantWindow { + return GrantWindow(scope: scope, durationMs: durationMs) + } + + /// A control the user moved. + /// + /// Allowed while a scan is armed on purpose: with the prompt inside the panel + /// there is nothing modal over the controls, so the selection can legitimately + /// change right up to the moment the scan lands. Refused only once an answer + /// has been given, so a late control event cannot rewrite a decision already + /// made. + public mutating func select( + scope newScope: SessionGrantScope, + durationMs newDurationMs: Int64? = nil, + breadth newBreadth: SessionGrantBreadth? = nil + ) { + if case .finished = state { return } + scope = newScope + durationMs = newScope == .duration ? newDurationMs : nil + if let newBreadth { breadth = newBreadth } + } + + public mutating func apply(_ event: ApprovalFlowEvent) -> ApprovalFlowEffect { + if case .finished(let decision) = state { + // Terminal. A late callback must not reopen anything. + return .finish(decision) + } + + switch event { + case .cancelPressed, .timedOut: + return finish(PanelDecision.denied(defaultScope: scope, breadth: breadth)) + + case .scanSucceeded: + return finish(approval()) + + case .scanFailed: + // Not a refusal. Leave the panel as it is, with the button live so the + // user can arm it again. Deliberately does not re-arm by itself. + failedScans += 1 + state = .awaitingInput + return .showControls + + case .confirmPressed: + guard state == .awaitingInput else { return .beginScan } + switch presenceMode { + case .none: + return finish(approval()) + case .embedded, .systemDialog: + state = .scanning + return .beginScan + } + } + } + + private mutating func finish(_ decision: PanelDecision) -> ApprovalFlowEffect { + state = .finished(decision) + return .finish(decision) + } + + /// What a yes right now carries, on both axes at once. + /// + /// `chosenBreadth` is nil under `once` because the user was shown no + /// checkbox and therefore expressed no opinion about breadth. What the grant + /// gets and what gets written down are different questions here, and this is + /// the one place they are allowed to differ. + private func approval() -> PanelDecision { + return PanelDecision( + approved: true, + scope: scope, + durationMs: durationForAnswer(), + breadth: effectiveBreadth, + chosenBreadth: scope == .once ? nil : breadth + ) + } + + private func durationForAnswer() -> Int64? { + return scope == .duration ? (durationMs ?? DurationPreset.default.milliseconds) : nil + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalRequest.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalRequest.swift new file mode 100644 index 000000000..ec9fb27c5 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/ApprovalRequest.swift @@ -0,0 +1,163 @@ +import Foundation + +/// The `request-approval` op: ask the user to approve something, and report what +/// they said. +/// +/// No key operation is attached and nothing is recorded. The caller (the proxy, +/// today) keeps its own record of what it was allowed to do, keyed by whatever +/// makes sense to it. All this op does is put a question on the trusted display +/// and hand back the answer, which keeps it usable for surfaces that have nothing +/// to do with encryption keys. +public struct ApprovalRequest: Equatable { + public let title: String + public let descriptionLines: [String] + public let allowedScopes: [SessionGrantScope] + public let defaultScope: SessionGrantScope + /// Client-supplied context lines, drawn as secondary under the derived ones. + public let clientContextLines: [String] + /// Run a user-presence check after the approve click, on top of the panel. + public let requireBiometric: Bool + public let confirmButtonTitle: String + + public init( + title: String, + descriptionLines: [String], + allowedScopes: [SessionGrantScope], + defaultScope: SessionGrantScope, + clientContextLines: [String] = [], + requireBiometric: Bool = false, + confirmButtonTitle: String = "Approve" + ) { + self.title = title + self.descriptionLines = descriptionLines + self.allowedScopes = allowedScopes + self.defaultScope = defaultScope + self.clientContextLines = clientContextLines + self.requireBiometric = requireBiometric + self.confirmButtonTitle = confirmButtonTitle + } + + public enum ParseError: LocalizedError, Equatable { + case missingTitle + case noUsableScopes + + public var errorDescription: String? { + switch self { + case .missingTitle: + return "An approval request needs a title" + case .noUsableScopes: + return "An approval request needs at least one of the scopes: once, session, duration" + } + } + + public var code: String { + switch self { + case .missingTitle: return "APPROVAL_MISSING_TITLE" + case .noUsableScopes: return "APPROVAL_NO_SCOPES" + } + } + } + + /// Caps on what a caller can put on the trusted display. A request that wants + /// more than this is trimmed, not refused: the panel stays readable and the + /// derived lines stay on screen. + public static let maxTitleLength = 80 + public static let maxLineLength = 160 + public static let maxDescriptionLines = 6 + public static let maxContextLines = 4 + + /// Read a `request-approval` payload off the wire. + public static func from(payload: [String: Any]?) throws -> ApprovalRequest { + guard let payload else { throw ParseError.missingTitle } + guard let title = clean(payload["title"], limit: maxTitleLength) else { + throw ParseError.missingTitle + } + + let description = cleanList(payload["descriptionLines"], limit: maxDescriptionLines) + let context = cleanList(payload["contextLines"], limit: maxContextLines) + + var scopes: [SessionGrantScope] = [] + if let raw = payload["allowedScopes"] as? [String] { + // Keep the canonical order rather than the caller's, so the panel's + // buttons never move around between requests. + scopes = UnlockPlanner.fullScopes.filter { raw.contains($0.rawValue) } + guard !scopes.isEmpty else { throw ParseError.noUsableScopes } + } else { + scopes = [.once] + } + + let requestedDefault = SessionGrantScope(wireValue: payload["defaultScope"] as? String) + let defaultScope = requestedDefault.flatMap { scopes.contains($0) ? $0 : nil } ?? scopes[0] + + return ApprovalRequest( + title: title, + descriptionLines: description, + allowedScopes: scopes, + defaultScope: defaultScope, + clientContextLines: context, + requireBiometric: (payload["requireBiometric"] as? NSNumber)?.boolValue ?? false, + confirmButtonTitle: clean(payload["confirmLabel"], limit: 24) ?? "Approve" + ) + } + + /// Panel content for this request. The requester is worked out by the daemon, + /// which is the only side that can do it truthfully. + public func panelContent(requester: PanelRequester) -> PanelContent { + var details = requester.details + details.append(contentsOf: clientContextLines.map { .clientSupplied($0) }) + return PanelContent( + titleSegments: [.plain(title)], + subtitle: descriptionLines.isEmpty ? nil : descriptionLines.joined(separator: "\n"), + requester: PanelRequester( + summary: requester.summary, + details: details, + chain: requester.chain + ), + keyRows: [], + scopes: allowedScopes, + defaultScope: defaultScope, + confirmButtonTitle: confirmButtonTitle + ) + } + + static func clean(_ value: Any?, limit: Int) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + let flattened = text.components(separatedBy: .newlines).joined(separator: " ") + return String(flattened.prefix(limit)) + } + + static func cleanList(_ value: Any?, limit: Int) -> [String] { + guard let raw = value as? [Any] else { return [] } + return raw.compactMap { clean($0, limit: maxLineLength) }.prefix(limit).map { $0 } + } +} + +/// The answer, as it goes back over the wire. +public struct ApprovalOutcome: Equatable { + public let approved: Bool + public let scope: SessionGrantScope + public let durationMs: Int64? + + public init(approved: Bool, scope: SessionGrantScope, durationMs: Int64? = nil) { + self.approved = approved + self.scope = scope + self.durationMs = durationMs + } + + public init(decision: PanelDecision) { + self.init(approved: decision.approved, scope: decision.scope, durationMs: decision.durationMs) + } + + public func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "decision": approved ? "approved" : "denied", + "scope": scope.rawValue, + ] + if approved, let durationMs, scope == .duration { + dict["durationMs"] = durationMs + } + return dict + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/AuthorizationAudit.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/AuthorizationAudit.swift new file mode 100644 index 000000000..046d89c4d --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/AuthorizationAudit.swift @@ -0,0 +1,236 @@ +import Foundation + +/// The append-only record of what the daemon authorized. +/// +/// One line of JSON per authorization, in a file only the user can read. The +/// point is answerability: if a session key was used, there is a durable line +/// saying when, for which key, under which grant, and which process asked. A +/// decrypt whose line cannot be written is refused, so the log has no holes +/// where the interesting cases would be. +/// +/// What a record must never contain is anything worth stealing. Every field +/// here is an identifier, a count, or a description of a process; no plaintext, +/// no ciphertext, and no key material passes through this type at all. +public struct AuthorizationRecord: Equatable { + public enum Kind: String { + /// Plaintext was about to be handed back for a batch of payloads. + case decrypt = "decrypt-v2" + /// A session took (or extended) its hold on one or more keys. + case unlock = "unlock-session" + /// Someone dropped grants on purpose. + case invalidate = "invalidate-session" + } + + public let kind: Kind + /// Session identity as resolved from the peer, never as claimed by it. + public let sessionId: String + public let keyIds: [String] + public let identityId: String? + /// How many payloads this call covered. Zero for anything but a decrypt. + public let payloadCount: Int + /// The grant scope the call ran under, when there was one. + public let scope: String? + /// How much of the key the call ran under, when there was a grant. + /// + /// Recorded beside the scope because the two together are what was + /// authorized: "session" alone does not say whether a whole key was opened + /// or three named values were, and a record that cannot tell those apart + /// cannot answer the question the log exists for. + public let breadth: String? + /// How many ciphertexts an item-scoped grant covered at the time. Absent + /// for a whole-key grant, which covers a number nobody can count. + public let coveredItemCount: Int? + /// One line describing the process that asked, derived by the daemon. + public let requester: String? + + public init( + kind: Kind, + sessionId: String, + keyIds: [String], + identityId: String? = nil, + payloadCount: Int = 0, + scope: String? = nil, + breadth: String? = nil, + coveredItemCount: Int? = nil, + requester: String? = nil + ) { + self.kind = kind + self.sessionId = sessionId + self.keyIds = keyIds + self.identityId = identityId + self.payloadCount = payloadCount + self.scope = scope + self.breadth = breadth + self.coveredItemCount = coveredItemCount + self.requester = requester + } + + public func jsonObject(timestamp: String) -> [String: Any] { + var object: [String: Any] = [ + "ts": timestamp, + "event": kind.rawValue, + "sessionId": sessionId, + "keyIds": keyIds, + "payloadCount": payloadCount, + ] + if let identityId { object["identityId"] = identityId } + if let scope { object["scope"] = scope } + if let breadth { object["breadth"] = breadth } + if let coveredItemCount { object["coveredItemCount"] = coveredItemCount } + if let requester { object["requester"] = requester } + return object + } +} + +public enum AuthorizationAuditError: LocalizedError { + /// The record did not make it to disk, whatever the reason. + case notPersisted(String) + + public var errorDescription: String? { + switch self { + case .notPersisted(let reason): + return "Refusing to release secrets: the authorization could not be recorded (\(reason))" + } + } + + /// Stable code the TS client can branch on without matching message text. + public var code: String { + switch self { + case .notPersisted: return "AUDIT_WRITE_FAILED" + } + } +} + +/// Appends authorization records, synchronously, and proves each one landed. +/// +/// Deliberately small and blocking. It runs on the path that is about to hand +/// back plaintext, so it has no queue to fall behind on, no buffer to lose on a +/// crash, and no way to report success for a line that is not on disk: every +/// append is flushed with `fsync` and then read back off the file before the +/// caller is told it worked. +public final class AuthorizationAuditLog { + public static let fileName = "authorizations.jsonl" + + public let directoryPath: String + public var filePath: String { return directoryPath + "/" + Self.fileName } + + private let timestamp: () -> String + /// Serialized so a read-back can trust the offset its own write returned. + private let queue = DispatchQueue(label: "dev.varlock.audit") + + public init( + directoryPath: String, + timestamp: @escaping () -> String = { AuthorizationAuditLog.iso8601(Date()) } + ) { + self.directoryPath = directoryPath + self.timestamp = timestamp + } + + public static func iso8601(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: date) + } + + /// Write one record, or throw. There is no third outcome. + public func append(_ record: AuthorizationRecord) throws { + let line = try encode(record) + try queue.sync { + try ensureDirectory() + let fd = open(filePath, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0o600) + guard fd >= 0 else { + throw AuthorizationAuditError.notPersisted("cannot open \(filePath): \(errnoText())") + } + defer { close(fd) } + + try writeFully(fd: fd, bytes: line) + guard fsync(fd) == 0 else { + throw AuthorizationAuditError.notPersisted("fsync failed: \(errnoText())") + } + + let end = lseek(fd, 0, SEEK_CUR) + guard end >= Int64(line.count) else { + throw AuthorizationAuditError.notPersisted("could not locate the record just written") + } + try verifyReadBack(bytes: line, at: end - Int64(line.count)) + } + } + + // MARK: - Private + + private func encode(_ record: AuthorizationRecord) throws -> [UInt8] { + let object = record.jsonObject(timestamp: timestamp()) + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { + throw AuthorizationAuditError.notPersisted("record could not be serialized") + } + // One record per line is the whole format, so a record that somehow + // carried a raw newline would corrupt the next one. JSON escaping already + // rules this out; the check is here so a future field cannot break it + // quietly. + guard !data.contains(UInt8(ascii: "\n")) else { + throw AuthorizationAuditError.notPersisted("record contains a line break") + } + return [UInt8](data) + [UInt8(ascii: "\n")] + } + + private func ensureDirectory() throws { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: directoryPath, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw AuthorizationAuditError.notPersisted("\(directoryPath) is not a directory") + } + return + } + do { + try FileManager.default.createDirectory( + atPath: directoryPath, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw AuthorizationAuditError.notPersisted( + "cannot create \(directoryPath): \(error.localizedDescription)" + ) + } + } + + private func writeFully(fd: Int32, bytes: [UInt8]) throws { + var written = 0 + while written < bytes.count { + let result = bytes.withUnsafeBufferPointer { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return write(fd, base.advanced(by: written), bytes.count - written) + } + if result <= 0 { + if result < 0 && errno == EINTR { continue } + throw AuthorizationAuditError.notPersisted("short write: \(errnoText())") + } + written += result + } + } + + /// Read the bytes back off the file. A write that returned success but left + /// nothing behind (a full disk that only reports at flush time, a file + /// swapped underneath us) has to be caught here or not at all. + private func verifyReadBack(bytes: [UInt8], at offset: off_t) throws { + let fd = open(filePath, O_RDONLY | O_CLOEXEC) + guard fd >= 0 else { + throw AuthorizationAuditError.notPersisted("cannot re-open \(filePath): \(errnoText())") + } + defer { close(fd) } + + var readBack = [UInt8](repeating: 0, count: bytes.count) + let got = readBack.withUnsafeMutableBufferPointer { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return pread(fd, base, bytes.count, offset) + } + guard got == bytes.count, readBack == bytes else { + throw AuthorizationAuditError.notPersisted("the record did not read back from disk") + } + } + + private func errnoText() -> String { + return String(cString: strerror(errno)) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Base64.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Base64.swift new file mode 100644 index 000000000..899435652 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Base64.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Base64 that never routes key material through a Swift `String`. +/// +/// `Data(base64Encoded:)` takes a String, and String storage is copied and +/// reference counted with no way to scrub it. Unwrapped identity keys arrive as +/// base64 bytes, so they get decoded here instead, straight from Data to Data. +public enum RawBase64 { + private static let decodeTable: [Int8] = { + var table = [Int8](repeating: -1, count: 256) + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".utf8) + for (index, char) in alphabet.enumerated() { + table[Int(char)] = Int8(index) + } + return table + }() + + public enum Base64Error: LocalizedError { + case invalidCharacter + case invalidLength + + public var errorDescription: String? { + switch self { + case .invalidCharacter: return "Invalid base64 input" + case .invalidLength: return "Invalid base64 length" + } + } + } + + /// Decode base64 bytes. Whitespace and `=` padding are skipped. + public static func decode(_ input: Data) throws -> Data { + var out = Data() + out.reserveCapacity((input.count / 4) * 3) + + var accumulator: UInt32 = 0 + var bitsCollected = 0 + + for byte in input { + if byte == UInt8(ascii: "=") { continue } + if byte == 0x0a || byte == 0x0d || byte == 0x20 || byte == 0x09 { continue } + let decoded = decodeTable[Int(byte)] + guard decoded >= 0 else { throw Base64Error.invalidCharacter } + + accumulator = (accumulator << 6) | UInt32(UInt8(decoded)) + bitsCollected += 6 + if bitsCollected >= 8 { + bitsCollected -= 8 + out.append(UInt8((accumulator >> UInt32(bitsCollected)) & 0xff)) + } + } + + // Leftover bits must be padding zeroes, never a partial byte of data + guard bitsCollected < 6 else { throw Base64Error.invalidLength } + return out + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/BiometricSetup.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/BiometricSetup.swift new file mode 100644 index 000000000..d12557489 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/BiometricSetup.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Setting Touch ID up is not the same act as approving an unlock, and the two +/// must not be satisfied by one finger. +/// +/// The first time varlock uses the sensor on a machine (and again after the +/// enrolled fingerprints change) macOS raises its own prompt as soon as the +/// policy is evaluated, and that prompt lands on top of whatever is behind it. +/// When the approval panel was that thing behind it, one scan dismissed both: +/// the setup prompt and the approval, before anybody had read what was being +/// unlocked. An approval nobody could read is not an approval. +/// +/// So the setup check runs on its own, with the panel not yet drawn and with +/// wording that says what it is, and the approval is a second, separate scan +/// taken while the panel is on screen. First run costs two scans on purpose. +/// +/// The decision itself is here, with no LocalAuthentication in sight, so the +/// rules can be tested without a sensor. +public enum BiometricSetupPolicy { + /// What the system prompt says during setup. + /// + /// Deliberately not approval wording: nothing is being unlocked yet, and a + /// prompt that says "unlock" while nothing is being unlocked teaches people + /// to scan without reading. Password managers use the same "setting up" + /// phrasing for this moment, which is a pattern users already recognise. + public static let setupReason = "set up Touch ID approvals" + + /// Whether this unlock has to do the setup step first. + /// + /// - Parameters: + /// - recordedDomainState: the biometric enrolment we last completed setup + /// against, or nil when we have never completed one here. + /// - currentDomainState: what the system reports now, or nil when it could + /// not be read. + /// + /// Two cases need it: never having done it, and the enrolment having changed + /// since (a new finger, a reset), which is the point at which macOS asks + /// again on its own. Everything else does not, including the case where the + /// current state cannot be read: an unreadable answer is not evidence of a + /// change, and inventing a setup scan on every unlock would be worse than the + /// bug this exists to fix. + public static func needsSetup( + recordedDomainState: String?, + currentDomainState: String? + ) -> Bool { + guard let recordedDomainState, !recordedDomainState.isEmpty else { return true } + guard let currentDomainState, !currentDomainState.isEmpty else { return false } + return recordedDomainState != currentDomainState + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/CustomDuration.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/CustomDuration.swift new file mode 100644 index 000000000..003f6e729 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/CustomDuration.swift @@ -0,0 +1,147 @@ +import Foundation + +/// The duration a person types in, and the rules that keep it honest. +/// +/// The ladder's fixed rungs cover the two windows most approvals want. This is +/// the rung for the one they do not: a number and the unit it is in, bounded at +/// both ends, with no way to express something the grant table would refuse. +/// +/// Every rule here is a CLAMP, never an error. A security panel that can be put +/// into a state where nothing legal is selected is a panel that stops somebody +/// approving a read they meant to approve, and the reflex it teaches is to +/// hammer the field until the complaint goes away. So empty reads as the floor, +/// nonsense reads as the floor, and anything past the cap reads as the cap. The +/// field always holds a value the panel can act on truthfully, which is what +/// lets the sensor stay armed while somebody is typing into it. + +/// The two units a window may be named in. +/// +/// Two, not a menu of them. Minutes and hours span everything between the floor +/// and the 12h cap, and a third unit would only be a way of naming a window that +/// is not on offer. +public enum DurationUnit: String, CaseIterable, Equatable { + case minutes = "min" + case hours = "hr" + + /// How the unit is written beside a number, on the toggle and on the rung. + public var suffix: String { rawValue } + + public var milliseconds: Int64 { + switch self { + case .minutes: return 60_000 + case .hours: return 3_600_000 + } + } + + /// The cap, said in this unit: 720 minutes, or 12 hours. The same ceiling + /// either way, so switching units can never be a way around it. + public var maxAmount: Int64 { SessionGrantTable.maxGrantMs / milliseconds } + + /// The floor. Above zero, because a window of no time is not an approval, + /// it is a refusal wearing one's clothes. + public static let minAmount: Int64 = 1 +} + +/// A number and its unit, already clamped. +/// +/// There is no way to hold an out-of-range one: the initialiser clamps, so every +/// value that exists is a window the daemon would actually grant. Callers never +/// have to ask whether the thing they are holding is valid. +public struct CustomDuration: Equatable { + public let amount: Int64 + public let unit: DurationUnit + + public init(amount: Int64, unit: DurationUnit) { + self.unit = unit + self.amount = min(max(DurationUnit.minAmount, amount), unit.maxAmount) + } + + public var milliseconds: Int64 { amount * unit.milliseconds } + + /// What the field opens on before anybody has set a value. + /// + /// Shorter than the longest preset on purpose, so an untouched control never + /// asserts more than the row was already offering. Somebody who wants longer + /// is by definition about to type a number; somebody who picked this rung + /// and then walked away should not have been handed one. + /// + /// Thirty minutes rather than either preset value, so the field opens + /// genuinely between them: seeding it at `10min` or `1hr` would make an + /// untouched custom rung a duplicate of a rung to its left. + public static let unset = CustomDuration(amount: 30, unit: .minutes) + + /// Read whatever is in the field right now. + /// + /// Deliberately total. Empty, blank, `abc`, `0`, `-4`, `99999999999999999999` + /// and `12 hours` all come back as a legal window rather than as a + /// complaint: the leading digits are taken, everything else is dropped, and + /// the result is clamped. A number too large for `Int64` reads as the cap, + /// since that is the only thing somebody typing twenty digits can have + /// meant. + public static func parse(_ text: String, unit: DurationUnit) -> CustomDuration { + let digits = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix { $0.isNumber } + guard !digits.isEmpty else { return CustomDuration(amount: DurationUnit.minAmount, unit: unit) } + guard let amount = Int64(digits) else { return CustomDuration(amount: unit.maxAmount, unit: unit) } + return CustomDuration(amount: amount, unit: unit) + } + + /// The same window said in the other unit. + /// + /// Converts the VALUE, never reinterprets the number: 90 minutes switched to + /// hours is one hour, not ninety of them. Where the window does not divide + /// evenly the shorter neighbour wins, so the rounding a unit switch does can + /// only ever narrow a grant. Below one whole unit there is no shorter answer + /// left and the floor applies, which is the one case where switching units + /// lengthens a window; it is visible in the field the moment it happens. + public func converted(to newUnit: DurationUnit) -> CustomDuration { + guard newUnit != unit else { return self } + return CustomDuration(amount: milliseconds / newUnit.milliseconds, unit: newUnit) + } + + /// A duration in milliseconds, in the unit that says it most plainly. + /// + /// Whole hours come back as hours and everything else as minutes, so a + /// remembered `2h` comes back reading `2hr` and a remembered `45min` comes + /// back reading `45min` rather than as a fraction of something. + public static func forMilliseconds(_ milliseconds: Int64) -> CustomDuration { + let parts = DurationText.parts(milliseconds) + return CustomDuration(amount: parts.amount, unit: parts.unit) + } +} + +/// A window, written down. Two registers, one rule for both. +public enum DurationText { + /// The compact form a rung carries: `10min`, `1hr`, `45min`. + public static func short(_ milliseconds: Int64) -> String { + let parts = parts(milliseconds) + return "\(parts.amount)\(parts.unit.suffix)" + } + + /// The prose form the summary sentence uses: "10 minutes", "1 hour". + /// + /// Spelled out because that line is a sentence somebody reads, and `for 1hr` + /// reads as a setting rather than as an answer to how long this lasts. + public static func prose(_ milliseconds: Int64) -> String { + let parts = parts(milliseconds) + let noun: String + switch parts.unit { + case .minutes: noun = parts.amount == 1 ? "minute" : "minutes" + case .hours: noun = parts.amount == 1 ? "hour" : "hours" + } + return "\(parts.amount) \(noun)" + } + + /// Whole hours are said in hours; everything else is said in minutes, + /// rounded to the nearest one and never down to nothing. + static func parts(_ milliseconds: Int64) -> (amount: Int64, unit: DurationUnit) { + let clamped = min(max(1, milliseconds), SessionGrantTable.maxGrantMs) + let hour = DurationUnit.hours.milliseconds + if clamped >= hour, clamped % hour == 0 { + return (clamped / hour, .hours) + } + let minute = DurationUnit.minutes.milliseconds + return (max(1, (clamped + minute / 2) / minute), .minutes) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Ecies.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Ecies.swift new file mode 100644 index 000000000..96d911efc --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/Ecies.swift @@ -0,0 +1,353 @@ +import Foundation +import CryptoKit + +/// The varlock ECIES wire format, shared by every backend. +/// +/// version(1) | ephemeralPub(65) | nonce(12) | ciphertext(N) | tag(16) +/// +/// P-256 ECDH, HKDF-SHA256 (salt "varlock-ecies-v1", info = ephemeralPub || recipientPub), +/// AES-256-GCM. This is the same scheme `SecureEnclaveManager` implements against a +/// Secure Enclave key; here it is expressed once, against any key that can perform +/// the key agreement, so software identity keys and enclave keys share one code path. +/// +/// The TypeScript side (`packages/varlock/src/lib/local-encrypt/crypto.ts`) implements +/// the identical format, and `EciesCompatTests` pins that with a fixture generated by it. +public enum Ecies { + /// Payload encrypted directly to a device key (Secure Enclave / TPM / file) + public static let devicePayloadVersion: UInt8 = 0x01 + /// Payload encrypted to an identity public key, which is itself wrapped to a device key + public static let identityPayloadVersion: UInt8 = 0x02 + + public static let hkdfSalt = Data("varlock-ecies-v1".utf8) + + static let publicKeyLength = 65 + static let nonceLength = 12 + static let tagLength = 16 + static let headerLength = 1 + publicKeyLength + nonceLength + + public enum EciesError: LocalizedError { + case payloadTooShort + case unsupportedVersion(UInt8) + case malformedPrivateKey(String) + case malformedPublicKey(String) + case decryptionFailed(String) + + public var errorDescription: String? { + switch self { + case .payloadTooShort: return "Payload too short" + case .unsupportedVersion(let v): return "Unsupported encrypted payload version \(v); upgrade varlock" + case .malformedPrivateKey(let msg): return "Malformed private key: \(msg)" + case .malformedPublicKey(let msg): return "Malformed recipient public key: \(msg)" + case .decryptionFailed(let msg): return "Unable to decrypt value: \(msg)" + } + } + + /// Stable code the TS client can branch on without matching message text. + public var code: String { + switch self { + case .payloadTooShort: return "PAYLOAD_TOO_SHORT" + case .unsupportedVersion: return "PAYLOAD_VERSION_UNSUPPORTED" + case .malformedPrivateKey: return "MALFORMED_PRIVATE_KEY" + case .malformedPublicKey: return "MALFORMED_PUBLIC_KEY" + case .decryptionFailed: return "DECRYPTION_FAILED" + } + } + } + + /// Validate a recipient public key up front. + /// + /// Callers that are about to do something slow or interactive use this so a + /// bad key is an immediate error rather than something found out after a + /// dialog has been put in front of somebody. + public static func recipientPublicKeyData(base64: String) throws -> Data { + guard let data = Data(base64Encoded: base64) else { + throw EciesError.malformedPublicKey("not valid base64") + } + guard data.count == publicKeyLength else { + throw EciesError.malformedPublicKey("expected \(publicKeyLength) bytes, got \(data.count)") + } + do { + _ = try P256.KeyAgreement.PublicKey(x963Representation: data) + } catch { + throw EciesError.malformedPublicKey("not a P-256 public key") + } + return data + } + + // MARK: - HKDF + + /// HKDF-SHA256 (RFC 5869), matching `SecureEnclaveManager.deriveKey` and the TS port. + public static func deriveKey( + sharedSecret: Data, + salt: Data, + info: Data, + outputByteCount: Int + ) -> SymmetricKey { + let prk = HMAC.authenticationCode(for: sharedSecret, using: SymmetricKey(data: salt)) + let prkData = Data(prk) + + var okm = Data() + var t = Data() + var counter: UInt8 = 1 + while okm.count < outputByteCount { + var input = t + input.append(info) + input.append(counter) + t = Data(HMAC.authenticationCode(for: input, using: SymmetricKey(data: prkData))) + okm.append(t) + counter += 1 + } + return SymmetricKey(data: okm.prefix(outputByteCount)) + } + + // MARK: - Encrypt + + /// Encrypt to a recipient public key. Needs no private key and no auth gate. + public static func encrypt( + plaintext: Data, + to recipientPublicKey: P256.KeyAgreement.PublicKey, + version: UInt8 + ) throws -> Data { + let recipientPubData = Data(recipientPublicKey.x963Representation) + + let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() + let ephemeralPubData = Data(ephemeralPrivateKey.publicKey.x963Representation) + + let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey) + var sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + defer { scrub(&sharedSecretData) } + + let symmetricKey = deriveKey( + sharedSecret: sharedSecretData, + salt: hkdfSalt, + info: ephemeralPubData + recipientPubData, + outputByteCount: 32 + ) + + let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey) + + var payload = Data() + payload.append(version) + payload.append(ephemeralPubData) + payload.append(contentsOf: sealedBox.nonce) + payload.append(sealedBox.ciphertext) + payload.append(sealedBox.tag) + return payload + } + + /// Encrypt to a recipient public key given in its raw x9.63 (65 byte) form. + public static func encrypt(plaintext: Data, toPublicKeyData data: Data, version: UInt8) throws -> Data { + let publicKey = try P256.KeyAgreement.PublicKey(x963Representation: data) + return try encrypt(plaintext: plaintext, to: publicKey, version: version) + } + + // MARK: - Decrypt + + /// Split a payload into its parts, validating the framing but not the key. + static func parse(payload: Data) throws -> (version: UInt8, ephemeralPub: Data, nonce: Data, ciphertext: Data, tag: Data) { + guard payload.count > headerLength + tagLength else { + throw EciesError.payloadTooShort + } + // Data slices keep the parent's indices, so rebase before slicing. + let bytes = Data(payload) + let version = bytes[0] + let ephemeralPub = bytes[1..<(1 + publicKeyLength)] + let nonce = bytes[(1 + publicKeyLength).. + ) throws -> Data { + let parts = try parse(payload: payload) + guard acceptedVersions.contains(parts.version) else { + throw EciesError.unsupportedVersion(parts.version) + } + + let ephemeralPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: parts.ephemeralPub) + var sharedSecretData = try privateKey.sharedSecretBytes(with: ephemeralPublicKey) + defer { scrub(&sharedSecretData) } + + let symmetricKey = deriveKey( + sharedSecret: sharedSecretData, + salt: hkdfSalt, + info: parts.ephemeralPub + privateKey.publicKeyX963, + outputByteCount: 32 + ) + + do { + let sealedBox = try AES.GCM.SealedBox( + nonce: try AES.GCM.Nonce(data: parts.nonce), + ciphertext: parts.ciphertext, + tag: parts.tag + ) + return try AES.GCM.open(sealedBox, using: symmetricKey) + } catch { + throw EciesError.decryptionFailed(error.localizedDescription) + } + } +} + +// MARK: - Key agreement abstraction + +/// A P-256 key that can perform ECDH, whether it lives in software or in the enclave. +public protocol EciesKeyAgreementKey { + var publicKeyX963: Data { get } + func sharedSecretBytes(with publicKey: P256.KeyAgreement.PublicKey) throws -> Data +} + +extension P256.KeyAgreement.PrivateKey: EciesKeyAgreementKey { + public var publicKeyX963: Data { Data(publicKey.x963Representation) } + + public func sharedSecretBytes(with publicKey: P256.KeyAgreement.PublicKey) throws -> Data { + let secret = try sharedSecretFromKeyAgreement(with: publicKey) + return secret.withUnsafeBytes { Data($0) } + } +} + +extension SecureEnclave.P256.KeyAgreement.PrivateKey: EciesKeyAgreementKey { + public var publicKeyX963: Data { Data(publicKey.x963Representation) } + + public func sharedSecretBytes(with publicKey: P256.KeyAgreement.PublicKey) throws -> Data { + let secret = try sharedSecretFromKeyAgreement(with: publicKey) + return secret.withUnsafeBytes { Data($0) } + } +} + +// MARK: - Private key import + +public enum IdentityKeyImport { + /// Load a P-256 private key from the PKCS#8 DER the TS side produces. + /// + /// `crypto.ts` exports identity private keys as base64 PKCS#8, so that is what + /// the daemon finds inside a wrap blob. We pull the 32-byte scalar out ourselves + /// rather than relying on CryptoKit's DER parsing accepting this exact profile, + /// and hand it straight to CryptoKit so the raw scalar lives in one place we + /// can scrub. + public static func p256KeyAgreementKey(fromPkcs8 der: Data) throws -> P256.KeyAgreement.PrivateKey { + var scalar = try extractP256Scalar(fromPkcs8: der) + defer { scrub(&scalar) } + do { + return try P256.KeyAgreement.PrivateKey(rawRepresentation: scalar) + } catch { + throw Ecies.EciesError.malformedPrivateKey(error.localizedDescription) + } + } + + /// Pull the raw 32-byte private scalar out of a PKCS#8 `PrivateKeyInfo`. + /// + /// Structure walked (all lengths short-form or 0x81/0x82 long-form): + /// SEQUENCE { INTEGER version, SEQUENCE algorithm, OCTET STRING privateKey } + /// and inside that OCTET STRING, an SEC1 `ECPrivateKey`: + /// SEQUENCE { INTEGER version, OCTET STRING privateKey(32), ... } + static func extractP256Scalar(fromPkcs8 der: Data) throws -> Data { + var reader = DerReader(Data(der)) + let outer = try reader.readSequenceBody() + + var top = DerReader(outer) + _ = try top.readElement(tag: 0x02) // version INTEGER + _ = try top.readElement(tag: 0x30) // AlgorithmIdentifier SEQUENCE + let inner = try top.readElement(tag: 0x04) // privateKey OCTET STRING + + var sec1 = DerReader(inner) + let sec1Body = try sec1.readSequenceBody() + var sec1Fields = DerReader(sec1Body) + _ = try sec1Fields.readElement(tag: 0x02) // version INTEGER + let scalar = try sec1Fields.readElement(tag: 0x04) + + guard scalar.count == 32 else { + throw Ecies.EciesError.malformedPrivateKey("expected a 32 byte P-256 scalar, got \(scalar.count)") + } + return scalar + } +} + +/// Minimal DER walker: enough to step through a PKCS#8 P-256 private key. +struct DerReader { + private let bytes: Data + private var offset: Int + + init(_ data: Data) { + self.bytes = Data(data) + self.offset = 0 + } + + /// Read one element with the expected tag and return its contents. + mutating func readElement(tag expected: UInt8) throws -> Data { + guard offset < bytes.count else { + throw Ecies.EciesError.malformedPrivateKey("truncated DER") + } + let tag = bytes[offset] + guard tag == expected else { + throw Ecies.EciesError.malformedPrivateKey( + String(format: "expected DER tag 0x%02x, found 0x%02x", expected, tag) + ) + } + offset += 1 + let length = try readLength() + guard offset + length <= bytes.count else { + throw Ecies.EciesError.malformedPrivateKey("DER element runs past end of buffer") + } + let contents = bytes[offset..<(offset + length)] + offset += length + return Data(contents) + } + + mutating func readSequenceBody() throws -> Data { + return try readElement(tag: 0x30) + } + + private mutating func readLength() throws -> Int { + guard offset < bytes.count else { + throw Ecies.EciesError.malformedPrivateKey("truncated DER length") + } + let first = bytes[offset] + offset += 1 + if first & 0x80 == 0 { return Int(first) } + + let byteCount = Int(first & 0x7f) + guard byteCount > 0, byteCount <= 4, offset + byteCount <= bytes.count else { + throw Ecies.EciesError.malformedPrivateKey("unsupported DER length encoding") + } + var value = 0 + for _ in 0.. SessionGrantBreadth { + let present = values.compactMap { $0 } + guard let first = present.first else { return builtInDefault } + return present.reduce(first) { $0.restrictiveness <= $1.restrictiveness ? $0 : $1 } + } +} + +/// Which breadth applies to which vault. +/// +/// One control sets it for every vault in a request today, because a request +/// names one vault in practice and a second checkbox for a distinction nobody +/// can yet make would be a control with nothing to control. +/// +/// It resolves PER VAULT anyway, which is the point of the type. A per-vault +/// checkbox (broad on your own local vault, narrow on a shared team one) is a +/// real thing to want and is deliberately deferred rather than ruled out; +/// building the resolution this way now means adding it later is a change to +/// the panel and nothing else. Nothing downstream asks "what did the user +/// pick", it asks "what applies to this vault". +public struct UnlockBreadthSelection: Equatable { + /// Per-vault answers, where one has been made. + private var byVault: [String: SessionGrantBreadth] + /// What a vault with no answer of its own gets. + private let fallback: SessionGrantBreadth + + public init(fallback: SessionGrantBreadth, byVault: [String: SessionGrantBreadth] = [:]) { + self.fallback = fallback + self.byVault = byVault + } + + /// One answer for every vault, which is what the panel's single checkbox + /// produces today. + public static func uniform(_ breadth: SessionGrantBreadth) -> UnlockBreadthSelection { + return UnlockBreadthSelection(fallback: breadth) + } + + public func breadth(forVault vaultId: String) -> SessionGrantBreadth { + return byVault[vaultId] ?? fallback + } + + /// Clamp to what the panel actually offered, per vault. + /// + /// A breadth that was never on the panel cannot be chosen, whatever comes + /// back from it: the answer is bounded by the question that was asked. + public func clamped(to offered: [SessionGrantBreadth]) -> UnlockBreadthSelection { + func allow(_ value: SessionGrantBreadth) -> SessionGrantBreadth { + return offered.contains(value) ? value : .wholeKey + } + return UnlockBreadthSelection( + fallback: allow(fallback), + byVault: byVault.mapValues(allow) + ) + } + + /// What a panel answer actually grants, per vault. + /// + /// Two rules, and the order matters. + /// + /// `once` grants narrow, full stop. The panel draws no breadth control + /// there, so there is no answer to clamp: `offered` guards an answer the + /// panel OFFERED, and under `once` it offered none. The safety `offered` + /// normally provides comes from the caller's own item binding instead, which + /// never binds a grant to an empty set, so a key that arrived with no + /// digests keeps its whole vault and the read still works. + /// + /// Anything else takes the checkbox, clamped to what was on the panel. + public static func granted( + by decision: PanelDecision, + offered: [SessionGrantBreadth] + ) -> UnlockBreadthSelection { + if decision.scope == .once { return .uniform(.listedItems) } + return .uniform(decision.breadth).clamped(to: offered) + } + + /// The single answer, for the places that still speak in one (the audit + /// record's summary line, and remembering a choice). The narrowest of what + /// was chosen, so a summary can never claim less caution than was applied. + public var narrowest: SessionGrantBreadth { + return SessionGrantBreadth.narrowest([fallback] + byVault.values.map { $0 }) + } +} + +/// The line a broad approval may not cross. +/// +/// Breadth is a control; this is not. Approving broadly says "anything in what +/// you just showed me", and what the panel showed is a set of vaults. A key in +/// a vault that was never on the panel is not something the user said yes to, +/// however broad they were feeling, so it asks again. +/// +/// Today every key sits in one implicit local vault, so this mostly holds +/// trivially. It is written as a vault rule rather than a key rule anyway, +/// because the moment a second vault exists it is the only rule that is +/// defensible, and a boundary retrofitted after the fact is a boundary with +/// holes in it. +public enum VaultBoundary { + /// The vault every key belongs to until there are vaults to belong to. + public static let localVaultId = "local" + + /// Whether a live grant's approval reaches a key in this vault. + public static func covers(approvedVaultId: String?, requestedVaultId: String) -> Bool { + guard let approvedVaultId else { return false } + return approvedVaultId == requestedVaultId + } +} + +/// A scope and its window as one comparable thing. +/// +/// `duration` is not a single answer, it is a family of them, so comparing two +/// approvals means comparing `once` against `4 hours` against `this session`. +/// Folding the window into the value is what lets that be a `<`. +public struct GrantWindow: Equatable { + public let scope: SessionGrantScope + /// Only meaningful for `duration`; nil elsewhere. + public let durationMs: Int64? + + public init(scope: SessionGrantScope, durationMs: Int64? = nil) { + self.scope = scope + self.durationMs = scope == .duration ? durationMs : nil + } + + /// The longest thing on offer, and the built-in default. + public static let builtInDefault = GrantWindow(scope: .session) + + /// How much life this answer carries, in ms, for comparing two of them. + /// + /// A `session` grant is clamped to the 12h cap like everything else, but it + /// also ends when the session does, so it ranks above the longest window a + /// caller can name rather than equal to it. + var lifetimeRank: Int64 { + switch scope { + case .once: return 0 + case .duration: return max(1, min(durationMs ?? SessionGrantTable.maxGrantMs, SessionGrantTable.maxGrantMs)) + case .session: return Int64.max + } + } + + /// The shortest-lived of several answers. + public static func narrowest(_ values: [GrantWindow?]) -> GrantWindow { + let present = values.compactMap { $0 } + guard let first = present.first else { return builtInDefault } + return present.reduce(first) { $0.lifetimeRank <= $1.lifetimeRank ? $0 : $1 } + } +} + +/// How a ciphertext is named in a grant's covered set. +/// +/// SHA-256 of the RAW payload bytes, computed on this side of the socket from +/// the ciphertext itself. Never a hash the client sent, and never anything +/// derived from a label: a digest the client could choose would let a client +/// choose what its own grant covers, which is the whole thing this is for. +public enum GrantItemDigest { + public static func of(_ ciphertext: Data) -> String { + return SHA256.hash(data: ciphertext).map { String(format: "%02x", $0) }.joined() + } + + /// The digests of a batch, in order, skipping nothing. + public static func of(_ ciphertexts: [Data]) -> [String] { + return ciphertexts.map { of($0) } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/GrantDeadline.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/GrantDeadline.swift new file mode 100644 index 000000000..ea84b47d5 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/GrantDeadline.swift @@ -0,0 +1,64 @@ +import Foundation + +/// The clock a grant's lifetime is actually measured against. +/// +/// `CLOCK_MONOTONIC_RAW` counts from an arbitrary point, is unaffected by NTP +/// steps and by anyone setting the system clock, and keeps counting while the +/// machine is asleep. That last part matters here: a clock that paused during +/// sleep (`CLOCK_UPTIME_RAW`) would let a suspended laptop hold a grant well past +/// its real 12h, which is the opposite of what the cap is for. +public enum MonotonicClock { + public static func nowMs() -> Int64 { + return Int64(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) / 1_000_000) + } +} + +/// When a grant runs out, measured on both clocks at once. +/// +/// Wall-clock time is what a person reads ("expires at 4pm"), so it has to be +/// recorded. It is also settable: anything that trusted it alone could be given +/// more life by moving the system clock backwards. So every deadline carries a +/// monotonic twin taken at the same instant, and whichever one runs out first +/// ends the grant. Under a normal clock the two are indistinguishable. +public struct GrantDeadline: Equatable { + /// epoch ms + public let wall: Int64 + /// `MonotonicClock` ms + public let monotonic: Int64 + + public init(wall: Int64, monotonic: Int64) { + self.wall = wall + self.monotonic = monotonic + } + + /// A deadline `durationMs` out from the two clock readings given. + public static func after(_ durationMs: Int64, wallNow: Int64, monotonicNow: Int64) -> GrantDeadline { + return GrantDeadline(wall: wallNow + durationMs, monotonic: monotonicNow + durationMs) + } + + public func isExpired(wallNow: Int64, monotonicNow: Int64) -> Bool { + return wall <= wallNow || monotonic <= monotonicNow + } + + /// Time left, on whichever clock has less of it. Never negative. + /// + /// The monotonic side is the one that governs in practice; the wall side only + /// becomes the smaller of the two after the system clock jumps forward, and in + /// that case the grant really does have less time than the monotonic clock + /// thinks, so reporting the smaller number keeps the answer honest. + public func remainingMs(wallNow: Int64, monotonicNow: Int64) -> Int64 { + return max(0, min(wall - wallNow, monotonic - monotonicNow)) + } + + /// The earlier of two deadlines, taken per clock. + /// + /// Element-wise rather than picking one whole deadline: clamping a grant to + /// its session cap has to clamp both halves, or a caller could ask for a long + /// window and keep the session's later monotonic deadline. + public static func earliest(_ lhs: GrantDeadline, _ rhs: GrantDeadline) -> GrantDeadline { + return GrantDeadline( + wall: Swift.min(lhs.wall, rhs.wall), + monotonic: Swift.min(lhs.monotonic, rhs.monotonic) + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/InvocationEvidence.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/InvocationEvidence.swift new file mode 100644 index 000000000..76ebfc51e --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/InvocationEvidence.swift @@ -0,0 +1,152 @@ +import Foundation +import SessionScoping + +/// How varlock came to be running, said plainly and always on screen. +/// +/// Two situations that look alike on a panel are materially different: a person +/// typing `varlock load`, and some program loading varlock as it starts. In the +/// first the user is the one asking. In the second a build tool, a dev server, or +/// a test runner is, and the values are for that program. Leaving the reader to +/// infer which one they are in is leaving out the thing they most need. +/// +/// The two halves of this have different standing, and the wording keeps them +/// apart: +/// +/// - the COMMAND LINES are read from the kernel's copy of each process's argv. +/// Nothing the client sent can change them. +/// - the MODE (cli, auto-load, sdk) is client-reported over the socket, because +/// from inside a spawned CLI an auto-load and a typed command are the same +/// process with the same arguments. It is a claim, not a measurement. +/// +/// Where the claim contradicts what the chain plainly shows, the chain wins and +/// the disagreement is recorded. A claim that cannot be checked is used as given; +/// one that can be, is. +public struct InvocationNote: Equatable { + public enum Kind: Equatable { + /// A person typed a varlock command. The string is that command line. + case typed(String) + /// varlock was loaded inside a host program, named by its own command. + case hosted(String) + /// Nothing could be read off the peer, so the panel says nothing rather + /// than guessing. + case unknown + } + + public let kind: Kind + /// For `varlock run`, the command that will be started and handed the values. + public let target: String? + /// Set when the client's claimed mode was overruled by the chain. For the + /// debug log, never for the panel: the user gets the conclusion, not the + /// argument. + public let disagreement: String? + + public init(kind: Kind, target: String? = nil, disagreement: String? = nil) { + self.kind = kind + self.target = target + self.disagreement = disagreement + } + + /// The lines the panel draws under the varlock hop, in order. + /// + /// Always visible. This is not detail for whoever opens the chain; it is the + /// answer to "what is this request". + /// + /// Each line is split into the words around it and the COMMAND itself, + /// because the panel draws the command as a command: a tinted strip, a dimmed + /// `$`, a monospaced face. It used to be small grey text like every other + /// note on the panel, which made the one line a person could recognise as + /// something they typed read as prose. + public var commandLines: [InvocationLine] { + switch kind { + case .typed(let command): + var lines = [InvocationLine(sigil: "$", command: command)] + if let target { + // The values do not stop at varlock: this command starts another + // one and hands them over. That process is in no chain, because + // it does not exist yet. + lines.append(InvocationLine( + sigil: "\u{21B3}", + command: target, + suffix: "receives these values" + )) + } + return lines + case .hosted(let host): + return [InvocationLine(prefix: "auto-loaded inside", command: host)] + case .unknown: + return [] + } + } + + /// The same lines as plain text, for a log or a test. + public var lines: [String] { commandLines.map(\.text) } +} + +/// One line naming a command, split so the command can be drawn as one. +public struct InvocationLine: Equatable { + /// The dimmed mark before the command: `$` for something a person typed, + /// `\u{21B3}` for the command the values are being handed on to. + public let sigil: String? + /// Words before the command that are ours rather than the shell's. + public let prefix: String? + /// The command line itself, read from the kernel's copy of argv. + public let command: String + /// Words after it. + public let suffix: String? + + public init(sigil: String? = nil, prefix: String? = nil, command: String, suffix: String? = nil) { + self.sigil = sigil + self.prefix = prefix + self.command = command + self.suffix = suffix + } + + public var text: String { + return [sigil, prefix, command, suffix].compactMap { $0 }.joined(separator: " ") + } +} + +public enum InvocationEvidence { + /// What to say about this request, from the chain and the client's claim. + public static func note(chain: ExecutionChain, claimed: UnlockInvocationMode?) -> InvocationNote { + guard let requester = chain.hops.first(where: { $0.isRequester }) else { + return InvocationNote(kind: .unknown) + } + + // A shell on the other end of the socket is not a shape this daemon + // speaks to: the peer is varlock's CLI or a program embedding it. Rather + // than narrate a chain that cannot be right (a preview pointed at some + // other pid, say), say nothing. + guard !requester.isShell else { + return InvocationNote(kind: .unknown) + } + + // varlock running inside somebody else's process. Nobody typed that, + // whatever the client said, and the command worth showing is the host's + // own, which is the process on the other end of the socket. + guard requester.isVarlock else { + let disagreement = claimed == .cli + ? "client claimed cli, but the peer is \(requester.name) rather than varlock's CLI" + : nil + guard let host = requester.invocation else { + return InvocationNote(kind: .unknown, disagreement: disagreement) + } + return InvocationNote(kind: .hosted(host), disagreement: disagreement) + } + + let typed = requester.invocation.map { InvocationNote.Kind.typed($0) } ?? .unknown + guard let claimed, claimed.isHosted else { + return InvocationNote(kind: typed, target: requester.runTarget) + } + if let host = chain.hostProgram?.invocation { + return InvocationNote(kind: .hosted(host)) + } + // Claimed as loaded by something, with nothing above the CLI that could + // have loaded it. The chain is the better witness. + return InvocationNote( + kind: typed, + target: requester.runTarget, + disagreement: "client claimed \(claimed.rawValue), but nothing above the CLI could have loaded it" + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/KeyAuthRecord.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/KeyAuthRecord.swift new file mode 100644 index 000000000..ef97089a6 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/KeyAuthRecord.swift @@ -0,0 +1,52 @@ +import Foundation + +/// What is recorded next to a key about how it must be authorized. +/// +/// Two independent things live here, and they are not the same question: +/// +/// - `requireAuth`: whether the key carries a presence gate at all. False only +/// for keys created with `--no-auth`, which is the CI and headless case. +/// - `policy`: for a gated key, how often the user must be asked. See +/// `KeyAuthPolicy`. +/// +/// The enclave knows the first as an access-control flag baked into the key, but +/// that flag cannot be read back off a stored key, so it is recorded here too. +/// Anything reading this file gets only policy, never key material, so losing the +/// file is a downgrade in strictness and never a leak. +/// +/// Both fields are optional on disk. A file written before `requireAuth` existed +/// reads as `true`, matching the Rust helper, whose `StoredKey.require_auth` +/// defaults the same way: never silently drop a prompt someone asked for. +public struct KeyAuthRecord: Equatable { + public static let fileVersion = 1 + + public let policy: KeyAuthPolicy + public let requireAuth: Bool + + public init(policy: KeyAuthPolicy = .standard, requireAuth: Bool = true) { + self.policy = policy + self.requireAuth = requireAuth + } + + /// Parse a sidecar file's contents. A missing or unreadable file is the + /// default record, which is the strictest reading of both fields. + public init(json: [String: Any]?) { + guard let json else { + self.init() + return + } + self.init( + policy: KeyAuthPolicy(wireValue: json["authMode"] as? String), + requireAuth: (json["requireAuth"] as? Bool) ?? true + ) + } + + /// The object form written back to the sidecar file. + public var jsonObject: [String: Any] { + return [ + "version": Self.fileVersion, + "authMode": policy.rawValue, + "requireAuth": requireAuth, + ] + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/LegacyPanelContent.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/LegacyPanelContent.swift new file mode 100644 index 000000000..73edc6c4e --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/LegacyPanelContent.swift @@ -0,0 +1,51 @@ +import Foundation + +/// What the panel says when the values were encrypted the old way. +/// +/// Before unlock sessions, values were encrypted straight to this Mac's device +/// key. That path has no grant table behind it: there is no session to scope, no +/// item set to narrow to, and nothing to remember, so it gets none of the +/// ladder, the breadth checkbox or the memory. What it does have is the same +/// panel frame, because a path to a secret that does not say who is asking is +/// the thing this window exists to remove. +/// +/// Two things it has to be honest about, and neither is a control. +/// +/// The window is not `once`. macOS is asked to reuse one scan for +/// `SessionManager.sessionTimeout`, so approving here lets reads continue +/// without another prompt for up to that long. The panel used to say "Allowed +/// for: once" over a note admitting "a few minutes", which is the panel +/// contradicting itself about the one number that matters. It says the real +/// number now, as a fact rather than as an option, because the user has no say +/// in it: the reuse window belongs to macOS and this path cannot set it. +/// +/// And it is a state somebody can leave: these values stay in the old format +/// until they are re-encrypted, so every install lands here on upgrade and a +/// partially upgraded project draws BOTH panels in one load. Saying so, and +/// doing something about it, is deliberately not here yet. This type is only +/// about the panel not lying, which is true whatever the way out turns out to +/// be. +public enum LegacyDeviceKeyPanel { + /// The window this approval really carries, said in the slot where the + /// ladder would be. + /// + /// "Up to", because the reuse is a ceiling and not a promise: the scan may + /// be re-asked for sooner, and a line reading "for 5 minutes" would be + /// claiming a guarantee nobody made. The number comes from the constant that + /// is actually sent to macOS, so the copy cannot drift away from it. + public static func windowFactLine(reuse: TimeInterval) -> String { + return "Allowed for up to \(DurationText.prose(milliseconds(reuse)))" + } + + /// What this format is, and who owns the window it grants. + public static func formatNote(reuse: TimeInterval) -> String { + return "These values are encrypted to this Mac's device key, the format varlock used " + + "before unlock sessions. macOS reuses one scan for up to " + + "\(DurationText.prose(milliseconds(reuse))) on this path, which varlock cannot set " + + "or shorten from here." + } + + private static func milliseconds(_ reuse: TimeInterval) -> Int64 { + return Int64((reuse * 1000).rounded()) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/MachineConfigEdit.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/MachineConfigEdit.swift new file mode 100644 index 000000000..666aa5124 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/MachineConfigEdit.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Editing one field of the user-level config file. +/// +/// That file is shared: telemetry settings live in it, and so will anything +/// varlock adds later. So this is a read, a single change, and a write of +/// everything else back untouched. It never starts from a blank object when the +/// file already has content, and it refuses to write at all rather than replace +/// something it could not parse, since clobbering a config that only failed to +/// parse because of a typo would lose settings the user wrote by hand. +public enum MachineConfigEdit { + public enum EditError: LocalizedError { + case unparseable + case notAnObject + + public var errorDescription: String? { + switch self { + case .unparseable: + return "The varlock config file could not be parsed, so it was left alone. Fix or remove it first." + case .notAnObject: + return "The varlock config file is not a JSON object, so it was left alone." + } + } + } + + /// The contents to write so that `sessions.lockOn` says `policy`. + /// + /// - Parameter existing: current file contents, or nil when there is no file. + public static func settingLockOn(_ policy: SessionLockPolicy, in existing: Data?) throws -> Data { + return try setting( + section: LockPolicyResolution.configSectionKey, + field: LockPolicyResolution.configFieldKey, + to: policy.rawValue, + in: existing + ) + } + + static func setting(section: String, field: String, to value: String, in existing: Data?) throws -> Data { + var root: [String: Any] = [:] + if let existing, !existing.isEmpty { + guard let parsed = try? JSONSerialization.jsonObject(with: existing) else { + throw EditError.unparseable + } + guard let object = parsed as? [String: Any] else { + throw EditError.notAnObject + } + root = object + } + + var sectionObject = (root[section] as? [String: Any]) ?? [:] + sectionObject[field] = value + root[section] = sectionObject + + // Sorted and pretty-printed: this file is edited by hand as well, and a + // write from the menu should not reshuffle it into one long line. + let data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + return data + Data("\n".utf8) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelContent.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelContent.swift new file mode 100644 index 000000000..9fbff381b --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelContent.swift @@ -0,0 +1,1273 @@ +import Foundation +import SessionScoping + +/// What the approval panel says, as data. +/// +/// The daemon is the trusted display here, so the wording is decided in this +/// process and never taken from the caller verbatim. Splitting the content from +/// the AppKit view means the copy, the grouping, and the scope choices can all be +/// asserted in tests that never open a window. + +/// One line of context about who is asking. +/// +/// `derived` lines are facts the daemon read off the peer process itself, so they +/// are the ones a user can rely on. `clientSupplied` lines came over the socket +/// from the connecting varlock process: the peer is code-signature checked, but +/// the content is still decoration and is shown as secondary. +public enum PanelContextLine: Equatable { + case derived(String) + case clientSupplied(String) + + public var text: String { + switch self { + case .derived(let text), .clientSupplied(let text): return text + } + } + + public var isDerived: Bool { + if case .derived = self { return true } + return false + } +} + +/// Who is asking, split into what the panel shows at rest and what it keeps behind +/// the disclosure. +/// +/// The panel has to be readable in the second before a finger lands on the sensor, +/// so the resting state is one line naming the process and where it is running. The +/// full ancestry and the client's own decoration are still there for anyone who +/// wants them, one click away, because the detail is the evidence: it just should +/// not be what a routine unlock makes you read. +public struct PanelRequester: Equatable { + /// The single line shown at rest. Derived from the peer process. + public let summary: String + /// Everything else, shown when the disclosure is opened. + public let details: [PanelContextLine] + /// The processes that lead to the caller, when the daemon could read them. + /// This is what the panel draws; `summary` is the flattened form the audit + /// log and the biometric prompt's reason line use. + public let chain: ExecutionChain? + + public init(summary: String, details: [PanelContextLine] = [], chain: ExecutionChain? = nil) { + self.summary = summary + self.details = details + self.chain = chain + } + + public var hasDetails: Bool { !details.isEmpty } +} + +/// A run of panel text, and whether it names something the machine reads. +/// +/// Key names are drawn in a monospaced face, which is the panel's way of saying +/// "this is an identifier, exactly as written". Deciding that here rather than in +/// the view keeps the copy and its emphasis in one testable place. +public enum PanelTextSegment: Equatable { + case plain(String) + case code(String) + + public var text: String { + switch self { + case .plain(let text), .code(let text): return text + } + } +} + +/// One row of the panel's key box: a key, the vault it lives in, and what the +/// client says it opens. +public struct PanelKeyRow: Equatable { + /// The real key id. Never drawn when a friendlier name exists, but it is what + /// the grant and the audit record are keyed by. + public let keyId: String + /// What the row calls this key. + public let displayName: String + /// The vault tag's label, or nil when a tag would only repeat the name. + public let vaultLabel: String? + /// The vault's `#rrggbb` identity colour, or nil for the default tint. + public let vaultColor: String? + /// How many values the client says this key covers, across every source. + public let valueCount: Int? + /// Where those values live: env files, the value cache, whatever else comes + /// later. Client-reported, and the row says so when it is opened. + /// + /// One list, not one list plus special cases. Everything a key protects is + /// opened by the same grant, so everything a key protects is a peer here, + /// and grouping by key is what carries that: when a key belongs to a vault, + /// its sources come with it. + public let sources: [UnlockValueSource] + /// Anything that changes what approving this row means, e.g. a strict key. + public let note: String? + + public init( + keyId: String, + displayName: String, + vaultLabel: String? = nil, + vaultColor: String? = nil, + valueCount: Int? = nil, + sources: [UnlockValueSource] = [], + note: String? = nil + ) { + self.keyId = keyId + self.displayName = displayName + self.vaultLabel = vaultLabel + self.vaultColor = vaultColor + self.valueCount = valueCount + self.sources = sources + self.note = note + } + + /// What this key is called out loud, in the one-line sentence macOS builds + /// for its own sheet. + /// + /// Never a key id: `varlock-default` is an implementation detail, and the + /// default key has no name of its own worth saying, so its vault (when there + /// is one) is the friendlier thing to name. Any other key is called what the + /// user called it. + public var spokenName: String { + if keyId == UnlockPanelContent.defaultKeyId { return vaultLabel ?? displayName } + return displayName + } + + /// "12 values", or nil when the client said nothing about how many. + /// + /// The client's own count wins where it sent one, because it knows what the + /// key covers and a batch only knows what it is decrypting right now. Where + /// it sent none, the sources are added up rather than left blank. + public var valueCountLabel: String? { + let total = valueCount ?? sources.reduce(0) { $0 + $1.itemCount } + guard total > 0 else { return nil } + return UnlockValueSource.valuesLabel(total) + } + + /// What the row's trailing slot says. + /// + /// A row whose client said nothing about what it covers says exactly that. + /// The alternative is a blank slot, which reads as "nothing much" on a panel + /// whose whole job is to say what is being handed over, and a panel that + /// looks authoritative while knowing nothing is worse than one that admits + /// it. + public var contentsLabel: String { + return valueCountLabel ?? "contents not reported" + } + + /// Whether the client said anything at all about what this key covers. + public var reportsContents: Bool { + return valueCountLabel != nil || !sources.isEmpty + } + + /// Whether there is anything to see when the row is opened. + public var isExpandable: Bool { + return sources.contains { $0.isDrawable } + } + + /// Where the open row's detail came from, said out loud because the daemon + /// derived none of it. + public var sourceFootnote: String { + return sources.contains { $0.kind != .file } + ? "Sources and contents reported by the client" + : PanelContent.valueSourceFootnote + } +} + +/// One rung of the panel's "how long" ladder: an answer, and what it is called. +public struct PanelWindowOption: Equatable { + /// Whether the rung's answer is fixed, or set by the person approving. + public enum Kind: Equatable { + /// `Once`, a preset window, `This session`. The label is the answer. + case fixed + /// The rung whose window is typed in. Its `window` is whatever the field + /// currently holds, so the panel re-reads it rather than trusting the + /// value the row was built with. + case custom + } + + public let window: GrantWindow + public let label: String + public let kind: Kind + + public init(window: GrantWindow) { + self.window = window + self.label = PanelContent.windowLabel(window) + self.kind = .fixed + } + + /// The custom rung, which always reads `Custom`. + /// + /// Never renamed to the value it holds, and that is the point of it. A rung + /// wearing `45min` at a fixed position between `1hr` and `This session` puts + /// a free value into an ordered row and breaks the order the row exists to + /// show. It can also end up reading exactly the same as the preset beside + /// it. The word is what keeps the position honest: this is the rung you set, + /// and where it sits says only that a typed window is bounded by the + /// session. + /// + /// Nothing is hidden by that. The field is on screen exactly when this rung + /// is selected, so the number is already in front of the reader, and the + /// summary sentence underneath states it in words. + /// + /// - Parameter custom: the value the rung is holding. It sets the rung's + /// `window`, so a remembered answer matches this rung exactly, and it does + /// not touch the label. + public init(custom: CustomDuration?) { + self.window = GrantWindow(scope: .duration, durationMs: (custom ?? .unset).milliseconds) + self.label = PanelContent.customWindowLabel + self.kind = .custom + } +} + +/// Everything the panel needs to draw itself and to report a decision. +public struct PanelContent: Equatable { + /// The heading, in runs, so key names can be drawn as identifiers. + public let titleSegments: [PanelTextSegment] + public let subtitle: String? + /// Who is asking: one line at rest, the chain and the rest behind disclosures. + public let requester: PanelRequester + /// The key box: one row per key this approval covers. + public let keyRows: [PanelKeyRow] + /// Small print under the key box: what makes this approval unusual, if + /// anything (an add-on to a live session, keys that ask every time). + public let notes: [String] + /// The quiet fact in the top bar. A standing truth about approvals, not + /// something about this one request. + public let factLine: String? + /// How the client says varlock came to be running, which changes how the + /// chain words its command line. + public let invocationMode: UnlockInvocationMode? + /// Amber lines drawn on the session-root row: what is unusual about the + /// session this request came from. Worked out where both halves are known, + /// which is here: the session comes off the kernel, the project comes off the + /// client, and neither side can answer on its own. + public let sessionAdvisories: [String] + /// Which build of varlock the client says it is, for the rows where the + /// daemon could not establish it. Always drawn as a claim. + public let reportedVarlockVersion: String? + public let scopes: [SessionGrantScope] + public let defaultScope: SessionGrantScope + /// Which timed rung the panel opens on, when `defaultScope` is `duration`. + /// nil means the shortest one, which is what a preselection that named no + /// window is asking for. + public let defaultDurationMs: Int64? + /// How much of each key an approval may cover. One entry means there is no + /// choice to draw and the approval covers the whole key, as it always did. + public let breadths: [SessionGrantBreadth] + /// Which breadth the panel starts on. + public let defaultBreadth: SessionGrantBreadth + /// How many ciphertexts the narrow choice would cover. Daemon-counted. + public let listedItemCount: Int + /// How many distinct vaults this approval is over. One, until vaults exist. + /// Only ever used to word things in the singular or the plural. + public let vaultCount: Int + /// Whether anything in this request has a source item scope cannot reach, + /// which the panel has to say out loud rather than let a reader assume. + public let hasUnlistableSource: Bool + /// What the panel says about why it opened where it did, when there is + /// something to say (a remembered narrowing, an unusual-looking request). + public let selectionNote: String? + /// The window this approval carries, stated rather than offered. + /// + /// Set where the user has no say in it, and the window is not the one the + /// ladder's narrowest rung would imply. A control implies a decision, so a + /// window nobody can change has to be a sentence instead; see the legacy + /// device-key panel, where macOS owns the reuse window and varlock is only + /// reporting it. + public let windowFactLine: String? + public let confirmButtonTitle: String + public let cancelButtonTitle: String + + public init( + titleSegments: [PanelTextSegment], + subtitle: String? = nil, + requester: PanelRequester = PanelRequester(summary: ""), + keyRows: [PanelKeyRow] = [], + notes: [String] = [], + windowFactLine: String? = nil, + factLine: String? = nil, + invocationMode: UnlockInvocationMode? = nil, + sessionAdvisories: [String] = [], + reportedVarlockVersion: String? = nil, + scopes: [SessionGrantScope], + defaultScope: SessionGrantScope, + defaultDurationMs: Int64? = nil, + breadths: [SessionGrantBreadth] = [.wholeKey], + defaultBreadth: SessionGrantBreadth = .wholeKey, + listedItemCount: Int = 0, + vaultCount: Int = 1, + hasUnlistableSource: Bool = false, + selectionNote: String? = nil, + confirmButtonTitle: String, + cancelButtonTitle: String = "Deny" + ) { + self.breadths = breadths + self.defaultBreadth = defaultBreadth + self.listedItemCount = listedItemCount + self.vaultCount = vaultCount + self.hasUnlistableSource = hasUnlistableSource + self.selectionNote = selectionNote + self.titleSegments = titleSegments + self.subtitle = subtitle + self.requester = requester + self.keyRows = keyRows + self.notes = notes + self.windowFactLine = windowFactLine + self.factLine = factLine + self.invocationMode = invocationMode + self.sessionAdvisories = sessionAdvisories + self.reportedVarlockVersion = reportedVarlockVersion + self.scopes = scopes + self.defaultScope = defaultScope + // Clamped here, at the boundary where a duration becomes something the + // panel DRAWS. A preselection can arrive from a remembered answer, and + // that file is a text file somebody can edit; a rung reading `48hr` on a + // grant the table would cut to 12 is the panel telling a lie it did not + // author. Below the floor is clamped for the same reason in the other + // direction. + self.defaultDurationMs = defaultScope == .duration + ? defaultDurationMs.map { min(max(1, $0), SessionGrantTable.maxGrantMs) } + : nil + self.confirmButtonTitle = confirmButtonTitle + self.cancelButtonTitle = cancelButtonTitle + } + + /// Plain-text form, for a window title, a log line, or a test. + public var title: String { + return titleSegments.map { $0.text }.joined() + } + + /// The rung of the ladder the panel opens on. + public var defaultWindow: GrantWindow { + return GrantWindow(scope: defaultScope, durationMs: defaultDurationMs) + } + + /// The value the custom rung opens holding, when the panel opens on one. + /// + /// This is the memory ratchet's other half. A remembered narrowing comes + /// back through `defaultDurationMs` like any other preselection; a value + /// that names no preset is by definition a custom one, so it comes back with + /// the custom rung selected and the field primed to it. There is no second + /// path for remembering a custom answer, and deliberately so: one + /// preselection rule is a rule people can check. + public var customDuration: CustomDuration? { + guard defaultScope == .duration, + let durationMs = defaultDurationMs, + DurationPreset.matching(milliseconds: durationMs) == nil else { return nil } + return CustomDuration.forMilliseconds(durationMs) + } + + /// Every rung this request may be answered with, shortest first. + public var windowOptions: [PanelWindowOption] { + return PanelContent.windowOptions(scopes: scopes, custom: customDuration) + } + + /// The breadth control: one checkbox, ticked for the broad answer. + /// + /// A checkbox rather than a pair of buttons because there is a default here + /// and the default is broad. Two equally weighted pills present a decision + /// where there is really a setting, and they make the safe, ordinary answer + /// look like something you have to pick. + /// + /// Says what ticking it DOES, in the words somebody would use for it: the + /// rest of this vault opens without asking again. An earlier wording, + /// "Cover anything this vault can open", described the grant's extent + /// accurately and left the reader to work out what that meant for them the + /// next time something was decrypted. + /// + /// Deliberately the plain-language version and not the precise one. The + /// sentence directly underneath carries the caveats (what the list is, that + /// it is a snapshot rather than a definition, what the vault boundary is), + /// and a label that tried to carry them too would be a paragraph on a + /// checkbox nobody reads twice. + public static func breadthCheckboxLabel(vaultCount: Int) -> String { + return vaultCount == 1 + ? "Auto-unlock all items in this vault" + : "Auto-unlock all items in these vaults" + } + + /// The whole answer in one sentence, under the controls. + /// + /// This is where the panel is honest about the list. Showing twelve named + /// values and then opening a thirteenth is the failure this wording exists + /// to prevent: under a broad approval the list is WHAT THE GRANT COVERS + /// RIGHT NOW, not what defines it, and a person who reads only this line + /// should not be surprised later. So the broad sentence names the vault as + /// the thing being granted and puts the list inside it ("not just the 12 + /// listed"), rather than letting the list stand as the definition and + /// hoping the reader works out that it is a snapshot. + /// + /// The narrow sentence can say "only", because there it really is the + /// definition and the daemon enforces it. + public static func selectionSummary( + breadth: SessionGrantBreadth, + itemCount: Int, + vaultCount: Int = 1, + scope: SessionGrantScope, + durationLabel: String? + ) -> String { + let vaults = vaultCount == 1 ? "this vault" : "these vaults" + let what: String + switch breadth { + case .listedItems: + what = itemCount == 1 + ? "Covers only the 1 value listed above" + : "Covers only the \(itemCount) values listed above" + case .wholeKey: + what = itemCount > 0 + ? "Covers anything \(vaults) can open, not just the \(itemCount) listed above" + : "Covers anything \(vaults) can open" + } + let howLong: String + switch scope { + case .once: return "\(what), for this one read." + case .session: howLong = "until this session ends" + case .duration: howLong = "for \(durationLabel ?? DurationPreset.default.label)" + } + return "\(what), \(howLong)." + } + + /// What the panel says when the narrow answer does not narrow everything. + /// + /// The value cache is never item scoped, so narrow means "only these FILE + /// values, and the cache as a whole". Nobody who reads "covers only the 12 + /// values listed above" should walk away believing they restricted cache + /// access, so the exception is stated next to the choice rather than as a + /// footnote under the key rows. + /// + /// Deliberately says nothing about the checkbox. It is true in every state, + /// including `once`, where the grant is narrow and no checkbox is drawn at + /// all: a caveat that pointed at a control the reader cannot see would send + /// them looking for it. + public static let unlistableSourceNote = + "The value cache is always covered as a whole: " + + "it is machine-written and changes constantly." + + /// Human label for one answer to "how long". Plain words, no jargon. + public static func windowLabel(_ window: GrantWindow) -> String { + switch window.scope { + case .once: return "Once" + case .session: return "This session" + case .duration: + return DurationText.short(window.durationMs ?? DurationPreset.default.milliseconds) + } + } + + /// What the custom rung says before anybody has set a value on it. + public static let customWindowLabel = "Custom" + + /// The ceiling, said next to the field that is bounded by it. + /// + /// Written out rather than left to be discovered by a clamp. A number that + /// silently becomes a different number is fine as a safety net and poor as a + /// way of teaching somebody what the limit is. + public static func customDurationCapLabel(unit: DurationUnit) -> String { + return "max \(unit.maxAmount)\(unit.suffix)" + } + + /// Every answer to "how long", as one ladder, shortest first. + /// + /// One question gets one control. Splitting it into a mode ("for a set + /// time") and then a second row of windows made the timed answers cost two + /// clicks and a reveal, and the reveal is what forced an empty band to be + /// held open under every other answer so the buttons would not move. + /// + /// The order carries information the labels do not: a reader scanning left + /// to right sees the ladder they are picking a rung on, from the single read + /// through to the whole session. So the timed rungs sit BETWEEN `Once` and + /// `This session` rather than after them, because that is where they fall. + /// + /// Two of the rungs are presets and one is yours. Four clock rungs guessed + /// at numbers, spent the row's width doing it, and still missed: the person + /// who cares enough about duration to change it usually wants a value nobody + /// guessed. So the row names the two windows most approvals want and then + /// offers the rest of the range on one rung. + /// + /// `Custom` sits after the presets and before `This session`. Its VALUE is + /// free and may well be shorter than the rung to its left, so the ladder is + /// strictly ascending only in its fixed rungs; what its position promises is + /// that a typed window is bounded by the session, never that it is longer + /// than an hour. + /// + /// - Parameter custom: the value the custom rung is holding, or nil for a + /// rung nobody has set yet. + public static func windowOptions( + scopes: [SessionGrantScope], + custom: CustomDuration? = nil + ) -> [PanelWindowOption] { + var options: [PanelWindowOption] = [] + if scopes.contains(.once) { + options.append(PanelWindowOption(window: GrantWindow(scope: .once))) + } + if scopes.contains(.duration) { + options.append(contentsOf: DurationPreset.allCases.map { + PanelWindowOption(window: GrantWindow(scope: .duration, durationMs: $0.milliseconds)) + }) + options.append(PanelWindowOption(custom: custom)) + } + if scopes.contains(.session) { + options.append(PanelWindowOption(window: GrantWindow(scope: .session))) + } + return options + } + + /// Which rung an answer is, falling back to the shortest one offered. + /// + /// A remembered custom window matches the custom rung exactly, because the + /// rung was built from the same `defaultDurationMs` this is looking up. That + /// is the whole round trip: one value, one rung, no second mechanism. + /// + /// A fallback that reached outwards would open the panel on more than the + /// caller asked for, so an answer with no rung lands on the narrowest thing + /// on the row rather than on the custom rung, whose value it has no claim + /// over. + public static func windowOptionIndex( + of window: GrantWindow, + in options: [PanelWindowOption] + ) -> Int { + if let exact = options.firstIndex(where: { $0.window == window }) { return exact } + if window.scope == .duration, + let firstTimed = options.firstIndex(where: { $0.window.scope == .duration }) { + return firstTimed + } + return 0 + } + + /// The one line macOS puts in its own sheet, as a verb phrase. + /// + /// macOS builds the sentence itself ("Varlock is trying to ..."), so this is + /// only ever the tail of one. It is deliberately the shortest true thing: + /// the panel is the surface that says who is asking and what they get, and + /// repeating any of that on a sheet that covers the panel would be two + /// voices telling the same story badly. Key ids never appear. + public var presenceReason: String { + return UnlockPanelContent.presenceReason(forRows: keyRows) + } + + /// Where the values listed under a key row came from. Said out loud on the + /// panel, because the daemon did not derive them and cannot vouch for them. + public static let valueSourceFootnote = "Value names and files reported by the client" +} + +/// What the user chose. +public struct PanelDecision: Equatable { + public let approved: Bool + public let scope: SessionGrantScope + public let durationMs: Int64? + /// How much of each vault this answer opens. Defaults to the broad answer, + /// so every caller that predates the choice keeps the behaviour it had. + /// + /// Under `once` this is always `listedItems`, whatever the checkbox last + /// said, because the panel draws no checkbox there. See + /// `ApprovalFlow.effectiveBreadth` for why. + public let breadth: SessionGrantBreadth + /// The breadth the USER chose, where they were given the choice. + /// + /// nil under `once`, and the difference is the whole point: `once` is a + /// DURATION answer that implies a breadth for that one grant. It is not a + /// statement about how broad this person likes their approvals, so it must + /// not be written down as one. Somebody who picks "once" today and "this + /// session" tomorrow should find the checkbox back at its own default, not + /// still tightened by a decision they made about time. + public let chosenBreadth: SessionGrantBreadth? + + public init( + approved: Bool, + scope: SessionGrantScope, + durationMs: Int64? = nil, + breadth: SessionGrantBreadth = .wholeKey, + chosenBreadth: SessionGrantBreadth? = nil + ) { + self.approved = approved + self.scope = scope + self.durationMs = durationMs + self.breadth = breadth + self.chosenBreadth = chosenBreadth + } + + /// The scope and its window as one value, for comparing and remembering. + public var window: GrantWindow { GrantWindow(scope: scope, durationMs: durationMs) } + + public static func denied( + defaultScope: SessionGrantScope, + breadth: SessionGrantBreadth = .wholeKey + ) -> PanelDecision { + // A refusal teaches the preferences nothing, so it carries no choice. + return PanelDecision( + approved: false, + scope: defaultScope, + durationMs: nil, + breadth: breadth, + chosenBreadth: nil + ) + } +} + +/// Reads the key ids out of an `unlock-session` payload. +/// +/// Deliberately has no default. A caller that names no key has asked for +/// nothing, and quietly unlocking some other key on its behalf would hand it a +/// grant it never requested. The caller is told instead. +public enum UnlockRequestKeys { + /// Both forms are accepted: `keyIds` for the normal batch, and `keyId` for a + /// one-off caller. Blank entries are dropped, and the result is deduped and + /// sorted so one unlock covers the same set however the caller ordered it. + public static func from(payload: [String: Any]?) -> [String] { + guard let payload else { return [] } + var requested = (payload["keyIds"] as? [Any]) ?? [] + if let single = payload["keyId"] { requested.append(single) } + + var seen = Set() + var keyIds: [String] = [] + for value in requested { + guard let keyId = value as? String else { continue } + guard !keyId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if seen.insert(keyId).inserted { keyIds.append(keyId) } + } + return keyIds.sorted() + } +} + +/// Reads the ciphertexts an unlock is being asked to cover. +/// +/// `{ "items": { "": ["", ...] } }`, and nothing else. +/// Not part of `display`, deliberately: everything in `display` is decoration +/// the daemon does not check, and this is the one thing a client sends that the +/// daemon turns into an enforced fact. Keeping them apart keeps that difference +/// visible in the payload as well as in the code. +/// +/// Payloads, never digests. A digest the client computed would be a digest the +/// client chose, and an item-scoped grant would then cover whatever it felt +/// like. Hashing happens on this side or not at all. +public enum UnlockRequestItems { + /// Caps on how much a caller can bind one grant to. A request over the cap + /// is trimmed rather than refused, and a trimmed key simply cannot be + /// narrowed: `UnlockPlanner` only offers item scope where every key's + /// digests arrived, so the panel does not promise a narrowing it would then + /// have to break. + public static let maxItemsPerKey = 500 + public static let maxItemsTotal = 2000 + + public static func from(payload: [String: Any]?) -> [String: Set] { + guard let raw = payload?["items"] as? [String: Any] else { return [:] } + var out: [String: Set] = [:] + var budget = maxItemsTotal + for (keyId, value) in raw { + guard budget > 0, let list = value as? [Any] else { continue } + var digests = Set() + for entry in list.prefix(min(maxItemsPerKey, budget)) { + guard let text = entry as? String, let data = Data(base64Encoded: text) else { continue } + digests.insert(GrantItemDigest.of(data)) + } + guard !digests.isEmpty else { continue } + budget -= digests.count + out[keyId] = digests + } + return out + } +} + +/// One place the values behind a key come from. +/// +/// An env file and varlock's value cache are the same kind of thing here, and +/// that is the point: one key means one grant, so everything that key opens +/// belongs in one list under it rather than in a list plus an exception. A new +/// kind of source is a new `Kind` and a label, and the panel draws it without +/// knowing what it is. +/// +/// Client-reported, and shown as such. The daemon has no way to know what an +/// env value is called or what filled a cache, so this is the only account +/// there is; it is drawn behind a disclosure and labelled, rather than +/// presented as something the daemon verified. +public struct UnlockValueSource: Equatable { + public enum Kind: String, Equatable { + /// An env file, whose entries are the values it defined. + case file + /// Varlock's value cache, whose entries are what filled it. + case cache + + /// What the panel calls a source of this kind when it has no path. + var fallbackLabel: String { + switch self { + case .file: return "values" + case .cache: return "value cache" + } + } + + /// Whether an item-scoped approval reaches inside this kind of source. + /// + /// A file's values are written by a person and change when that person + /// changes them, so approving them one by one is a thing a person can + /// mean. The value cache is not like that: it is machine-written, and + /// every provider refresh rewrites an entry into a ciphertext nobody has + /// ever seen. Item-scoping it would refuse the next read of a value that + /// only changed because it was renewed on schedule, and the user would + /// answer a panel per refresh for the rest of the day. A control that + /// makes varlock unusable is not a control, so the cache is always + /// covered as a whole and the panel says so. + /// + /// This lives on the kind rather than at the place the decision is + /// enforced, so a source kind added later cannot quietly acquire the + /// exemption by being handled somewhere that forgot to ask. + public var isItemScopable: Bool { + switch self { + case .file: return true + case .cache: return false + } + } + } + + public let kind: Kind + /// The file that defined these values. nil for anything that is not a file, + /// and for a file the client did not name. + public let path: String? + /// What is inside: value names for a file, the providers that filled the + /// cache for a cache. + public let entries: [Entry] + /// How many values this source contributes, when the entries summarise + /// rather than enumerate. nil means the entries are the whole list. + public let reportedItemCount: Int? + + public init( + kind: Kind = .file, + path: String? = nil, + entries: [Entry] = [], + reportedItemCount: Int? = nil + ) { + self.kind = kind + self.path = path + self.entries = entries + self.reportedItemCount = reportedItemCount + } + + /// One thing inside a source: an env value, or a provider that filled the + /// cache and how much of it that provider accounts for. + public struct Entry: Equatable { + public let name: String + /// How many values this entry stands for. nil when it stands for one + /// and needs no number after it. + public let count: Int? + + public init(name: String, count: Int? = nil) { + self.name = name + self.count = count + } + + /// The chip's text: a bare name, or a name with what it accounts for. + public var label: String { + guard let count, count > 1 else { return name } + return "\(name) \u{00B7} \(count)" + } + } + + /// How many values this source contributes. + public var itemCount: Int { + if let reportedItemCount { return reportedItemCount } + return entries.reduce(0) { $0 + ($1.count ?? 1) } + } + + /// The line above the chips: the source's own name and nothing else. nil + /// for a file the client did not name, whose values are listed under no + /// heading rather than under a made-up one. + /// + /// How much is in it is `headingCount`, drawn as a badge rather than said + /// in words: a column of sources is read by comparing their sizes, and + /// numerals compare at a glance where "8 values / 4 values / 12 values" + /// has to be read three times. + public var heading: String? { + return path ?? (kind == .file ? nil : kind.fallbackLabel) + } + + /// The number on the heading's badge, or nil when there is nothing to say. + /// + /// A source whose size is unknown draws no badge at all: an empty one would + /// be a claim of its own, and a zero would be a wrong one. + public var headingCount: Int? { + guard heading != nil, itemCount > 0 else { return nil } + return itemCount + } + + /// Whether this source puts anything on the panel at all. + public var isDrawable: Bool { + return !entries.isEmpty || heading != nil + } + + /// Whether an item-scoped approval reaches inside this source. + public var isItemScopable: Bool { kind.isItemScopable } + + /// "1 value" / "12 values", in one place so every line that counts values + /// counts them the same way. + public static func valuesLabel(_ count: Int) -> String { + return count == 1 ? "1 value" : "\(count) values" + } +} + +/// What the client says one key is being asked to open. +/// +/// Display only, and deliberately not bound into anything: none of it reaches +/// the crypto, and the daemon never checks it against what it holds. It exists +/// so the panel can answer "what do they get" beyond a bare key id. +public struct UnlockKeyDisplay: Equatable { + public let valueCount: Int? + public let sources: [UnlockValueSource] + /// The vault this key belongs to, once vaults exist. nil means the local one. + public let vaultLabel: String? + /// The vault's identity colour as `#rrggbb`, or nil for the default tint. + public let vaultColor: String? + + /// The vault's stable id, which is the line a broad approval may not cross. + /// + /// Client-supplied like the rest of this type, and that is fine in the only + /// direction it can act: an id is compared against the one a live grant was + /// approved under, and any disagreement means a fresh panel. A caller can + /// therefore cost itself a prompt by changing its mind about which vault a + /// key is in, and cannot do anything else with it. + /// + /// Falls back to the vault label, and then to the local vault, so a caller + /// that only names its vaults still gets a boundary between them. + public var vaultId: String { + if let declaredVaultId { return declaredVaultId } + if let vaultLabel { return "label:" + vaultLabel.lowercased() } + return VaultBoundary.localVaultId + } + + private let declaredVaultId: String? + + public init( + valueCount: Int? = nil, + sources: [UnlockValueSource] = [], + vaultLabel: String? = nil, + vaultColor: String? = nil, + vaultId: String? = nil + ) { + self.valueCount = valueCount + self.sources = sources + self.vaultLabel = vaultLabel + self.vaultColor = vaultColor + self.declaredVaultId = vaultId + } + + /// Caps on how much a client can put in one key's row. A caller with more + /// than this is trimmed rather than refused: the panel has to stay a panel. + public static let maxSources = 8 + public static let maxEntries = 60 + static let maxEntryNameLength = 64 + static let maxPathLength = 60 + static let maxVaultLabelLength = 32 + + static func from(_ raw: Any?) -> UnlockKeyDisplay? { + guard let raw = raw as? [String: Any] else { return nil } + + var sources: [UnlockValueSource] = [] + var entriesLeft = maxEntries + for raw in (raw["sources"] as? [Any] ?? []).prefix(maxSources) { + guard let raw = raw as? [String: Any] else { continue } + // An unrecognised kind is drawn as a file rather than dropped: a + // source the panel cannot name is still a source it must not hide. + let kind = UnlockValueSource.Kind(rawValue: (raw["kind"] as? String) ?? "") ?? .file + let entries = (raw["entries"] as? [Any] ?? []) + .compactMap { Self.entry($0) } + .prefix(entriesLeft) + let reported = (raw["itemCount"] as? NSNumber)?.intValue + let source = UnlockValueSource( + kind: kind, + path: UnlockDisplayInfo.trimmedNonEmpty(raw["path"], limit: maxPathLength), + entries: Array(entries), + reportedItemCount: (reported ?? 0) > 0 ? reported : nil + ) + guard source.isDrawable else { continue } + entriesLeft -= entries.count + sources.append(source) + if entriesLeft <= 0 { break } + } + + let count = (raw["valueCount"] as? NSNumber)?.intValue + return UnlockKeyDisplay( + valueCount: (count ?? 0) > 0 ? count : nil, + sources: sources, + vaultLabel: UnlockDisplayInfo.trimmedNonEmpty(raw["vaultLabel"], limit: maxVaultLabelLength), + vaultColor: hexColor(raw["vaultColor"]), + vaultId: UnlockDisplayInfo.trimmedNonEmpty(raw["vaultId"], limit: maxVaultLabelLength) + ) + } + + /// One entry inside a source. A blank name is dropped; a count that is not a + /// positive number is simply absent, which draws as a bare name. + static func entry(_ raw: Any?) -> UnlockValueSource.Entry? { + guard let raw = raw as? [String: Any] else { return nil } + guard let name = UnlockDisplayInfo.trimmedNonEmpty(raw["name"], limit: maxEntryNameLength) else { + return nil + } + let count = (raw["count"] as? NSNumber)?.intValue + return UnlockValueSource.Entry(name: name, count: (count ?? 0) > 0 ? count : nil) + } + + /// Only `#rrggbb` is accepted, so a colour cannot smuggle anything else onto + /// the panel. + static func hexColor(_ value: Any?) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + guard text.count == 7, text.hasPrefix("#") else { return nil } + let digits = text.dropFirst() + guard digits.allSatisfy({ $0.isHexDigit }) else { return nil } + return "#" + digits.lowercased() + } +} + +/// How varlock came to be running, as the client reported it. +/// +/// The daemon reads the command line off the kernel, which is the half worth +/// trusting, but a command line cannot say whether varlock was typed or +/// imported: an auto-load spawns the same CLI a person would. So the client says +/// which it was and the panel keeps the two apart, saying "auto-loaded inside" +/// rather than showing an internal command nobody typed. +public enum UnlockInvocationMode: String, Equatable { + case cli + case autoLoad = "auto-load" + case sdk + + /// Whether varlock is running inside something rather than as a command. + public var isHosted: Bool { self != .cli } + + public init?(wireValue: String?) { + guard let wireValue, let parsed = UnlockInvocationMode(rawValue: wireValue) else { return nil } + self = parsed + } +} + +/// Client-supplied decoration for an unlock panel. +/// +/// None of this is trusted. It only ever adds a line to the panel; it can never +/// change which keys are unlocked, which scopes are offered, or whether a prompt +/// happens at all. +public struct UnlockDisplayInfo: Equatable { + public let projectName: String? + public let projectPath: String? + /// key id -> how many encrypted items the client says that key covers + public let itemCounts: [String: Int] + /// key id -> what the client says that key covers, in detail + public let keys: [String: UnlockKeyDisplay] + /// How the client says varlock came to be running. + public let invocationMode: UnlockInvocationMode? + /// Which build of varlock the client says it is. + /// + /// A claim, like everything else here, and drawn as one. It exists for the + /// compiled binary, which carries no package the daemon can read a version + /// out of; where varlock is running as JavaScript the daemon resolves the + /// package itself and that answer wins. + public let varlockVersion: String? + + public init( + projectName: String? = nil, + projectPath: String? = nil, + itemCounts: [String: Int] = [:], + keys: [String: UnlockKeyDisplay] = [:], + invocationMode: UnlockInvocationMode? = nil, + varlockVersion: String? = nil + ) { + self.projectName = projectName + self.projectPath = projectPath + self.itemCounts = itemCounts + self.keys = keys + self.invocationMode = invocationMode + self.varlockVersion = varlockVersion + } + + public var isEmpty: Bool { + return projectName == nil && projectPath == nil && itemCounts.isEmpty && keys.isEmpty + && invocationMode == nil && varlockVersion == nil + } + + /// How many values a key covers, from either form the client sent. + public func valueCount(forKey keyId: String) -> Int? { + return keys[keyId]?.valueCount ?? itemCounts[keyId] + } + + /// Which vault a key sits in, defaulting to the one implicit local vault + /// every key is in until there are vaults to be in. + public func vaultId(forKey keyId: String) -> String { + return keys[keyId]?.vaultId ?? VaultBoundary.localVaultId + } + + /// Read the optional `display` object from an `unlock-session` payload. + /// Anything malformed is dropped rather than rejected: it is decoration. + public static func from(payload: [String: Any]?) -> UnlockDisplayInfo { + guard let display = payload?["display"] as? [String: Any] else { return UnlockDisplayInfo() } + var counts: [String: Int] = [:] + if let raw = display["itemCounts"] as? [String: Any] { + for (keyId, value) in raw { + guard let count = (value as? NSNumber)?.intValue, count > 0 else { continue } + counts[keyId] = count + } + } + var keys: [String: UnlockKeyDisplay] = [:] + if let raw = display["keys"] as? [String: Any] { + for (keyId, value) in raw { + guard let parsed = UnlockKeyDisplay.from(value) else { continue } + keys[keyId] = parsed + } + } + return UnlockDisplayInfo( + projectName: trimmedNonEmpty(display["projectName"]), + projectPath: trimmedNonEmpty(display["projectPath"]), + itemCounts: counts, + keys: keys, + invocationMode: UnlockInvocationMode(wireValue: display["invocationMode"] as? String), + varlockVersion: ExecutionChainBuilder.versionText(display["varlockVersion"]) + ) + } + + /// Cap on any single client-supplied string, so a long value cannot push the + /// derived lines off the panel. + static let maxLength = 120 + + static func trimmedNonEmpty(_ value: Any?, limit: Int = maxLength) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + // Collapse newlines so a multi-line value cannot fake extra panel lines. + let flattened = text.components(separatedBy: .newlines).joined(separator: " ") + return String(flattened.prefix(limit)) + } +} + +/// Builds the unlock panel's content from a plan. +public enum UnlockPanelContent { + /// The key every varlock install has, whose id is an implementation detail + /// nobody should have to read off a panel. + public static let defaultKeyId = "varlock-default" + /// What that key is called out loud. + public static let defaultKeyDisplayName = "local encryption" + + /// - Parameters: + /// - plan: what still needs asking. + /// - requester: who is asking, as the daemon read it off the peer process. + /// - display: client-supplied decoration. + /// - lockOn: the lock policy this unlock would run under, for the top bar. + /// - preselection: where the two controls open, and why. Worked out in + /// `UnlockDefaults`, which is the one place that decision is made. + public static func build( + plan: UnlockPlan, + requester: PanelRequester, + display: UnlockDisplayInfo = UnlockDisplayInfo(), + lockOn: SessionLockPolicy = .builtInDefault, + preselection: UnlockPreselection? = nil + ) -> PanelContent { + var details = requester.details + if let project = projectLine(display) { + details.append(.clientSupplied(project)) + } + let rows = plan.promptKeys.map { row(for: $0, display: display) } + + return PanelContent( + titleSegments: titleSegments(for: plan), + subtitle: projectSubtitle(display), + requester: PanelRequester( + summary: requester.summary, + details: details, + chain: requester.chain + ), + keyRows: rows, + notes: notes(for: plan), + factLine: factLine(plan: plan, lockOn: lockOn), + invocationMode: display.invocationMode, + sessionAdvisories: sessionAdvisories( + session: requester.chain?.agentSession, + projectPath: display.projectPath + ), + reportedVarlockVersion: display.varlockVersion, + scopes: plan.offeredScopes, + defaultScope: preselection?.window.scope ?? plan.defaultScope, + defaultDurationMs: preselection?.window.durationMs, + breadths: plan.offeredBreadths, + defaultBreadth: preselection?.breadth ?? plan.offeredBreadths.last ?? .wholeKey, + listedItemCount: plan.listedItemCount, + vaultCount: max(1, plan.vaultIds.count), + // The caveat is only worth a line where the choice it qualifies is + // actually on the panel. + hasUnlistableSource: plan.offersBreadthChoice + && (plan.hasUnlistableSource || rows.contains { row in row.sources.contains { !$0.isItemScopable } }), + selectionNote: preselection?.note, + confirmButtonTitle: "Unlock" + ) + } + + /// What one key's row says. + /// + /// The vault tag names the vault a key lives in. Without vaults there is only + /// the local one, and a tag repeating the row's own name would be noise, so + /// it is left off in that case. + public static func row(for key: RequestedKey, display: UnlockDisplayInfo) -> PanelKeyRow { + let supplied = display.keys[key.keyId] + let name = displayName(forKeyId: key.keyId) + let vaultLabel = supplied?.vaultLabel ?? defaultKeyDisplayName + return PanelKeyRow( + keyId: key.keyId, + displayName: name, + vaultLabel: vaultLabel == name ? nil : vaultLabel, + vaultColor: supplied?.vaultColor, + valueCount: key.itemCount ?? display.valueCount(forKey: key.keyId), + sources: supplied?.sources ?? [], + note: key.policy == .everyTime ? "asks every time" : nil + ) + } + + /// The line macOS puts in its own sheet, for a set of key rows. + public static func presenceReason(forRows rows: [PanelKeyRow]) -> String { + let names = rows.map { $0.spokenName } + switch names.count { + case 0: return "unlock your encrypted values" + case 1: return "unlock \(names[0])" + case 2: return "unlock \(names[0]) and \(names[1])" + default: return "unlock \(names.count) encryption keys" + } + } + + /// The same line for a bare list of keys, where there is no panel to take it + /// from. + public static func presenceReason(forKeyIds keyIds: [String], display: UnlockDisplayInfo) -> String { + return presenceReason(forRows: keyIds.map { row(for: RequestedKey(keyId: $0), display: display) }) + } + + /// What a key is called on the panel. + public static func displayName(forKeyId keyId: String) -> String { + return keyId == defaultKeyId ? defaultKeyDisplayName : keyId + } + + static func titleSegments(for plan: UnlockPlan) -> [PanelTextSegment] { + let names = plan.promptKeys.map { displayName(forKeyId: $0.keyId) } + let lead = plan.isDelta ? "Also unlock " : "Unlock " + switch names.count { + case 1: + return [.plain(lead), .code(names[0])] + case 2: + return [.plain(lead), .code(names[0]), .plain(" and "), .code(names[1])] + default: + return [.plain("\(lead)\(names.count) encryption keys")] + } + } + + /// The hero's second line: which project is asking, as the client named it. + static func projectSubtitle(_ display: UnlockDisplayInfo) -> String? { + if let name = display.projectName { return "for \(name)" } + guard let path = display.projectPath else { return nil } + let leaf = (path as NSString).lastPathComponent + return "for \(leaf.isEmpty ? path : leaf)" + } + + /// What is unusual about the agent session this request came from. + /// + /// Two things earn a line, and nothing else does: + /// + /// - NOBODY IS WATCHING. A headless or print-mode agent has no person in + /// front of it, and "approve for this session" then means approving for + /// something that will keep going unobserved. + /// - THE AGENT IS SOMEWHERE ELSE. An agent working in one project asking to + /// open another project's secrets is exactly the shape this panel exists + /// to make visible. Both halves are needed to say it, and both are weak + /// on their own: the session's directory is the agent's own record of + /// itself, and the project is what the client said. So it is worded as an + /// observation and never as an accusation, and it never blocks anything. + /// + /// Silence when either side is missing. "The agent did not say where it is" + /// is not evidence of anything. + public static func sessionAdvisories(session: AgentSession?, projectPath: String?) -> [String] { + guard let session else { return [] } + var advisories: [String] = [] + if let unattended = session.unattendedNote { advisories.append(unattended) } + if isWorkingOutside(session: session, projectPath: projectPath) { + advisories.append("this session is working in \(abbreviated(session.workingDirectory ?? "")), " + + "not in the project above") + } + return advisories + } + + /// The second of those two, on its own. + /// + /// Split out because the preselection rules need the FACT and not the + /// sentence. Reading it back out of the advisory text would tie what varlock + /// preselects to how a line of copy happens to be worded, and the next + /// person to improve that wording would silently turn a risk rule off. + public static func isWorkingOutside(session: AgentSession?, projectPath: String?) -> Bool { + guard let cwd = session?.workingDirectory, let projectPath else { return false } + return !pathIsInside(cwd, of: projectPath) + } + + /// Whether one path is the same directory as another or sits inside it. + /// + /// Compared on standardized, symlink-resolved paths, because the two sides + /// arrive by different routes: `/tmp/x` and `/private/tmp/x` are the same + /// directory, a worktree reached through a symlink is the same directory as + /// the one it links to, and a panel that cried anomaly over either would be + /// trained away inside a week. The comparison is on whole components, so + /// `/a/project-two` is not inside `/a/project`. + static func pathIsInside(_ path: String, of parent: String) -> Bool { + let child = canonical(path) + let root = canonical(parent) + guard !child.isEmpty, !root.isEmpty else { return false } + if child == root { return true } + return child.hasPrefix(root == "/" ? root : root + "/") + } + + /// The macOS firmlinks that make one directory reachable by two names. + /// + /// `resolvingSymlinksInPath` collapses these, but only for a path that + /// exists, and neither of the paths being compared here is guaranteed to + /// still be on disk by the time the panel draws. Stripping the prefix + /// outright makes the comparison the same either way. + static let privatePrefixes = ["/private/tmp", "/private/var", "/private/etc"] + + private static func canonical(_ path: String) -> String { + var resolved = NSString(string: NSString(string: path).expandingTildeInPath) + .resolvingSymlinksInPath + resolved = NSString(string: resolved).standardizingPath + for prefix in privatePrefixes where resolved == prefix || resolved.hasPrefix(prefix + "/") { + resolved = String(resolved.dropFirst("/private".count)) + break + } + // `standardizingPath` already drops a trailing slash, but a caller can + // hand us "/" and a root of "/" must not become "". + if resolved.count > 1, resolved.hasSuffix("/") { resolved.removeLast() } + return resolved + } + + /// A path with the home directory folded back to `~`, for a line of prose. + static func abbreviated(_ path: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty, path.hasPrefix(home) else { return path } + return "~" + path.dropFirst(home.count) + } + + static func notes(for plan: UnlockPlan) -> [String] { + var notes: [String] = [] + if plan.isDelta { + let already = plan.coveredKeys.count + notes.append(already == 1 + ? "This session already has 1 other key unlocked." + : "This session already has \(already) other keys unlocked.") + } + if plan.isStrictOnly { + notes.append("These keys are set to ask every time, so this unlock covers one read.") + } + return notes + } + + /// The standing fact in the top bar. + /// + /// Two things are always true of an unlock: it is recorded, and a session has + /// a limit. Whichever one the panel is not already implying is the one worth + /// saying, so an approval that cannot open a session talks about the record + /// instead of about session limits it will never reach. + static func factLine(plan: UnlockPlan, lockOn: SessionLockPolicy) -> String { + guard plan.offeredScopes.contains(.session) else { return "Recorded to the audit log" } + switch lockOn { + case .screenLock: return "Sessions end on screen lock \u{00B7} 12h max" + case .sleep: return "Sessions end on sleep \u{00B7} 12h max" + case .never: return "Sessions last 12h at most" + } + } + + static func projectLine(_ display: UnlockDisplayInfo) -> String? { + switch (display.projectName, display.projectPath) { + case (let name?, let path?): return "Project: \(name) (\(path))" + case (let name?, nil): return "Project: \(name)" + case (nil, let path?): return "Project: \(path)" + default: return nil + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelGlyph.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelGlyph.swift new file mode 100644 index 000000000..1d26693f3 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PanelGlyph.swift @@ -0,0 +1,96 @@ +import Foundation + +/// What the panel's Touch ID glyph is doing, and why. +/// +/// The glyph is the only part of the panel that moves, so what it does has to +/// mean something. It breathes while the sensor is actually armed, reacts when a +/// check ends without an answer, and confirms when one succeeds. Deciding that +/// here, from the flow's own state, keeps the mapping honest and testable: the +/// AppKit side only turns an effect into animation, and cannot invent a state the +/// flow is not in. +/// +/// The rule the mapping exists to enforce: the glyph never animates as though it +/// were waiting for a finger unless a presence check is genuinely running. A +/// panel that looked armed while nothing was listening is the exact bug this +/// feature spent several rounds chasing. + +/// What is happening, in glyph terms. +public enum PanelGlyphState: Equatable { + /// Nothing is armed. Includes the button-driven modes, and the moment after a + /// check ends, since the sensor is not listening again until asked. + case idle + /// A presence check is running: the sensor is live right now. + case armed + /// A check just ended without an answer. + case failed + /// Approved by a presence check. + case approved +} + +/// What the view should do about it. +public enum PanelGlyphEffect: Equatable { + /// Static glyph, resting colour. + case still + /// Gentle breathing, for a sensor that is genuinely listening. + case pulse + /// A short horizontal shake, then back to static. Deliberately not back to + /// pulsing: after a failed check nothing is armed until the user asks again, + /// and a pulse would promise a sensor that is not listening. + case shakeThenStill + /// Green, with a small pop, before the panel closes. + case successPop + /// Reduce-motion forms. Same meaning, carried by colour alone. + case armedStill + case failedStill + case successStill + + /// Whether this effect involves movement, which is what reduce-motion drops. + public var isAnimated: Bool { + switch self { + case .pulse, .shakeThenStill, .successPop: return true + case .still, .armedStill, .failedStill, .successStill: return false + } + } +} + +public enum PanelGlyph { + /// The effect for a glyph state, honouring the system's reduce-motion setting. + /// + /// Reduce motion does not mean "show nothing different": the states still have + /// to be distinguishable, so each keeps a colour and emphasis of its own and + /// only the movement is dropped. + public static func effect(for state: PanelGlyphState, reduceMotion: Bool) -> PanelGlyphEffect { + switch state { + case .idle: + return .still + case .armed: + return reduceMotion ? .armedStill : .pulse + case .failed: + return reduceMotion ? .failedStill : .shakeThenStill + case .approved: + return reduceMotion ? .successStill : .successPop + } + } +} + +public extension ApprovalFlow { + /// What the glyph should be showing for this flow, right now. + /// + /// Only the embedded mode ever draws a glyph, and only that mode arms anything + /// without a button, so every other mode reports `idle` and the glyph sits + /// still. + var glyphState: PanelGlyphState { + if case .finished(let decision) = state { + return decision.approved && presenceMode != .none ? .approved : .idle + } + guard presenceMode == .embedded else { return .idle } + switch state { + case .scanning: + return .armed + case .awaitingInput: + return failedScans > 0 ? .failed : .idle + case .finished: + return .idle + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PeerPosturePolicy.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PeerPosturePolicy.swift new file mode 100644 index 000000000..9e3178d11 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/PeerPosturePolicy.swift @@ -0,0 +1,209 @@ +import Foundation +import SessionScoping + +/// What the daemon does about a peer that fails a posture check. +/// +/// Deliberately two-valued. A check that is off entirely is a check nobody +/// notices has stopped working, so the weaker setting still says something on +/// stderr every time it fires. +public enum PostureSeverity: Equatable { + /// Refuse the connection. + case reject + /// Serve the connection, and say on stderr that it should not have been + /// necessary. + case warn +} + +/// A posture check that a peer did not pass. +public enum PeerPostureViolation: Equatable { + /// A debugger or tracer is attached to the peer right now. + case debuggerAttached + /// The peer is not running with Hardened Runtime, so nothing stops a + /// debugger or an injected library from attaching to it later. + case hardenedRuntimeMissing + /// The kernel would not say, so neither of the above could be checked. + case postureUnreadable + + /// Stable code, so a client can branch without matching message text. + public var code: String { + switch self { + case .debuggerAttached: return "PEER_DEBUGGER_ATTACHED" + case .hardenedRuntimeMissing: return "PEER_HARDENED_RUNTIME_MISSING" + case .postureUnreadable: return "PEER_POSTURE_UNREADABLE" + } + } + + /// What the client is told. Short, and it names the fix. + public var clientMessage: String { + switch self { + case .debuggerAttached: + return "Refusing to serve a process that is being debugged or traced; detach the debugger and try again" + case .hardenedRuntimeMissing: + return "Refusing to serve a process that is not running with the Hardened Runtime; " + + "use an official varlock, node, or bun build" + case .postureUnreadable: + return "Could not read the calling process's code-signing status, so it was refused" + } + } + + /// One line per check, so which one fired is obvious in the daemon's log. + public func stderrLine(pid: pid_t, path: String, severity: PostureSeverity) -> String { + let verb = severity == .reject ? "rejected" : "allowed (posture warning only)" + switch self { + case .debuggerAttached: + return "varlock: \(verb) IPC connection: the calling process is being debugged or traced " + + "(pid=\(pid), path=\(path))\n" + case .hardenedRuntimeMissing: + return "varlock: \(verb) IPC connection: the calling process is not running with the " + + "Hardened Runtime (pid=\(pid), path=\(path))\n" + case .postureUnreadable: + return "varlock: \(verb) IPC connection: the calling process's code-signing status could " + + "not be read (pid=\(pid), path=\(path))\n" + } + } +} + +/// How hard each posture check bites. +/// +/// Two knobs rather than one switch, because the two checks are not equally safe +/// to enforce. See `resolve` for what each build actually gets. +public struct PeerPostureRequirements: Equatable { + public let debugger: PostureSeverity + /// Also governs `postureUnreadable`: both mean "cannot show this process is + /// hard to get into", one because it is not, one because nobody could tell. + public let hardenedRuntime: PostureSeverity + + public init(debugger: PostureSeverity, hardenedRuntime: PostureSeverity) { + self.debugger = debugger + self.hardenedRuntime = hardenedRuntime + } + + /// Everything rejects. What `sessions.peerPosture: "strict"` asks for. + public static let strict = PeerPostureRequirements(debugger: .reject, hardenedRuntime: .reject) + + /// Nothing rejects, everything is reported. + public static let warnOnly = PeerPostureRequirements(debugger: .warn, hardenedRuntime: .warn) + + /// The default for a signed release daemon. + /// + /// A traced peer is rejected: there is no legitimate reason for a process to + /// be under a debugger while asking this daemon for secrets, and the check has + /// no false positives to speak of. + /// + /// A peer without Hardened Runtime is reported and served. That check is + /// correct in principle and unshippable as a rejection today, because the + /// processes that legitimately connect are frequently not hardened: the + /// standalone `varlock` binary is ad-hoc signed by `bun build --compile`, and + /// Homebrew's node and bun are ad-hoc signed too. Rejecting would lock those + /// users out of their own secrets to close a hole that only matters once + /// somebody already has code running as them. It flips to `.reject` here once + /// the release pipeline signs the CLI with `--options runtime`; anyone whose + /// clients are all hardened can have that today with + /// `sessions.peerPosture: "strict"`. + public static let signedRelease = PeerPostureRequirements(debugger: .reject, hardenedRuntime: .warn) + + /// The default for a development daemon, which is any daemon whose own binary + /// is not running hardened. + /// + /// A daemon that is not hardened itself is in no position to demand it of + /// anyone, and this is also the shape of a working tree: `swift build` output + /// is ad-hoc signed, and it is normally being driven by processes a developer + /// may well have a debugger on. So the checks run and report, and nothing is + /// refused. This is the same allowance the peer binary-name check already + /// makes for `node` and `bun`, kept in one place. + public static let development = warnOnly +} + +/// The answer for one peer. +public struct PeerPostureOutcome: Equatable { + /// The check that refused the connection, if any. First one wins. + public let rejection: PeerPostureViolation? + /// Checks that failed but were configured only to report. + public let warnings: [PeerPostureViolation] + + public var isAllowed: Bool { return rejection == nil } + + public static let clean = PeerPostureOutcome(rejection: nil, warnings: []) +} + +public enum PeerPostureEvaluator { + /// Key path into the machine config file: + /// `{ "sessions": { "peerPosture": "strict" } }` + public static let configSectionKey = "sessions" + public static let configFieldKey = "peerPosture" + + /// Wire values for `sessions.peerPosture`. + public static let configValues = ["default", "strict", "warn"] + + /// Judge one peer. + /// + /// The debugger check is answered first: when a debugger is attached, that is + /// the interesting fact, and saying "no Hardened Runtime" instead would send + /// the reader after the wrong thing. + public static func evaluate( + facts: PeerPostureFacts, + requirements: PeerPostureRequirements + ) -> PeerPostureOutcome { + var failed: [(PeerPostureViolation, PostureSeverity)] = [] + + if !facts.isReadable { + failed.append((.postureUnreadable, requirements.hardenedRuntime)) + } else { + if facts.isTraced { + failed.append((.debuggerAttached, requirements.debugger)) + } + if !facts.hasHardenedRuntime { + failed.append((.hardenedRuntimeMissing, requirements.hardenedRuntime)) + } + } + + let rejection = failed.first { $0.1 == .reject }?.0 + let warnings = failed.filter { $0.1 == .warn }.map(\.0) + return PeerPostureOutcome(rejection: rejection, warnings: warnings) + } + + /// What this daemon demands of its peers. + /// + /// The starting point is the daemon's own hardening, not a build flag in its + /// Info.plist: a plist can be edited by anyone who can reach the bundle, while + /// the code-signing status word cannot be. The config file may then move it, + /// in either direction. + public static func resolve( + selfFacts: PeerPostureFacts, + machineConfigData: Data?, + warn: (String) -> Void = { message in fputs("varlock: \(message)\n", stderr) } + ) -> PeerPostureRequirements { + let base: PeerPostureRequirements = selfFacts.hasHardenedRuntime ? .signedRelease : .development + + guard let raw = configValue(fromConfigData: machineConfigData, warn: warn) else { return base } + switch raw { + case "strict": return .strict + case "warn": return .warnOnly + case "default": return base + default: + warn( + "ignoring invalid config \(configSectionKey).\(configFieldKey) value \"\(raw)\"; expected one of " + + configValues.map { "\"\($0)\"" }.joined(separator: ", ") + ) + return base + } + } + + private static func configValue( + fromConfigData data: Data?, + warn: (String) -> Void + ) -> String? { + guard let data, !data.isEmpty else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + warn("could not parse the varlock config file; ignoring it for peer posture settings") + return nil + } + guard let sessions = json[configSectionKey] as? [String: Any] else { return nil } + guard let value = sessions[configFieldKey] else { return nil } + guard let string = value as? String else { + warn("ignoring non-string config \(configSectionKey).\(configFieldKey) value") + return nil + } + return string + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionGrants.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionGrants.swift new file mode 100644 index 000000000..267c112e6 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionGrants.swift @@ -0,0 +1,562 @@ +import Foundation + +/// Grant bookkeeping for identity-backed sessions. +/// +/// A grant is what makes the daemon's holding of an identity key legitimate. It is +/// keyed by (sessionId x keyId): the same session unlocking a different key is a +/// separate grant, and the same key in a different session is too. `sessionId` comes +/// from `SessionScoping`, so a grant cannot be borrowed by an unrelated session on +/// the same machine. +/// +/// This type is pure bookkeeping with an injected clock, so the lifetime rules are +/// unit testable without an enclave. Key material lives in `IdentitySessionStore` +/// on the daemon side, which drives its erase decisions off what this table reports. + +public enum SessionGrantScope: String, CaseIterable { + /// a single decrypt call, then the grant is spent + case once + /// until the session it is bound to ends, or the cap is hit + case session + /// a caller-chosen window, still bounded by the cap + case duration + + public init?(wireValue: String?) { + guard let wireValue else { return nil } + self.init(rawValue: wireValue) + } +} + +/// Identifies one grant. +public struct SessionGrantRef: Hashable { + public let sessionId: String + public let keyId: String + + public init(sessionId: String, keyId: String) { + self.sessionId = sessionId + self.keyId = keyId + } +} + +/// A grant as the daemon reports it back. Never includes key material. +public struct SessionGrantInfo: Equatable { + public let sessionId: String + public let keyId: String + public let identityId: String + public let scope: SessionGrantScope + /// epoch ms + public let grantedAt: Int64 + /// epoch ms; always set, since every scope is capped. Display only: what + /// actually ends the grant is `remainingMs`, which the table measures on the + /// monotonic clock as well as this one. + public let expiresAt: Int64 + /// ms of life left, as the table measured it when it built this record. + /// + /// Not derived from `expiresAt` by the reader: the wall clock can be moved, + /// and this number cannot be. + public let remainingMs: Int64 + /// epoch ms of the last decrypt this grant served, nil until first use + public let lastUsedAt: Int64? + /// epoch ms when the session this grant belongs to was unlocked + public let sessionUnlockedAt: Int64 + /// epoch ms when the session's hard cap runs out + public let sessionExpiresAt: Int64 + /// ms left on the session's hard cap, measured the same way as `remainingMs`. + /// Never shorter than `remainingMs`, since every grant is clamped to the cap. + public let sessionRemainingMs: Int64 + /// which system events erase this session, as resolved at unlock time + public let lockOn: SessionLockPolicy + /// how many decrypts this grant has served + public let useCount: Int + /// how much of the key this grant opens + public let breadth: SessionGrantBreadth + /// how many distinct ciphertexts an item-scoped grant currently covers. + /// nil for a whole-key grant, which covers a number nobody can count. + public let coveredItemCount: Int? + /// the vault this grant was approved over, which a broad approval may not + /// reach outside of + public let vaultId: String + + /// Defaulted, so every caller that predates the breadth axis reads as what + /// it has always been: an approval over the whole key. + public init( + sessionId: String, + keyId: String, + identityId: String, + scope: SessionGrantScope, + grantedAt: Int64, + expiresAt: Int64, + remainingMs: Int64, + lastUsedAt: Int64? = nil, + sessionUnlockedAt: Int64, + sessionExpiresAt: Int64, + sessionRemainingMs: Int64, + lockOn: SessionLockPolicy, + useCount: Int, + breadth: SessionGrantBreadth = .wholeKey, + coveredItemCount: Int? = nil, + vaultId: String = VaultBoundary.localVaultId + ) { + self.sessionId = sessionId + self.keyId = keyId + self.identityId = identityId + self.scope = scope + self.grantedAt = grantedAt + self.expiresAt = expiresAt + self.remainingMs = remainingMs + self.lastUsedAt = lastUsedAt + self.sessionUnlockedAt = sessionUnlockedAt + self.sessionExpiresAt = sessionExpiresAt + self.sessionRemainingMs = sessionRemainingMs + self.lockOn = lockOn + self.useCount = useCount + self.breadth = breadth + self.coveredItemCount = coveredItemCount + self.vaultId = vaultId + } + + public func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "sessionId": sessionId, + "keyId": keyId, + "identityId": identityId, + "scope": scope.rawValue, + "grantedAt": grantedAt, + "expiresAt": expiresAt, + "sessionUnlockedAt": sessionUnlockedAt, + "sessionExpiresAt": sessionExpiresAt, + "sessionExpiresInMs": sessionRemainingMs, + "lockOn": lockOn.rawValue, + "useCount": useCount, + "expiresInMs": remainingMs, + "breadth": breadth.rawValue, + "vaultId": vaultId, + ] + if let lastUsedAt { + dict["lastUsedAt"] = lastUsedAt + } + if let coveredItemCount { + dict["coveredItemCount"] = coveredItemCount + } + return dict + } +} + +public enum SessionGrantError: LocalizedError { + case noGrant(SessionGrantRef) + case expired(SessionGrantRef) + /// The grant is live, but this batch carries a ciphertext it was not + /// approved over. Not a failure: the caller is expected to go and ask. + case itemNotCovered(SessionGrantRef) + + public var errorDescription: String? { + switch self { + case .noGrant(let ref): + return "No unlock session for key \"\(ref.keyId)\"; run an unlock first" + case .expired(let ref): + return "The unlock session for key \"\(ref.keyId)\" has expired; unlock again" + case .itemNotCovered(let ref): + return "The unlock session for key \"\(ref.keyId)\" covers only the values it was approved over; " + + "this request includes others, so it needs approving again" + } + } + + /// Stable code the TS client can branch on without matching message text. + public var code: String { + switch self { + case .noGrant: return "NO_SESSION_GRANT" + case .expired: return "SESSION_GRANT_EXPIRED" + case .itemNotCovered: return "GRANT_ITEM_NOT_COVERED" + } + } +} + +/// What changed after a mutation, so the caller knows when to crypto-erase. +public struct SessionGrantChange { + /// how many grants were dropped + public let dropped: Int + /// sessions that no longer hold any live grant, and whose key should be erased + public let closedSessions: [String] +} + +public final class SessionGrantTable { + /// Hard ceiling on any grant, whatever scope or duration was asked for. + /// A `session` grant on a session that never ends still expires here. + public static let maxGrantMs: Int64 = 12 * 60 * 60 * 1000 + + private struct Grant { + let identityId: String + let scope: SessionGrantScope + let grantedAt: Int64 + var deadline: GrantDeadline + var lastUsedAt: Int64? + var useCount: Int + /// The ciphertexts this grant may open, by SHA-256 digest, or nil when + /// it opens anything the key can. + /// + /// Digests only. The grant never holds a ciphertext, a value name, or + /// anything else a client sent: it holds the one thing the daemon + /// computed for itself, which is what makes membership in this set an + /// answer rather than a claim. + var coveredItems: Set? + /// The vault this was approved over. Held on the grant rather than + /// looked up per request, because the answer that matters is the one + /// the user was shown, not whatever a caller says now. + let vaultId: String + + var breadth: SessionGrantBreadth { coveredItems == nil ? .wholeKey : .listedItems } + } + + private struct SessionState { + let unlockedAt: Int64 + /// unlockedAt + cap on both clocks; every grant in the session is clamped + /// to this + let deadline: GrantDeadline + /// Which system events erase this session. Held per session rather than + /// globally, so one session can outlive a screen lock that ends another. + var lockOn: SessionLockPolicy + var grants: [String: Grant] = [:] // keyed by keyId + } + + private var sessions: [String: SessionState] = [:] + private let clock: () -> Int64 + private let monotonicClock: () -> Int64 + + /// - Parameters: + /// - clock: epoch milliseconds. Injected so tests can move time. + /// - monotonicClock: `MonotonicClock` milliseconds, injected for the same + /// reason. Tests drive the two independently, which is the only way to + /// check that moving the settable clock cannot buy a grant more life. + public init( + clock: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }, + monotonicClock: @escaping () -> Int64 = { MonotonicClock.nowMs() } + ) { + self.clock = clock + self.monotonicClock = monotonicClock + } + + public func nowMs() -> Int64 { clock() } + + // MARK: - Session lifetime + + /// When the given session was unlocked, if it is still live. + public func sessionUnlockedAt(_ sessionId: String) -> Int64? { + pruneExpired() + return sessions[sessionId]?.unlockedAt + } + + /// Whether the session still holds at least one live grant, meaning the daemon + /// is still holding its session-wrapped identity key. + public func isSessionLive(_ sessionId: String) -> Bool { + pruneExpired() + guard let state = sessions[sessionId] else { return false } + return !state.grants.isEmpty + } + + /// Whether any session is live. The daemon refuses to idle-quit while this holds. + public func hasLiveSessions() -> Bool { + pruneExpired() + return sessions.values.contains { !$0.grants.isEmpty } + } + + public func liveSessionIds() -> [String] { + pruneExpired() + return sessions.filter { !$0.value.grants.isEmpty }.keys.sorted() + } + + /// The live grant for one (session x key), if there is one. + /// + /// Read-only, and it charges nothing. This is what the unlock planner reads to + /// tell a first unlock from an add-on to a session that is already open. + public func liveGrant(ref: SessionGrantRef) -> SessionGrantInfo? { + pruneExpired() + guard let state = sessions[ref.sessionId], let grant = state.grants[ref.keyId] else { return nil } + return info(ref: ref, grant: grant, session: state) + } + + /// What an item-scoped grant currently covers, or nil when it covers the + /// whole key. Read by the unlock planner, which is how a batch carrying a + /// ciphertext nobody approved becomes a panel rather than a refusal. + public func coveredItems(ref: SessionGrantRef) -> Set? { + pruneExpired() + return sessions[ref.sessionId]?.grants[ref.keyId]?.coveredItems + } + + /// The vault a live grant was approved over, if there is one. + public func vaultId(ref: SessionGrantRef) -> String? { + pruneExpired() + return sessions[ref.sessionId]?.grants[ref.keyId]?.vaultId + } + + // MARK: - Granting + + /// Record a grant, opening the session if this is its first one. + /// + /// The session's cap starts at its first unlock, so a caller cannot extend its + /// hold past 12h by re-granting the same key over and over. + /// + /// - Parameter coveredItems: the ciphertext digests this grant may open, or + /// nil for the whole key. An empty set is not the same as nil: it is a + /// grant that opens nothing yet, which is what an item-scoped approval + /// over a batch the client described badly should degrade to. + @discardableResult + public func grant( + ref: SessionGrantRef, + identityId: String, + scope: SessionGrantScope, + durationMs: Int64? = nil, + lockOn: SessionLockPolicy = .builtInDefault, + coveredItems: Set? = nil, + vaultId: String = VaultBoundary.localVaultId + ) -> SessionGrantInfo { + pruneExpired() + let now = clock() + let monotonicNow = monotonicClock() + + var state: SessionState + if var existing = sessions[ref.sessionId] { + // The most recent unlock sets the session's lock policy, so re-unlocking + // is how someone changes their mind about it. + existing.lockOn = lockOn + sessions[ref.sessionId] = existing + state = existing + } else { + state = SessionState( + unlockedAt: now, + deadline: GrantDeadline.after(Self.maxGrantMs, wallNow: now, monotonicNow: monotonicNow), + lockOn: lockOn + ) + sessions[ref.sessionId] = state + } + + let requestedDeadline: GrantDeadline + switch scope { + case .once, .session: + requestedDeadline = state.deadline + case .duration: + let window = max(0, min(durationMs ?? Self.maxGrantMs, Self.maxGrantMs)) + requestedDeadline = GrantDeadline.after(window, wallNow: now, monotonicNow: monotonicNow) + } + + let grant = Grant( + identityId: identityId, + scope: scope, + grantedAt: now, + // never past the session cap, whatever was asked for, on either clock + deadline: GrantDeadline.earliest(requestedDeadline, state.deadline), + lastUsedAt: nil, + useCount: 0, + coveredItems: coveredItems, + vaultId: vaultId + ) + sessions[ref.sessionId]?.grants[ref.keyId] = grant + return info(ref: ref, grant: grant, session: sessions[ref.sessionId]!) + } + + // MARK: - Using + + /// Check a grant and charge one use against it. + /// + /// A `once` grant is spent here: it serves exactly one `decrypt-v2` call, however + /// many payloads that call carries, and is then dropped. + /// + /// - Parameters: + /// - itemDigests: the digests of the ciphertexts this batch carries, as + /// the daemon computed them. An item-scoped grant refuses the WHOLE + /// batch if any of them is outside what it was approved over, and is + /// not charged for the attempt: the caller's next move is to ask, and + /// spending a `once` grant on a refusal would cost it the answer. + /// - alsoCovered: digests the grant covers for a structural reason rather + /// than because they were listed. This is where the value cache lives: + /// see `admitsUnlistedItems` on the source kind. Consulted only when the + /// listed set has already said no, so it costs nothing in the normal case. + public func consume( + ref: SessionGrantRef, + itemDigests: [String] = [], + alsoCovered: () -> Set = { [] } + ) throws -> (info: SessionGrantInfo, change: SessionGrantChange) { + // Deliberately no prune first: an expired grant should still be found here + // so the caller is told the session ran out, not that it never existed. + let now = clock() + let monotonicNow = monotonicClock() + + guard let state = sessions[ref.sessionId], var grant = state.grants[ref.keyId] else { + throw SessionGrantError.noGrant(ref) + } + guard !grant.deadline.isExpired(wallNow: now, monotonicNow: monotonicNow), + !state.deadline.isExpired(wallNow: now, monotonicNow: monotonicNow) else { + // Drop it here rather than leaving a dead row for the next prune to find. + drop(ref: ref) + throw SessionGrantError.expired(ref) + } + + if var covered = grant.coveredItems { + let unlisted = itemDigests.filter { !covered.contains($0) } + if !unlisted.isEmpty { + let structural = alsoCovered() + guard unlisted.allSatisfy({ structural.contains($0) }) else { + // Nothing is charged and nothing is dropped. The grant is + // still good for what it covers; this batch simply is not it. + throw SessionGrantError.itemNotCovered(ref) + } + // Remembered, so the same entry read again later is an O(1) hit + // and stays readable even once the store behind it has moved on. + covered.formUnion(unlisted) + grant.coveredItems = covered + } + } + + grant.useCount += 1 + grant.lastUsedAt = now + sessions[ref.sessionId]?.grants[ref.keyId] = grant + let served = info(ref: ref, grant: grant, session: state) + + if grant.scope == .once { + return (served, drop(ref: ref)) + } + return (served, SessionGrantChange(dropped: 0, closedSessions: [])) + } + + // MARK: - Listing + + /// Every live grant, oldest session first, stable within a session by key id. + public func list() -> [SessionGrantInfo] { + pruneExpired() + var out: [SessionGrantInfo] = [] + for (sessionId, state) in sessions { + for (keyId, grant) in state.grants { + out.append(info(ref: SessionGrantRef(sessionId: sessionId, keyId: keyId), grant: grant, session: state)) + } + } + return out.sorted { + if $0.sessionUnlockedAt != $1.sessionUnlockedAt { return $0.sessionUnlockedAt < $1.sessionUnlockedAt } + if $0.sessionId != $1.sessionId { return $0.sessionId < $1.sessionId } + return $0.keyId < $1.keyId + } + } + + // MARK: - Invalidating + + /// Drop grants. + /// + /// Omitting both arguments drops every grant, which is what the argument-less + /// `invalidate-session` has always done. Naming a session drops that session's + /// grants; naming both drops exactly one. + @discardableResult + public func invalidate(sessionId: String? = nil, keyId: String? = nil) -> SessionGrantChange { + var dropped = 0 + var closed: [String] = [] + + for (sid, state) in sessions where sessionId == nil || sessionId == sid { + var remaining = state.grants + for kid in state.grants.keys where keyId == nil || keyId == kid { + remaining.removeValue(forKey: kid) + dropped += 1 + } + if remaining.isEmpty { + sessions.removeValue(forKey: sid) + closed.append(sid) + } else { + sessions[sid]?.grants = remaining + } + } + + return SessionGrantChange(dropped: dropped, closedSessions: closed.sorted()) + } + + /// Drop the sessions whose own lock policy says this event ends them. + /// + /// Each session is judged individually, so a `screenLock` session can be erased + /// by the same event that a `none` session in the same daemon shrugs off. + @discardableResult + public func invalidate(onLockEvent event: SessionLockEvent) -> SessionGrantChange { + var dropped = 0 + var closed: [String] = [] + + for (sid, state) in sessions where state.lockOn.erases(on: event) { + dropped += state.grants.count + sessions.removeValue(forKey: sid) + closed.append(sid) + } + + return SessionGrantChange(dropped: dropped, closedSessions: closed.sorted()) + } + + /// The lock policy a live session is running under. + public func lockPolicy(forSession sessionId: String) -> SessionLockPolicy? { + pruneExpired() + return sessions[sessionId]?.lockOn + } + + /// Drop everything whose time is up, and report the sessions that closed. + @discardableResult + public func pruneExpired() -> SessionGrantChange { + let now = clock() + let monotonicNow = monotonicClock() + var dropped = 0 + var closed: [String] = [] + + for (sid, state) in sessions { + if state.deadline.isExpired(wallNow: now, monotonicNow: monotonicNow) { + dropped += state.grants.count + sessions.removeValue(forKey: sid) + closed.append(sid) + continue + } + var remaining = state.grants + for (kid, grant) in state.grants + where grant.deadline.isExpired(wallNow: now, monotonicNow: monotonicNow) { + remaining.removeValue(forKey: kid) + dropped += 1 + } + if remaining.count != state.grants.count { + if remaining.isEmpty { + sessions.removeValue(forKey: sid) + closed.append(sid) + } else { + sessions[sid]?.grants = remaining + } + } + } + + return SessionGrantChange(dropped: dropped, closedSessions: closed.sorted()) + } + + // MARK: - Private + + @discardableResult + private func drop(ref: SessionGrantRef) -> SessionGrantChange { + guard var state = sessions[ref.sessionId], state.grants[ref.keyId] != nil else { + return SessionGrantChange(dropped: 0, closedSessions: []) + } + state.grants.removeValue(forKey: ref.keyId) + if state.grants.isEmpty { + sessions.removeValue(forKey: ref.sessionId) + return SessionGrantChange(dropped: 1, closedSessions: [ref.sessionId]) + } + sessions[ref.sessionId] = state + return SessionGrantChange(dropped: 1, closedSessions: []) + } + + private func info(ref: SessionGrantRef, grant: Grant, session: SessionState) -> SessionGrantInfo { + let now = clock() + let monotonicNow = monotonicClock() + return SessionGrantInfo( + sessionId: ref.sessionId, + keyId: ref.keyId, + identityId: grant.identityId, + scope: grant.scope, + grantedAt: grant.grantedAt, + expiresAt: grant.deadline.wall, + remainingMs: grant.deadline.remainingMs(wallNow: now, monotonicNow: monotonicNow), + lastUsedAt: grant.lastUsedAt, + sessionUnlockedAt: session.unlockedAt, + sessionExpiresAt: session.deadline.wall, + sessionRemainingMs: session.deadline.remainingMs(wallNow: now, monotonicNow: monotonicNow), + lockOn: session.lockOn, + useCount: grant.useCount, + breadth: grant.breadth, + coveredItemCount: grant.coveredItems?.count, + vaultId: grant.vaultId + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionLockPolicy.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionLockPolicy.swift new file mode 100644 index 000000000..0a4c36082 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionLockPolicy.swift @@ -0,0 +1,122 @@ +import Foundation + +/// What ends an unlock session, short of its TTL running out. +/// +/// The hard cap and explicit invalidation are not covered here: those always apply. +/// This only decides which system events erase a session's key material. +public enum SessionLockPolicy: String, CaseIterable { + /// Erase on screen lock and on sleep. + case screenLock + /// Erase on sleep only. Sessions survive the screen locking. + case sleep + /// Erase only on TTL expiry, the 12h cap, or an explicit lock. + /// + /// Spelled `never` in Swift, `"none"` on the wire: a case literally named `none` + /// collides with `Optional.none` at every optional comparison. + case never = "none" + + /// Used when neither the session nor the machine config says otherwise. + public static let builtInDefault: SessionLockPolicy = .sleep + + public init?(wireValue: String?) { + guard let wireValue else { return nil } + self.init(rawValue: wireValue) + } + + /// Every value a caller may send, for error messages. + public static var wireValues: [String] { + return allCases.map(\.rawValue) + } + + public func erases(on event: SessionLockEvent) -> Bool { + switch self { + case .screenLock: return true + case .sleep: return event == .sleep + case .never: return false + } + } +} + +/// A system event that may end sessions, depending on their policy. +public enum SessionLockEvent: String { + /// The machine is going to sleep. + case sleep + /// The screen locked, the display slept, or the login session resigned active. + case screenLock +} + +/// Resolving the effective lock policy for one unlock. +/// +/// Order is: what this unlock asked for, then the machine config, then the built-in +/// default. Anything unparseable is reported and skipped rather than failing the +/// unlock, so a typo in a config file cannot lock someone out of their own secrets. +public enum LockPolicyResolution { + /// Where the effective policy came from, for diagnostics. + public enum Source: String { + case sessionOverride = "session-override" + case machineConfig = "machine-config" + case builtInDefault = "built-in-default" + } + + public struct Resolved { + public let policy: SessionLockPolicy + public let source: Source + } + + /// Key path into the machine config file: `{ "sessions": { "lockOn": "sleep" } }` + public static let configSectionKey = "sessions" + public static let configFieldKey = "lockOn" + + public static func resolve( + overrideWireValue: String?, + machineConfigData: Data?, + warn: (String) -> Void = { message in fputs("varlock: \(message)\n", stderr) } + ) -> Resolved { + if let overrideWireValue, !overrideWireValue.isEmpty { + if let policy = SessionLockPolicy(wireValue: overrideWireValue) { + return Resolved(policy: policy, source: .sessionOverride) + } + warn(invalidValueMessage(overrideWireValue, origin: "unlock-session lockOn")) + } + + if let policy = machineLockPolicy(fromConfigData: machineConfigData, warn: warn) { + return Resolved(policy: policy, source: .machineConfig) + } + + return Resolved(policy: .builtInDefault, source: .builtInDefault) + } + + /// Read `sessions.lockOn` out of the user-level config file's contents. + /// + /// A missing file, a missing section, or a missing field all mean "not + /// configured", silently. Only a value that is present and wrong is worth + /// saying something about. + public static func machineLockPolicy( + fromConfigData data: Data?, + warn: (String) -> Void = { message in fputs("varlock: \(message)\n", stderr) } + ) -> SessionLockPolicy? { + guard let data, !data.isEmpty else { return nil } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + warn("could not parse the varlock config file; ignoring it for session lock settings") + return nil + } + guard let sessions = json[configSectionKey] as? [String: Any] else { return nil } + guard let raw = sessions[configFieldKey] else { return nil } + + guard let rawString = raw as? String else { + warn(invalidValueMessage(String(describing: raw), origin: "config \(configSectionKey).\(configFieldKey)")) + return nil + } + guard let policy = SessionLockPolicy(wireValue: rawString) else { + warn(invalidValueMessage(rawString, origin: "config \(configSectionKey).\(configFieldKey)")) + return nil + } + return policy + } + + private static func invalidValueMessage(_ value: String, origin: String) -> String { + return "ignoring invalid \(origin) value \"\(value)\"; expected one of " + + SessionLockPolicy.wireValues.map { "\"\($0)\"" }.joined(separator: ", ") + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionMenuModel.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionMenuModel.swift new file mode 100644 index 000000000..ef670bbbf --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/SessionMenuModel.swift @@ -0,0 +1,117 @@ +import Foundation +import SessionScoping + +/// What the menu bar shows about the sessions the daemon is holding. +/// +/// Every string the menu draws is decided here, with no AppKit anywhere, so the +/// wording and grouping are unit tested and `StatusBarMenu` is left with nothing +/// but turning rows into `NSMenuItem`s. +/// +/// Times are coarse on purpose. The menu is rebuilt when it opens, not on a +/// timer, so a live countdown would be a lie the moment it was drawn; "9h left" +/// stays true for an hour. +public struct SessionMenuModel: Equatable { + + /// One granted key inside a session. + public struct KeyRow: Equatable { + public let keyId: String + public let scopeLabel: String + public let remainingLabel: String + + /// "varlock-default: this session, 9h left" + public var title: String { + return "\(keyId): \(scopeLabel), \(remainingLabel)" + } + } + + /// One unlocked session. + public struct SessionRow: Equatable { + public let sessionId: String + /// "Terminal ttys004" + public let title: String + public let keys: [KeyRow] + /// "12h limit: 9h left" + public let capLine: String + /// "Locks on sleep" + public let lockLine: String + } + + public let rows: [SessionRow] + + public var sessionCount: Int { return rows.count } + public var isEmpty: Bool { return rows.isEmpty } + + /// Group live grants into one row per session, keeping the order the grant + /// table produced (oldest session first, keys sorted within a session). + public static func build(from grants: [SessionGrantInfo]) -> SessionMenuModel { + var order: [String] = [] + var bySession: [String: [SessionGrantInfo]] = [:] + for grant in grants { + if bySession[grant.sessionId] == nil { + order.append(grant.sessionId) + bySession[grant.sessionId] = [] + } + bySession[grant.sessionId]?.append(grant) + } + + let rows: [SessionRow] = order.compactMap { sessionId in + guard let sessionGrants = bySession[sessionId], let first = sessionGrants.first else { return nil } + return SessionRow( + sessionId: sessionId, + title: SessionLabel.describe(sessionId: sessionId), + keys: sessionGrants.map { + KeyRow( + keyId: $0.keyId, + scopeLabel: scopeLabel($0.scope), + remainingLabel: coarseRemaining($0.remainingMs) + ) + }, + capLine: "\(capHours)h limit: \(coarseRemaining(first.sessionRemainingMs))", + lockLine: lockLine(first.lockOn) + ) + } + return SessionMenuModel(rows: rows) + } + + // MARK: - Wording + + private static var capHours: Int64 { return SessionGrantTable.maxGrantMs / (60 * 60 * 1000) } + + public static func scopeLabel(_ scope: SessionGrantScope) -> String { + switch scope { + case .once: return "single use" + case .session: return "this session" + case .duration: return "timed" + } + } + + /// Rounded down, so the menu never claims more time than there is. + public static func coarseRemaining(_ milliseconds: Int64) -> String { + guard milliseconds > 0 else { return "expired" } + let minutes = milliseconds / 60_000 + if minutes >= 60 { + return "\(minutes / 60)h left" + } + if minutes >= 1 { + return "\(minutes)m left" + } + return "under a minute left" + } + + public static func lockLine(_ policy: SessionLockPolicy) -> String { + switch policy { + case .screenLock: return "Locks on screen lock" + case .sleep: return "Locks on sleep" + case .never: return "Stays unlocked until it expires" + } + } + + /// The label the "lock sessions on" setting uses for each choice. + public static func lockPolicyMenuLabel(_ policy: SessionLockPolicy) -> String { + switch policy { + case .screenLock: return "Screen lock" + case .sleep: return "Sleep" + case .never: return "Only manually" + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockDecision.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockDecision.swift new file mode 100644 index 000000000..c429c3dfb --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockDecision.swift @@ -0,0 +1,335 @@ +import Foundation + +/// What the daemon decides BEFORE any panel is drawn or any biometric runs. +/// +/// Everything here is pure: given the keys a caller asked for, the grants that +/// already exist, and each key's auth policy, it works out whether the user has +/// to be asked at all, which keys the question is about, and which scopes may be +/// offered. Keeping it separate from the AppKit view is what lets the rules be +/// tested with no window server and no enclave. + +/// How often a key must be re-authorized. +public enum KeyAuthPolicy: String, Equatable { + /// The normal case: one approval can cover a session or a chosen window. + case standard + /// Strict: this key never receives a lasting grant. Every batch asks again. + case everyTime = "every-time" + + public init(wireValue: String?) { + guard let wireValue, let parsed = KeyAuthPolicy(rawValue: wireValue) else { + self = .standard + return + } + self = parsed + } +} + +/// One key in an unlock request, with the decoration the client sent for it. +public struct RequestedKey: Equatable { + public let keyId: String + public let policy: KeyAuthPolicy + /// How many encrypted items the client says this key covers. Client-supplied + /// decoration: it changes what the panel says, never what the daemon allows. + public let itemCount: Int? + /// The ciphertexts this key is being asked to open, by digest, as the daemon + /// computed them from the payloads the client handed over. + /// + /// This is the only part of a request that can narrow a grant, and it is the + /// only part of it the daemon worked out for itself. Everything else about a + /// key (its name, its file, how many values the client claims) is + /// decoration; this is the enforcement set. + public let itemDigests: Set + /// Whether this key has a source that item scope does not reach: today, + /// varlock's value cache. See `SessionGrantBreadth`, and the line the panel + /// draws about it. + public let hasUnlistableSource: Bool + /// The vault this key lives in, which is the line a broad approval may not + /// cross. `VaultBoundary.localVaultId` until there are vaults. + public let vaultId: String + + public init( + keyId: String, + policy: KeyAuthPolicy = .standard, + itemCount: Int? = nil, + itemDigests: Set = [], + hasUnlistableSource: Bool = false, + vaultId: String = VaultBoundary.localVaultId + ) { + self.keyId = keyId + self.policy = policy + self.itemCount = itemCount + self.itemDigests = itemDigests + self.hasUnlistableSource = hasUnlistableSource + self.vaultId = vaultId + } +} + +/// The part of a live grant that matters when deciding whether to ask again. +public struct ExistingGrantSnapshot: Equatable { + public let scope: SessionGrantScope + /// ms of life the grant has left, as the grant table measured it. + /// + /// A remaining window rather than an expiry instant, so the planner never has + /// to pick a clock. The table already reconciles the wall and monotonic + /// deadlines; this is the answer it arrived at. + public let remainingMs: Int64 + /// What an item-scoped grant covers, or nil when it covers the whole key. + /// + /// A grant that is long enough but narrow is still not enough for a batch + /// carrying something it never covered, so this sits beside the window + /// rather than behind it: the two are independent, and coverage means both. + public let coveredItems: Set? + /// The vault this grant was approved over. A key that now reports a + /// different one is not covered by it, however broad or long it is. + public let vaultId: String? + + public init( + scope: SessionGrantScope, + remainingMs: Int64, + coveredItems: Set? = nil, + vaultId: String? = VaultBoundary.localVaultId + ) { + self.scope = scope + self.remainingMs = remainingMs + self.coveredItems = coveredItems + self.vaultId = vaultId + } +} + +/// The full picture of what an unlock request needs. +public struct UnlockPlan: Equatable { + /// Keys with no live grant at all. + public let newKeys: [RequestedKey] + /// Keys that hold a grant which does not cover this request (strict keys, or + /// a request for a longer scope than the live grant carries). + public let refreshKeys: [RequestedKey] + /// Keys whose live grant already covers this request. Nothing to ask about. + public let coveredKeys: [RequestedKey] + /// Which scopes the panel may offer, given the strictest key in the batch. + public let offeredScopes: [SessionGrantScope] + /// Which scope the panel starts on. + public let defaultScope: SessionGrantScope + /// Which breadths the panel may offer. + /// + /// The narrow one is only there when EVERY key in the question brought + /// items with it. A batch where one key's ciphertexts arrived and another's + /// did not cannot honestly offer "only these values": the second key's grant + /// would open nothing, and the panel would have promised a narrowing that + /// was really an outage. + public let offeredBreadths: [SessionGrantBreadth] + + /// The keys the user is actually being asked about, in a stable order. + public var promptKeys: [RequestedKey] { newKeys + refreshKeys } + + /// Keys in the question that must be re-approved every single time. + public var strictPromptKeys: [RequestedKey] { promptKeys.filter { $0.policy == .everyTime } } + + /// Keys in the question that can take a lasting grant. + public var standardPromptKeys: [RequestedKey] { promptKeys.filter { $0.policy == .standard } } + + /// Whether the user has to be asked at all. + public var requiresPrompt: Bool { !promptKeys.isEmpty } + + /// Whether this is an add-on to a session that already holds other keys, which + /// the panel says differently ("also unlock ..."). + public var isDelta: Bool { !coveredKeys.isEmpty && requiresPrompt } + + /// Whether every key in the question asks every time, so no lasting scope is + /// on offer. + public var isStrictOnly: Bool { requiresPrompt && standardPromptKeys.isEmpty } + + /// Whether the panel has a breadth choice to draw at all. + public var offersBreadthChoice: Bool { offeredBreadths.count > 1 } + + /// How many distinct ciphertexts a narrow approval would cover, across every + /// key in the question. + /// + /// Counted from the digests the daemon computed, so unlike the client's own + /// value count this number is one varlock can stand behind: it is exactly + /// how many payloads the grant will open. + public var listedItemCount: Int { + return promptKeys.reduce(into: Set()) { $0.formUnion($1.itemDigests) }.count + } + + /// Whether anything in the question has a source item scope does not reach. + public var hasUnlistableSource: Bool { promptKeys.contains { $0.hasUnlistableSource } } + + /// The vaults this approval would be over, in the order their keys appear. + /// + /// What the checkbox's label counts, and the set a broad approval is bounded + /// by. One entry today, since every key is in the local vault. + public var vaultIds: [String] { + var seen = Set() + return promptKeys.compactMap { seen.insert($0.vaultId).inserted ? $0.vaultId : nil } + } +} + +/// The timed rungs the ladder names for you. +/// +/// Two of them, not four. Four clock rungs spent most of the control's width on +/// the options people reach for least, and the ones they did reach for were +/// rarely the number they actually had in mind. So the presets cover the two +/// shapes a timed approval usually has, and everything else is typed on the +/// `Custom` rung beside them. +/// +/// Ten minutes is deliberate, and it is at the short end on purpose: a window +/// here is a guard around a task, not a convenience that lasts the afternoon. +/// The 12h cap is still the ceiling, but it is now something you have to name +/// rather than something the row hands you. +public enum DurationPreset: Int64, CaseIterable { + case tenMinutes = 600_000 + case oneHour = 3_600_000 + + /// The long form, for prose: the summary sentence reads "for 10 minutes". + public var label: String { DurationText.prose(rawValue) } + + /// The form the ladder uses, where the timed rungs sit in one row beside + /// `Once`, `Custom` and `This session`. + /// + /// Written exactly the way a custom value is, so a row reading + /// `Once 10min 1hr 45min This session` is one scale rather than a set of + /// labels in two dialects. + public var shortLabel: String { DurationText.short(rawValue) } + + public var milliseconds: Int64 { rawValue } + + public static let `default`: DurationPreset = .oneHour + + /// The preset a duration in milliseconds names, when it names one. + public static func matching(milliseconds: Int64?) -> DurationPreset? { + guard let milliseconds else { return nil } + return DurationPreset(rawValue: milliseconds) + } +} + +public enum UnlockPlanner { + /// Scopes offered when at least one key in the batch can take a lasting grant. + public static let fullScopes: [SessionGrantScope] = [.session, .once, .duration] + + /// Work out what an unlock request still needs. + /// + /// - Parameters: + /// - requested: the keys the caller named, already filtered to ones this + /// identity can actually be opened with. + /// - requestedScope: the scope the caller asked for, used to judge whether a + /// live grant is already strong enough. + /// - requestedDurationMs: only meaningful when `requestedScope` is `duration`. + /// - existing: live grants for this session, keyed by key id. + public static func plan( + requested: [RequestedKey], + requestedScope: SessionGrantScope, + requestedDurationMs: Int64? = nil, + existing: [String: ExistingGrantSnapshot] + ) -> UnlockPlan { + var newKeys: [RequestedKey] = [] + var refreshKeys: [RequestedKey] = [] + var coveredKeys: [RequestedKey] = [] + + for key in requested { + guard let live = existing[key.keyId] else { + newKeys.append(key) + continue + } + // A key that asks every time is never covered by what it was granted + // last time. That is the whole point of the policy. + if key.policy == .everyTime { + refreshKeys.append(key) + continue + } + if covers( + live: live, + requestedScope: requestedScope, + requestedDurationMs: requestedDurationMs, + requestedItems: key.itemDigests, + requestedVaultId: key.vaultId + ) { + coveredKeys.append(key) + } else { + refreshKeys.append(key) + } + } + + let promptKeys = newKeys + refreshKeys + let anyStandard = promptKeys.contains { $0.policy == .standard } + let offered: [SessionGrantScope] = anyStandard ? fullScopes : [.once] + let canNarrow = !promptKeys.isEmpty && promptKeys.allSatisfy { !$0.itemDigests.isEmpty } + + return UnlockPlan( + newKeys: newKeys, + refreshKeys: refreshKeys, + coveredKeys: coveredKeys, + offeredScopes: offered, + defaultScope: offered.contains(.session) ? .session : .once, + offeredBreadths: canNarrow ? [.listedItems, .wholeKey] : [.wholeKey] + ) + } + + /// Whether a live grant is already at least as strong as what was asked for. + /// + /// The rules are deliberately blunt, so the answer never depends on clock + /// drift or on comparing two windows that were measured from different + /// starting points: + /// + /// - a `session` grant covers anything, since it is the longest thing on offer + /// - a `duration` grant covers a `once` request, and covers another `duration` + /// request only if the window already granted reaches past the new one + /// - a `once` grant covers only another `once` request + /// + /// Anything else counts as an upgrade and is worth asking about. + /// + /// Breadth is checked first and separately. A grant can be long enough and + /// still not cover this request, because the two axes are independent: a + /// session-long approval over three values does not become an approval over + /// a fourth just by having time left on it. A batch carrying something + /// outside the covered set is an upgrade, and upgrades are what the panel is + /// for, so it goes back through the same delta prompt a brand-new key takes. + static func covers( + live: ExistingGrantSnapshot, + requestedScope: SessionGrantScope, + requestedDurationMs: Int64?, + requestedItems: Set = [], + requestedVaultId: String = VaultBoundary.localVaultId + ) -> Bool { + guard live.remainingMs > 0 else { return false } + // The vault first, because it is the one bound that no answer on the + // panel can lift. A key that has moved vault since it was approved was + // never approved, and the breadth control has nothing to say about it. + guard VaultBoundary.covers(approvedVaultId: live.vaultId, requestedVaultId: requestedVaultId) else { + return false + } + if let coveredItems = live.coveredItems, !requestedItems.isSubset(of: coveredItems) { + return false + } + switch live.scope { + case .session: + return true + case .duration: + switch requestedScope { + case .once: return true + case .duration: + let window = min(requestedDurationMs ?? SessionGrantTable.maxGrantMs, SessionGrantTable.maxGrantMs) + return live.remainingMs >= window + case .session: return false + } + case .once: + return requestedScope == .once + } + } + + /// The scope a single key actually receives once the user has chosen one. + /// + /// A key that asks every time only ever gets `once`, whatever the panel was + /// set to for the rest of the batch. + public static func effectiveScope(chosen: SessionGrantScope, policy: KeyAuthPolicy) -> SessionGrantScope { + return policy == .everyTime ? .once : chosen + } + + /// The duration a single key actually receives, in the same spirit. + public static func effectiveDurationMs( + chosen: SessionGrantScope, + chosenDurationMs: Int64?, + policy: KeyAuthPolicy + ) -> Int64? { + return effectiveScope(chosen: chosen, policy: policy) == .duration ? chosenDurationMs : nil + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreferences.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreferences.swift new file mode 100644 index 000000000..62a60ab88 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreferences.swift @@ -0,0 +1,203 @@ +import Foundation + +/// What this Mac remembers about how you answer an unlock panel. +/// +/// It remembers ONE thing: that you tightened a request. That is the whole +/// feature, and the reason it is safe. +/// +/// The default is already broad (the whole key, for this session), so a broad +/// answer is not worth writing down: replaying it would change nothing. A narrow +/// answer is worth writing down, because otherwise the panel springs back to the +/// broad default next time and quietly undoes a decision somebody made on +/// purpose. Since a memory can only ever move the preselection inwards, a stale +/// or mismatched one costs an extra panel and nothing else, which is why this +/// needs no invalidation matrix over requesters, session roots and postures: it +/// has no failure mode worth defending against. +/// +/// Choosing the broad default again is how you forget. That is not a +/// convenience, it is the design: an answer that cannot be taken back is an +/// answer people stop giving. +/// +/// It lives under the user's varlock directory, never in a project file. What a +/// machine will hand over is the machine's business, and a preference committed +/// to a repository is a preference anybody who can open a pull request gets to +/// set. + +/// One remembered narrowing, keyed by project and key id. +public struct UnlockNarrowing: Equatable { + /// Set only when the user chose the narrow breadth. Never `wholeKey`. + public var breadth: SessionGrantBreadth? + /// Set only when the user chose something shorter than a session. + public var window: GrantWindow? + /// Whether this project and key have ever been approved on this Mac. + /// + /// Kept here because it is the same fact about the same pair, and because + /// its absence is the safe direction: a first sighting reads as elevated + /// risk and preselects something narrower. + public var approvedBefore: Bool + /// epoch ms, so a person reading the file can tell when this was decided + public var savedAt: Int64 + + public init( + breadth: SessionGrantBreadth? = nil, + window: GrantWindow? = nil, + approvedBefore: Bool = false, + savedAt: Int64 = 0 + ) { + self.breadth = breadth + self.window = window + self.approvedBefore = approvedBefore + self.savedAt = savedAt + } + + /// Whether there is anything here worth keeping a row for. + public var isEmpty: Bool { breadth == nil && window == nil && !approvedBefore } +} + +/// Reading and writing the file, with no file system in sight. +/// +/// Everything is a pure function of `Data`, so the rules can be tested without a +/// home directory and the daemon side is a read, a call, and a write. +public enum UnlockPreferences { + /// Under the user varlock dir, beside the identities and the audit log. + public static let fileName = "unlock-preferences.json" + public static let fileVersion = 1 + + /// How a row is addressed: the project it was decided in, and the key. + /// + /// The project is the client's own `projectPath`, which is a claim like + /// everything else it sends. It cannot be used to widen anything (the only + /// thing a row can do is narrow), so the worst a wrong one can do is fail to + /// find a narrowing that exists, which costs a broader preselection than the + /// user last chose. That is the one direction worth being careful about, so + /// a request with no project path is not remembered at all rather than + /// sharing one nameless bucket across every project on the machine. + public static func rowKey(projectPath: String?, keyId: String) -> String? { + guard let projectPath, !projectPath.isEmpty else { return nil } + return "\(projectPath)\u{0000}\(keyId)" + } + + // MARK: - Codec + + public static func decode(_ data: Data?) -> [String: UnlockNarrowing] { + guard let data, !data.isEmpty else { return [:] } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } + guard (json["version"] as? NSNumber)?.intValue == fileVersion else { return [:] } + guard let rows = json["projects"] as? [String: Any] else { return [:] } + + var out: [String: UnlockNarrowing] = [:] + for (key, raw) in rows { + guard let raw = raw as? [String: Any] else { continue } + let scope = SessionGrantScope(wireValue: raw["scope"] as? String) + let window = scope.map { + GrantWindow(scope: $0, durationMs: (raw["durationMs"] as? NSNumber)?.int64Value) + } + let entry = UnlockNarrowing( + // Only a narrowing is ever honoured, whatever the file says. A + // hand-edited "key" here must not be able to widen a panel. + breadth: SessionGrantBreadth(wireValue: raw["breadth"] as? String) == .listedItems + ? .listedItems + : nil, + window: window.flatMap { $0.scope == .session ? nil : $0 }, + approvedBefore: (raw["approvedBefore"] as? NSNumber)?.boolValue ?? false, + savedAt: (raw["savedAt"] as? NSNumber)?.int64Value ?? 0 + ) + if !entry.isEmpty { out[key] = entry } + } + return out + } + + public static func encode(_ rows: [String: UnlockNarrowing]) -> Data? { + var projects: [String: Any] = [:] + for (key, entry) in rows where !entry.isEmpty { + var row: [String: Any] = ["approvedBefore": entry.approvedBefore, "savedAt": entry.savedAt] + if let breadth = entry.breadth { row["breadth"] = breadth.rawValue } + if let window = entry.window { + row["scope"] = window.scope.rawValue + if let durationMs = window.durationMs { row["durationMs"] = durationMs } + } + projects[key] = row + } + let json: [String: Any] = ["version": fileVersion, "projects": projects] + return try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + } + + // MARK: - Rules + + /// What to keep after an approval, given what the user actually chose. + /// + /// Only the axes they tightened are written down. An axis they left at the + /// default clears whatever was remembered for it, which is what makes + /// choosing the default the way to forget. + /// + /// A nil `breadth` is a third thing, and it is not "the default". It means + /// the user was never asked, which happens under `once`: the panel draws no + /// breadth control there because the scope already implies the narrow + /// answer. A duration choice must not be able to write down a breadth + /// opinion the person never held, in either direction, so nil leaves that + /// axis exactly as it was: a narrowing chosen earlier survives, and an + /// absent one is not invented. + public static func remembering( + existing: UnlockNarrowing?, + breadth: SessionGrantBreadth?, + window: GrantWindow, + now: Int64 + ) -> UnlockNarrowing { + let rememberedBreadth: SessionGrantBreadth? + switch breadth { + case .none: rememberedBreadth = existing?.breadth + case .some(UnlockDefaults.breadth): rememberedBreadth = nil + case .some(let chosen): rememberedBreadth = chosen + } + return UnlockNarrowing( + breadth: rememberedBreadth, + window: window.scope == UnlockDefaults.window.scope ? nil : window, + approvedBefore: true, + savedAt: now + ) + } + + /// Fold one approval into the whole file. + public static func apply( + rows: [String: UnlockNarrowing], + rowKey: String?, + breadth: SessionGrantBreadth?, + window: GrantWindow, + now: Int64 + ) -> [String: UnlockNarrowing] { + guard let rowKey else { return rows } + var next = rows + let entry = remembering(existing: rows[rowKey], breadth: breadth, window: window, now: now) + if entry.isEmpty { + next.removeValue(forKey: rowKey) + } else { + next[rowKey] = entry + } + return next + } + + /// Drop rows. No arguments forgets everything; a project forgets that + /// project; both forget one key in one project. + public static func forget( + rows: [String: UnlockNarrowing], + projectPath: String? = nil, + keyId: String? = nil + ) -> (rows: [String: UnlockNarrowing], forgotten: Int) { + guard projectPath != nil || keyId != nil else { return ([:], rows.count) } + var next: [String: UnlockNarrowing] = [:] + var forgotten = 0 + for (key, entry) in rows { + let parts = key.components(separatedBy: "\u{0000}") + let rowProject = parts.first ?? "" + let rowKeyId = parts.count > 1 ? parts[1] : "" + let projectMatches = projectPath == nil || projectPath == rowProject + let keyMatches = keyId == nil || keyId == rowKeyId + if projectMatches && keyMatches { + forgotten += 1 + } else { + next[key] = entry + } + } + return (next, forgotten) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreselection.swift b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreselection.swift new file mode 100644 index 000000000..23bc58e01 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/IdentitySessions/UnlockPreselection.swift @@ -0,0 +1,242 @@ +import Foundation +import SessionScoping + +/// Which answer the panel starts on. +/// +/// Three inputs decide it, and they are combined by ONE rule: take the +/// narrowest. Nothing here can ever move the preselection outwards, which is +/// what makes the whole mechanism safe to be wrong about. A stale memory, a +/// misread signal, or a risk rule that fires when it should not all cost the +/// user one extra panel; none of them can hand anything away. +/// +/// 1. the built-in default, which is broad: the whole key, for this session +/// 2. the risk the request itself carries, worked out below +/// 3. a narrowing the user chose here before +/// +/// The rules live in this one function on purpose. Spread across the panel and +/// the manager as conditionals they would be unreviewable, and "what does +/// varlock preselect and why" is a question a person should be able to answer by +/// reading one screen of code. +/// +/// FUTURE WORK: a default set on a vault, or on a single value, is one more +/// input to the same rule. It joins the list `preselect` takes the narrowest of +/// and needs nothing else: no precedence to invent, and no way for it to widen +/// anything. Deliberately not built yet, and deliberately not given a +/// configuration surface, so the shape of that surface stays an open question. + +/// What the daemon noticed about this request, as facts rather than as verdicts. +/// +/// Every field is derived: the chain comes off the kernel, the agent session off +/// the agent's own record of itself, and `seenBefore` off this Mac's own history +/// of approvals. The client's `projectPath` is the one claim in the mix, and it +/// can only make the answer narrower (a wrong path reads as "somewhere else"). +public struct UnlockRiskSignals: Equatable { + /// The request came from inside a coding-agent session. + public var hasAgentSession: Bool + /// That session told us nobody is watching it (headless, print mode). + public var nobodyWatching: Bool + /// The session is working in a different tree from the project being opened. + public var sessionOutsideProject: Bool + /// The code that decides what happens is a script an interpreter was handed, + /// and it is not varlock itself. varlock's own JavaScript is excluded on + /// purpose: that is how most installs run, so treating it as an anomaly + /// would make the anomaly the norm and teach people to click past it. + public var actorIsForeignScript: Bool + /// The kernel was asked about the actor and reported no valid signature. + /// + /// Only that answer. A process the kernel would not discuss at all is left + /// out on purpose: "unchecked" is the absence of a verdict rather than a bad + /// one, and treating it as one would narrow half the panels on machines + /// where the status word is simply unreadable. + public var actorCodeUnverified: Bool + /// This project and key have been approved on this Mac before. + public var seenBefore: Bool + + public init( + hasAgentSession: Bool = false, + nobodyWatching: Bool = false, + sessionOutsideProject: Bool = false, + actorIsForeignScript: Bool = false, + actorCodeUnverified: Bool = false, + seenBefore: Bool = false + ) { + self.hasAgentSession = hasAgentSession + self.nobodyWatching = nobodyWatching + self.sessionOutsideProject = sessionOutsideProject + self.actorIsForeignScript = actorIsForeignScript + self.actorCodeUnverified = actorCodeUnverified + self.seenBefore = seenBefore + } + + /// Read the signals off what the daemon already worked out for the panel. + public static func read( + chain: ExecutionChain?, + projectPath: String?, + seenBefore: Bool + ) -> UnlockRiskSignals { + let session = chain?.agentSession + let actor = chain?.hops.first { $0.isImportant } + return UnlockRiskSignals( + hasAgentSession: session != nil, + nobodyWatching: session?.unattendedNote != nil, + sessionOutsideProject: UnlockPanelContent.isWorkingOutside( + session: session, + projectPath: projectPath + ), + actorIsForeignScript: actor.map { $0.posture == .interpretedScript && !$0.isVarlock } ?? false, + actorCodeUnverified: actor.map { $0.posture == .unsigned } ?? false, + seenBefore: seenBefore + ) + } +} + +/// How ordinary this request looks. +public enum UnlockRisk: String, Equatable { + /// A person, in their own project, opening a key they have opened before. + case routine + /// Nothing is wrong, but something here is worth being deliberate about. + case elevated + /// Something about this request is the shape the panel exists to catch. + case unusual +} + +/// The answer the panel opens on, and why. +public struct UnlockPreselection: Equatable { + public let breadth: SessionGrantBreadth + public let window: GrantWindow + public let risk: UnlockRisk + /// Whether a narrowing the user chose before is part of this answer. + public let isRemembered: Bool + /// The one thing that made this narrower than the default, worded for the + /// panel. nil when nothing did. + public let note: String? + + public init( + breadth: SessionGrantBreadth, + window: GrantWindow, + risk: UnlockRisk, + isRemembered: Bool = false, + note: String? = nil + ) { + self.breadth = breadth + self.window = window + self.risk = risk + self.isRemembered = isRemembered + self.note = note + } +} + +public enum UnlockDefaults { + /// What every approval starts from before anything narrows it. + public static let breadth = SessionGrantBreadth.builtInDefault + public static let window = GrantWindow.builtInDefault + + /// How unusual this request is, as one readable ladder. + /// + /// UNUSUAL is reserved for the three things that change what approving + /// means rather than merely colouring it: + /// + /// - nobody is watching. "For this session" then means approving for a + /// program that will keep going with no person in front of it. + /// - the session is somewhere else. An agent working in one tree asking + /// for another tree's secrets is the exact shape this panel exists for. + /// - the actor's code is unverified. The kernel was asked and had nothing + /// good to say, which is different from not having been asked. + /// + /// ELEVATED is for the things that are normal but not nothing: an agent is + /// involved at all, somebody else's script is driving varlock, or this is + /// the first time this Mac has been asked for this key in this project. + /// First contact belongs here rather than in `unusual`: it is not a warning + /// sign, it is simply a decision that has not been made before. + /// + /// Everything else is ROUTINE, which is most of what actually happens. + public static func risk(_ signals: UnlockRiskSignals) -> UnlockRisk { + if signals.nobodyWatching || signals.sessionOutsideProject || signals.actorCodeUnverified { + return .unusual + } + if signals.hasAgentSession || signals.actorIsForeignScript || !signals.seenBefore { + return .elevated + } + return .routine + } + + /// What the risk alone would preselect. + static func selection(for risk: UnlockRisk) -> (SessionGrantBreadth, GrantWindow) { + switch risk { + case .routine: return (.wholeKey, GrantWindow(scope: .session)) + case .elevated: return (.listedItems, GrantWindow(scope: .session)) + case .unusual: return (.listedItems, GrantWindow(scope: .once)) + } + } + + /// The one sentence that says what made this narrower than usual. + static func note(for risk: UnlockRisk, signals: UnlockRiskSignals) -> String? { + switch risk { + case .routine: + return nil + case .unusual: + if signals.nobodyWatching { return "Narrowed: no person is watching this session." } + if signals.sessionOutsideProject { return "Narrowed: this session is working outside the project." } + return "Narrowed: the code asking has not been verified." + case .elevated: + if !signals.seenBefore { return "Narrowed: this key has not been approved here before." } + if signals.actorIsForeignScript { return "Narrowed: a script is driving varlock." } + return "Narrowed: this request came from an agent session." + } + } + + /// Where the panel opens: the narrowest of the default, the risk, and what + /// was remembered, clamped to what this batch can actually offer. + /// + /// - Parameters: + /// - signals: what the daemon noticed about the request. + /// - remembered: a narrowing the user chose here before, if any. Only ever + /// narrowings: a broad choice is the default and is never written down. + /// - offeredBreadths: `listed` is missing when the batch has no items to + /// narrow to, and a preselection of something that is not on the panel + /// would be a lie about what is about to happen. + /// - offeredScopes: `session` is missing when a key asks every time. + public static func preselect( + signals: UnlockRiskSignals, + remembered: UnlockNarrowing? = nil, + offeredBreadths: [SessionGrantBreadth], + offeredScopes: [SessionGrantScope] + ) -> UnlockPreselection { + let level = risk(signals) + let (riskBreadth, riskWindow) = selection(for: level) + + var breadth = SessionGrantBreadth.narrowest([Self.breadth, riskBreadth, remembered?.breadth]) + var window = GrantWindow.narrowest([Self.window, riskWindow, remembered?.window]) + + // Never preselect an answer the panel does not offer. + if !offeredBreadths.contains(breadth) { + breadth = offeredBreadths.first ?? .wholeKey + } + if !offeredScopes.contains(window.scope) { + window = GrantWindow(scope: offeredScopes.contains(.once) ? .once : (offeredScopes.first ?? .once)) + } + + // Remembered only counts as applied when it is actually the thing that + // narrowed something. A memory the risk rules had already overtaken is + // not what the user is looking at, so saying so would be noise. + let rememberedApplied = remembered.map { memory in + (memory.breadth == breadth && riskBreadth != breadth) + || (memory.window == window && riskWindow != window) + } ?? false + + return UnlockPreselection( + breadth: breadth, + window: window, + risk: level, + isRemembered: rememberedApplied, + note: rememberedApplied ? rememberedNote : note(for: level, signals: signals) + ) + } + + /// What the panel says when a narrowing came from the user's own last answer. + /// + /// Said out loud rather than quietly preselected: a panel that is tighter + /// than the one you saw last week, with nothing explaining why, is a panel + /// people learn to distrust. + public static let rememberedNote = "Remembered from the last time you approved this here." +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/AgentSessionMetadata.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/AgentSessionMetadata.swift new file mode 100644 index 000000000..496c717f9 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/AgentSessionMetadata.swift @@ -0,0 +1,191 @@ +import Foundation +import Darwin + +/// What a coding agent recorded about the session a request came from. +/// +/// The panel wants to say "Claude Code, 'vault panel redesign', started 2:14 PM", +/// because that is a sentence a person can check against the window they were +/// just looking at. A uuid is not, so none is ever shown; the raw id stays in the +/// audit log where a machine reads it. +/// +/// The agent writes this itself, so reading it is a same-user file read of a file +/// that is already on disk. It is display only: a missing, stale, or unparseable +/// file costs the row its title and nothing else. + +/// The coding agents the panel knows how to name. +public enum AgentProduct: String, Equatable, CaseIterable { + case claudeCode + case codex + + public var displayName: String { + switch self { + case .claudeCode: return "Claude Code" + case .codex: return "Codex" + } + } +} + +public struct AgentSessionMetadata: Equatable { + /// The session's own human name. + public let title: String? + /// Whether the agent generated that name itself rather than the user typing + /// it. A derived name is still worth showing (it tells two sessions apart) + /// but it is not somebody's words, and the panel must not dress it as one. + public let isTitleDerived: Bool + /// When the agent says the session began, in seconds since the epoch. + public let startTime: Int? + /// What kind of session this is: "interactive" when a person is sitting in + /// front of it, something else when nobody is. nil when the agent did not + /// say, which is not the same as "nobody is watching" and is never drawn as + /// though it were. + public let kind: String? + /// The directory the session is working in. Cross-checked against the + /// project whose values are being unlocked, and shown as evidence. + public let workingDirectory: String? + /// How the agent was started ("claude-desktop", "cli"). Expanded detail only. + public let entrypoint: String? + /// The agent's own version. Expanded detail only. + public let version: String? + + public init( + title: String?, + isTitleDerived: Bool = false, + startTime: Int?, + kind: String? = nil, + workingDirectory: String? = nil, + entrypoint: String? = nil, + version: String? = nil + ) { + self.title = title + self.isTitleDerived = isTitleDerived + self.startTime = startTime + self.kind = kind + self.workingDirectory = workingDirectory + self.entrypoint = entrypoint + self.version = version + } +} + +/// Looks up a session's own record of itself. Behind a protocol so the chain can +/// be built in tests without a home directory full of agent state. +public protocol AgentSessionMetadataReader { + /// - Parameters: + /// - product: which agent this is. + /// - pid: the process the daemon identified as the session's root. + /// - processStartTime: when the kernel says that process started, used to + /// reject a record left behind by a different process with the same pid. + func metadata(for product: AgentProduct, pid: pid_t, processStartTime: Int) -> AgentSessionMetadata? +} + +/// Reads the agents' own on-disk session records. +public struct LiveAgentSessionMetadataReader: AgentSessionMetadataReader { + /// A record bigger than this is not the small json file we are looking for, + /// and is not worth reading to find out. + static let maxFileBytes = 64 * 1024 + + /// How far the recorded start may be from the kernel's view of the process + /// start before the record is treated as belonging to a different process. + /// Pids are reused, and a stale file would name somebody else's session. + static let startTimeToleranceSeconds = 300 + + private let homeDirectory: String + + public init(homeDirectory: String = NSHomeDirectory()) { + self.homeDirectory = homeDirectory + } + + public func metadata( + for product: AgentProduct, + pid: pid_t, + processStartTime: Int + ) -> AgentSessionMetadata? { + switch product { + case .claudeCode: + return claudeCodeMetadata(pid: pid, processStartTime: processStartTime) + case .codex: + // Codex keys its rollout files by timestamp and working directory, not + // by pid, so tying one to a live process would mean picking the most + // recent file that looks close enough. A guess is worse than no title + // here: naming the wrong session is exactly the mistake this panel + // exists to prevent. Detection and the start time still work. + return nil + } + } + + /// Claude Code writes `~/.claude/sessions/.json` for each live session, + /// carrying the session's name and when it started. + private func claudeCodeMetadata(pid: pid_t, processStartTime: Int) -> AgentSessionMetadata? { + let path = "\(homeDirectory)/.claude/sessions/\(pid).json" + guard let record = readJsonObject(atPath: path) else { return nil } + + // The record has to be about the process we are looking at. Both checks + // are cheap and both matter: pids are reused, and a session that ended + // may leave its file behind. + if let recordedPid = (record["pid"] as? NSNumber)?.int32Value, recordedPid != pid { + return nil + } + let startedAtMs = (record["startedAt"] as? NSNumber)?.doubleValue + let startedAt = startedAtMs.map { Int($0 / 1000) } + if let startedAt, processStartTime > 0, + abs(startedAt - processStartTime) > Self.startTimeToleranceSeconds { + return nil + } + + return AgentSessionMetadata( + title: Self.humanTitle(record["name"]), + // Claude Code says so itself when it made the name up. + isTitleDerived: (record["nameSource"] as? String) == "derived", + startTime: startedAt, + kind: Self.shortField(record["kind"]), + workingDirectory: Self.shortField(record["cwd"], limit: Self.maxPathLength), + entrypoint: Self.shortField(record["entrypoint"]), + version: Self.shortField(record["version"]) + ) + } + + private func readJsonObject(atPath path: String) -> [String: Any]? { + let url = URL(fileURLWithPath: path) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = (attributes[.size] as? NSNumber)?.intValue, + size > 0, size <= Self.maxFileBytes else { return nil } + guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + /// A title worth showing: trimmed, short enough to draw, and never a uuid. + /// + /// Agents fall back to the session id when they have nothing better, and an + /// id on the panel is noise a person cannot check anything against. + public static func humanTitle(_ value: Any?) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty, !looksLikeIdentifier(text) else { return nil } + let flattened = text.components(separatedBy: .newlines).joined(separator: " ") + return String(flattened.prefix(64)) + } + + /// How much of a path from this file the panel will ever draw. + public static let maxPathLength = 160 + /// How much of any other field. These are short words ("interactive", + /// "2.1.234"); anything longer is not the field we are reading. + public static let maxFieldLength = 40 + + /// One small field from the record, trimmed, flattened, and capped. + /// + /// Everything here is written by the agent into a file any process running as + /// the user can edit, so it is bounded before it is ever drawn: a field is + /// display decoration, and no amount of it may push the panel's own lines off + /// the screen. + public static func shortField(_ value: Any?, limit: Int = maxFieldLength) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty else { return nil } + let flattened = text.components(separatedBy: .newlines).joined(separator: " ") + return String(flattened.prefix(limit)) + } + + static func looksLikeIdentifier(_ text: String) -> Bool { + // A uuid, with or without its dashes. + let stripped = text.replacingOccurrences(of: "-", with: "") + guard stripped.count == 32 else { return false } + return stripped.allSatisfy { $0.isHexDigit } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChain.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChain.swift new file mode 100644 index 000000000..0ed2488dc --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChain.swift @@ -0,0 +1,626 @@ +import Foundation +import Darwin + +/// The line of processes that leads to whoever is asking. +/// +/// One name is not enough to answer "is this me?". `bun` says nothing; `agent.ts +/// via bun, started from iTerm2` says everything. So the panel shows the chain +/// from the app that was launched down to the process on the other end of the +/// socket, and marks the one hop that actually decides what is running. +/// +/// Everything in here is read off the kernel by the daemon, never taken from the +/// message, which is what makes it worth showing. It is a value type with no +/// syscalls of its own so the shape, the emphasis, and the collapsing can be +/// tested against synthetic process trees. + +/// What the daemon can say about the code running at one hop. +/// +/// The distinction that matters most here is between a hop that IS an executable +/// and a hop that is a FILE an executable was handed. macOS will happily tell you +/// that a `bun` process is signed by Jarred Sumner with the Hardened Runtime, and +/// every word of that is true of bun and none of it is true of the JavaScript bun +/// is running, which is an ordinary file any process running as the user can +/// rewrite. Reporting the first as though it answered for the second is the one +/// claim a security prompt must never make, so `interpretedScript` is its own +/// answer rather than a shade of `signedHardened`. +public enum HopPosture: Equatable { + /// A valid signature and the Hardened Runtime. The strongest thing the + /// kernel will say about an executable. + case signedHardened + /// A valid signature, but no Hardened Runtime, so nothing stops a debugger + /// or an injected library attaching to it later. Not an accusation: plenty + /// of legitimate tools ship this way, `varlock`'s own binary included. + case signedOnly + /// The status word was readable and there is no valid signature on it. + case unsigned + /// The code that decides what happens here is a file an interpreter was + /// handed. Whatever the interpreter's own signature says, nothing has been + /// checked about the file, so nothing is claimed about it. + case interpretedScript + /// Nothing could be read. Neither answer, and drawn as neither. + case unknown + + /// The word that goes next to the mark once the chain is opened. + /// + /// Every posture has one now, including the ones that are not good news. An + /// absent word used to mean "we are not saying", which reads on a panel as + /// "nothing to report": the two are opposites, and a reader cannot tell them + /// apart from a blank space. + public var inlineLabel: String { + switch self { + case .signedHardened: return "signed" + case .signedOnly: return "unhardened" + case .unsigned: return "unsigned" + case .interpretedScript: return "not verified" + case .unknown: return "unchecked" + } + } + + /// The SF Symbol the mark is drawn with. + /// + /// A bare coloured dot says nothing to anyone who was not told the legend, so + /// each answer gets a shape that carries it: a shield for what was checked, a + /// warning triangle for code that was not, and a question mark for a process + /// the kernel would not talk about. + public var symbolName: String { + switch self { + case .signedHardened: return "checkmark.shield.fill" + case .signedOnly: return "shield" + case .unsigned: return "shield.slash" + case .interpretedScript: return "exclamationmark.triangle.fill" + case .unknown: return "questionmark.circle" + } + } + + /// Every answer, so a test can hold each one to the same standard. + public static let allAnswers: [HopPosture] = [ + .signedHardened, .signedOnly, .unsigned, .interpretedScript, .unknown, + ] + + /// Whether this answer is the good one, for whoever is choosing a colour. + public var isVerified: Bool { self == .signedHardened } + + /// Whether this answer should read as a caution rather than as a shrug. + public var isCaution: Bool { self == .interpretedScript } + + /// What was checked and what was not, in plain language, for the tooltip. + /// + /// - Parameter subject: what this mark is about, as the row names it. + /// - Parameter interpreter: the interpreter running the code, when there is + /// one, so the sentence can say whose signature is being set aside. + public func explanation(subject: String, interpreter: String? = nil) -> String { + switch self { + case .signedHardened: + return "\(subject) has a code signature the kernel accepts, and is running with the " + + "Hardened Runtime, so macOS refuses to attach a debugger to it or inject code into it. " + + "Checked: signature valid, Hardened Runtime on, no debugger attached. " + + "Not checked: who signed it." + case .signedOnly: + return "\(subject) has a code signature the kernel accepts, but is not running with the " + + "Hardened Runtime, so nothing stops a debugger or an injected library attaching to it " + + "later. Checked: signature valid, no debugger attached right now. " + + "Not checked: who signed it, and whether it stays uncompromised." + case .unsigned: + return "\(subject) has no code signature the kernel accepts. " + + "Checked: no debugger attached right now. " + + "Not checked: everything else. Anything running as you could have replaced this file." + case .interpretedScript: + let runner = interpreter ?? "an interpreter" + return "Nothing has been verified about the code running here. " + + "\(subject) is a file on disk that \(runner) is executing, and any process running " + + "as you can edit that file. \(runner)'s own signature is checked and says nothing " + + "about it. Checked: the interpreter. Not checked: the code that actually decides " + + "what happens." + case .unknown: + return "The kernel would not report this process's code-signing status, so nothing about " + + "\(subject) has been checked either way. This is not a verdict, it is the absence " + + "of one." + } + } +} + +/// One line of evidence under a hop, drawn only once the chain is opened. +public struct HopEvidence: Equatable { + /// The quiet word on the left: "program", "interpreter", "version". + public let label: String + public let value: String + /// Paths are drawn monospaced and elided in the MIDDLE, so the tail (the + /// package and the entry file, which is the identifying half) survives. + public let isPath: Bool + /// A posture mark at the end of the line, when this line is about something + /// whose posture was checked. + public let posture: HopPosture? + /// What that mark is a claim about, for its tooltip. + public let postureSubject: String? + + public init( + label: String, + value: String, + isPath: Bool = false, + posture: HopPosture? = nil, + postureSubject: String? = nil + ) { + self.label = label + self.value = value + self.isPath = isPath + self.posture = posture + self.postureSubject = postureSubject + } +} + +/// Which build of a program is running, and how confident the daemon is of it. +/// +/// A version on a security prompt is only worth drawing if the reader can tell +/// whether the machine established it or the caller merely said it, so the two +/// are different cases rather than one string with an asterisk. +public struct HopRelease: Equatable { + public enum Source: Equatable { + /// Read by the daemon off a file on disk that it resolved itself. + case readFromDisk + /// Sent over the socket by the client. A claim, and drawn as one. + case clientReported + } + + public let version: String + public let source: Source + + public init(version: String, source: Source) { + self.version = version + self.source = source + } + + /// Whether this is a build somebody made rather than one that was published. + /// + /// A `-dev` or `-canary` suffix is real signal on this panel: a development + /// build is not the artifact the release pipeline produced and nobody can + /// look up what is in it. + public var isPrerelease: Bool { version.contains("-") } + + /// "1.17.1", or "1.17.1 (reported by the caller)". + public var displayValue: String { + switch source { + case .readFromDisk: return version + case .clientReported: return "\(version) (reported by the caller)" + } + } +} + +/// One process in the chain. +public struct ExecutionHop: Equatable { + public let pid: pid_t + /// What the hop is called: a script's file name, an app's name, or the + /// executable's own. + public let name: String + /// "via bun", when `name` is a script rather than the executable itself. + public let via: String? + /// Executable path, shown only when the chain is expanded. + public let path: String? + /// The script this hop is running, as a real file on disk, when the argument + /// could be resolved to one. This is what the file's own icon is read from: + /// asking the system about a path lets the registered handler answer, where + /// asking about an extension gets whichever type claimed it first. + public let scriptPath: String? + /// The `.app` bundle this hop is, when it is one: the OUTERMOST enclosing + /// one, so an Electron editor's nested helper is drawn as the editor. The + /// panel turns it into the launcher's icon. + public let bundlePath: String? + /// The interpreter's own file name, when this hop's code is a script rather + /// than an executable: "bun", "node". + /// + /// Set whether or not `via` is, and they are not the same decision. `via` is + /// a DISPLAY choice ("varlock via bun" is noise on the resting row, so it is + /// left off); this is the FACT, and the panel needs it wherever it makes a + /// claim about what was checked, because the interpreter is the only part + /// that was. + public let interpreterName: String? + /// Where that interpreter is, for the expanded detail. + public let interpreterPath: String? + /// The interpreter's own posture. Never this hop's: it is stated next to the + /// interpreter's name, never on its own, so the verified thing and the claim + /// about it can never drift apart on screen. + public let interpreterPosture: HopPosture + /// The version of the code running here, when it could be established, and + /// where that answer came from. + public let release: HopRelease? + /// How this process was invoked, as the kernel has it: "varlock load". + /// + /// Set on the process that actually connected, and read from its argv rather + /// than from anything it sent, which is what makes it worth showing next to + /// the value names the client reported for itself. + public let invocation: String? + /// For a `varlock run`, the command this run will start and hand the values + /// to. That process does not exist yet, so it is in no ancestry: naming it + /// from argv is the only way the panel can say where the values are going. + public let runTarget: String? + public let posture: HopPosture + /// The process that actually connected to the daemon. + public let isRequester: Bool + /// The app the user launched. Drawn small, at the top, with its icon. + public let isLauncher: Bool + /// The hop that decides what runs. Drawn large; everything else is quiet. + public let isImportant: Bool + /// Set on the hop the unlock's session identity is anchored to: the process a + /// "this session" grant will actually attach to. Exactly one hop in a chain + /// carries it, and it is where the session begins in the ancestry, so it is + /// drawn there rather than as a note floating beside the chain. + public let sessionRoot: SessionRootMark? + /// Whether this hop is running inside the session rooted above it. The panel + /// tints the rail for these, so "inside the session" is something you can see + /// rather than something you work out. + public let isInsideSession: Bool + + public init( + pid: pid_t, + name: String, + via: String? = nil, + path: String? = nil, + scriptPath: String? = nil, + bundlePath: String? = nil, + interpreterName: String? = nil, + interpreterPath: String? = nil, + interpreterPosture: HopPosture = .unknown, + release: HopRelease? = nil, + invocation: String? = nil, + runTarget: String? = nil, + posture: HopPosture = .unknown, + isRequester: Bool = false, + isLauncher: Bool = false, + isImportant: Bool = false, + sessionRoot: SessionRootMark? = nil, + isInsideSession: Bool = false + ) { + self.pid = pid + self.name = name + self.via = via + self.path = path + self.scriptPath = scriptPath + self.bundlePath = bundlePath + self.interpreterName = interpreterName + self.interpreterPath = interpreterPath + self.interpreterPosture = interpreterPosture + self.release = release + self.invocation = invocation + self.runTarget = runTarget + self.posture = posture + self.isRequester = isRequester + self.isLauncher = isLauncher + self.isImportant = isImportant + self.sessionRoot = sessionRoot + self.isInsideSession = isInsideSession + } + + /// Whether this hop is varlock itself, however it was started. + /// + /// Read off the name, which the builder has already settled: it names a hop + /// varlock when the names say so OR when the file it runs came out of + /// varlock's own package, so an installed copy entered through a symlink is + /// varlock here too, and the row, its mark, and its version line all follow + /// from the same finding. + public var isVarlock: Bool { ExecutionChainBuilder.isOwnCommand(name) } + + /// Whether this hop is a plain shell: plumbing that says how something was + /// started rather than what is running. + public var isShell: Bool { ExecutionChainBuilder.shellNames.contains(name) } + + /// Whether the session a grant attaches to begins here. + public var isSessionRoot: Bool { sessionRoot != nil } + + /// The coding-agent session running at this hop, when it is one. Decoration + /// on the session root row rather than the reason that row exists. + public var agentSession: AgentSession? { sessionRoot?.agent } + + /// The one thing about this hop a person should know before approving. + /// + /// It sits under the hop it is about rather than in a legend at the bottom of + /// the chain: a warning a reader has to match back up to a row is a warning + /// that gets skipped. + public var advisory: String? { + guard posture == .interpretedScript, let via else { return nil } + let interpreter = via.hasPrefix("via ") ? String(via.dropFirst(4)) : via + return "a script run by \(interpreter): approval trusts this file, not the signed interpreter" + } + + /// What this posture mark is a claim about, worded for a tooltip. + public var postureSubject: String { + if posture == .interpretedScript { return "The code at \u{201C}\(name)\u{201D}" } + return "\u{201C}\(name)\u{201D}" + } + + /// How varlock itself is running, in words, on the row that is varlock. + /// + /// `bunx varlock load` and `/opt/homebrew/bin/varlock load` both draw a row + /// called "varlock", and they are not the same thing: one is a self-contained + /// binary the kernel has a signature for, the other is ordinary JavaScript + /// files any process running as the user can rewrite, executed by somebody + /// else's signed interpreter. A reader should not have to work that out from + /// a path, so the row says it. + public var runtimeForm: String? { + guard isVarlock else { return nil } + guard let interpreterName else { return "the standalone varlock binary" } + return "varlock's JavaScript, run by \(interpreterName), not the standalone binary" + } + + /// Whether that line is the one that should read as a caution. + public var runtimeFormIsCaution: Bool { isVarlock && interpreterName != nil } + + /// The lines drawn under this hop once the chain is opened: where the code + /// is, what is running it, and which build it is. + /// + /// Paths used to be crammed into the right-hand end of the hop's own row, + /// where they had a few dozen points to live in and truncated to things like + /// "~/Libra\u{2026}2.1.234". Evidence you cannot read is not evidence, so it + /// gets full-width lines of its own. + public var evidence: [HopEvidence] { + var lines: [HopEvidence] = [] + + // The file whose contents decide what this hop does, named first. + if let scriptPath { + lines.append(HopEvidence(label: "program", value: scriptPath, isPath: true)) + } else if interpreterName != nil { + // An interpreter with a script we could not resolve to a real file. + // Saying so is better than leaving the row looking complete. + lines.append(HopEvidence(label: "program", value: "could not be resolved to a file on disk")) + } else if let path { + lines.append(HopEvidence(label: isLauncher ? "bundle" : "program", value: path, isPath: true)) + } + + // The interpreter, and its posture stated right beside it. These two + // never appear apart: the whole failure this replaces was a signature + // shown without the name of what it was a signature of. + if let interpreterName { + lines.append(HopEvidence( + label: "interpreter", + value: interpreterPath ?? interpreterName, + isPath: interpreterPath != nil, + posture: interpreterPosture, + postureSubject: "\u{201C}\(interpreterName)\u{201D}" + )) + } + + if let release { + lines.append(HopEvidence(label: "version", value: release.displayValue)) + } + + if let agent = agentSession { + lines.append(contentsOf: agent.evidence) + } + return lines + } + + /// Hops that are neither the launcher, the actor, nor the root of a session: + /// shells, wrappers, and varlock itself. Present for completeness, drawn + /// small, and the first thing to fold away. + /// + /// A session root is never one of these. It is the answer to "which session + /// am I granting to", which the panel is asking about in the same breath, and + /// a fact that load-bearing does not go behind a disclosure. + public var isMinor: Bool { !isImportant && !isLauncher && !isSessionRoot } +} + +/// The session-root mark a hop can carry: what the session is called, and the +/// agent running it where there is one. +/// +/// The label comes from the identifier the grant is scoped to, so the panel and +/// the menu bar call the same session by the same name. +public struct SessionRootMark: Equatable { + /// "Terminal ttys004", "Process 4120", "Claude Code session". + public let label: String + /// How the identity was anchored, for anyone who needs to word it. + public let kind: SessionAnchor.Kind + /// The terminal this session is on, when it is on one, read back out of the + /// same identifier. This row is the ONLY place a tty id is drawn: a + /// controlling terminal is inherited, so naming it on a second row would be + /// saying the same fact twice, and naming it on the app that was launched + /// would be saying it where it is not even true. + public let terminal: String? + /// The coding-agent session rooted at this exact hop, when there is one. + public let agent: AgentSession? + + public init( + label: String, + kind: SessionAnchor.Kind, + terminal: String? = nil, + agent: AgentSession? = nil + ) { + self.label = label + self.kind = kind + self.terminal = terminal + self.agent = agent + } + + /// The line under the session root's name. + /// + /// The session is always named here, because this is the row that answers + /// "which session am I granting to" and the one place the chain states a + /// tty. An agent's own title leads when it recorded one, since that is what + /// tells two of its sessions apart, but it never replaces the name: a title + /// is what somebody called a conversation, and the terminal is where it is. + public var descriptionLine: String { + guard let quoted = quotedTitle else { return label } + return "\(quoted) \u{00B7} \(terminal ?? label)" + } + + /// The session's own title as it is drawn: the prefix of `descriptionLine` + /// the view sets in italics, so a name cannot be read as something varlock + /// asserts. + /// + /// Quotation marks are reserved for a name a PERSON chose. Agents generate a + /// name for every session from the directory they were opened in, and + /// dressing that in quotes would present a machine's guess as somebody's + /// words, on the one surface where the difference matters. + public var quotedTitle: String? { + guard let title = agent?.title, !title.isEmpty else { return nil } + guard agent?.isTitleDerived != true else { return title } + return "\u{201C}\(title)\u{201D}" + } +} + +/// A coding-agent session the chain is running inside. +/// +/// Named by product, the session's own human title, and when it started. Never by +/// id: a uuid is not something a person can check against their own screen, and +/// the raw id is a machine's business that lives in the audit log. +public struct AgentSession: Equatable { + public let productName: String + /// What the agent itself calls this session. nil when it could not be read, + /// which costs the row its title and nothing else. + public let title: String? + /// Whether the agent made that name up rather than the user typing it. + public let isTitleDerived: Bool + /// Seconds since the epoch: the agent's own record of when the session began + /// where that is available, and the process start otherwise. + public let startTime: Int? + /// The agent's word for what kind of session this is. nil when it did not + /// say, which is not the same as saying "not interactive". + public let kind: String? + /// Where the session is working. Cross-checked against the project being + /// unlocked, because an agent in one project opening another project's + /// secrets is exactly the anomaly this panel exists to surface. + public let workingDirectory: String? + public let entrypoint: String? + public let version: String? + + public init( + productName: String, + title: String?, + isTitleDerived: Bool = false, + startTime: Int?, + kind: String? = nil, + workingDirectory: String? = nil, + entrypoint: String? = nil, + version: String? = nil + ) { + self.productName = productName + self.title = title + self.isTitleDerived = isTitleDerived + self.startTime = startTime + self.kind = kind + self.workingDirectory = workingDirectory + self.entrypoint = entrypoint + self.version = version + } + + /// The agent's own word for an attended session. + public static let interactiveKind = "interactive" + + /// Said out loud on the session row when nobody is watching this agent. + /// + /// This is the single most decision-changing thing in the whole record. An + /// interactive session has a person in front of it who will see what happens + /// next; a print-mode or headless one does not, and "approve for this + /// session" then means "approve for a program running unattended". + /// + /// Only ever said on positive evidence. A record with no `kind` gets no line, + /// because "the agent did not say" and "no human is watching" are different + /// facts and only one of them is worth an alarm. + public var unattendedNote: String? { + guard let kind, kind != Self.interactiveKind else { return nil } + return "a \(kind) session: no person is watching this agent" + } + + /// The evidence lines this session contributes once the chain is opened. + public var evidence: [HopEvidence] { + var lines: [HopEvidence] = [] + if let workingDirectory { + lines.append(HopEvidence(label: "working dir", value: workingDirectory, isPath: true)) + } + if let build = buildLine { + lines.append(HopEvidence(label: "agent", value: build)) + } + if isTitleDerived, title != nil { + lines.append(HopEvidence(label: "name", value: "generated by \(productName), not typed by you")) + } + return lines + } + + /// "Claude Code 2.1.234, started from claude-desktop". + private var buildLine: String? { + switch (version, entrypoint) { + case (let version?, let entrypoint?): return "\(productName) \(version), started from \(entrypoint)" + case (let version?, nil): return "\(productName) \(version)" + case (nil, let entrypoint?): return "\(productName), started from \(entrypoint)" + default: return nil + } + } +} + +/// The whole chain, launcher first, the process that connected last. +public struct ExecutionChain: Equatable { + public let hops: [ExecutionHop] + + public init(hops: [ExecutionHop]) { + self.hops = hops + } + + public static let empty = ExecutionChain(hops: []) + + /// What the host process was running, when varlock was loaded by one rather + /// than run by a person. + /// + /// An auto-load spawns the same CLI a person would, so the useful line is not + /// varlock's own internal command but the command it was loaded inside: the + /// nearest thing above it that is not varlock. + public var hostInvocation: String? { + if let hostProgram { return hostProgram.invocation } + guard let requesterIndex = hops.firstIndex(where: { $0.isRequester }) else { return nil } + // Nothing but shells above: they answer only when there is nothing else + // to point at. + return hops[..= Self.collapseThreshold && !collapsibleHops.isEmpty + } + + /// The hops folded away at rest: everything that is neither the launcher nor + /// the actor. The two that carry meaning always stay on screen. + public var collapsibleHops: [ExecutionHop] { + return hops.filter { $0.isMinor } + } + + /// What the chain shows at rest. + public var restingHops: [ExecutionHop] { + return collapsesWhenResting ? hops.filter { !$0.isMinor } : hops + } + + /// "2 more steps (zsh, varlock)", or nil when nothing is folded away. + public var expanderLabel: String? { + guard collapsesWhenResting else { return nil } + let folded = collapsibleHops + let names = folded.map { $0.name }.joined(separator: ", ") + let count = folded.count == 1 ? "1 more step" : "\(folded.count) more steps" + return "\(count) (\(names))" + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChainBuilder.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChainBuilder.swift new file mode 100644 index 000000000..a81268079 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ExecutionChainBuilder.swift @@ -0,0 +1,833 @@ +import Foundation +import Darwin + +/// Reads the chain of processes behind a request off the live machine. +/// +/// The daemon knows the peer's pid from the socket, and everything above it is +/// there to be read: parents, executable paths, arguments, code-signing status. +/// Turning that into "agent.ts, via bun, launched from iTerm2" is what makes the +/// panel answerable, because the pid on its own tells a person nothing. +/// +/// Every read here is best effort and bounded. A process that exits mid-walk, an +/// argument list that cannot be read, a signature that cannot be checked: each of +/// those costs one missing detail and never a missing panel. The unlock is the +/// thing the user is waiting for, and no amount of provenance is worth making +/// them wait for it, so the walk gives up on a deadline rather than blocking. + +/// The code-signing half of the inspection, behind a protocol so the chain can be +/// built against synthetic processes in tests. +public protocol PostureProbe { + func posture(forPid pid: pid_t) -> PeerPostureFacts +} + +/// The live probe, reading the kernel's own view of a process. +public struct LivePostureProbe: PostureProbe { + private let reader = PeerPostureReader() + + public init() {} + + public func posture(forPid pid: pid_t) -> PeerPostureFacts { + return reader.facts(forPid: pid) + } +} + +public struct ExecutionChainBuilder { + /// How far up the tree to walk. Deep enough to reach the terminal app that + /// was launched, short enough that a deep tree cannot become a long panel. + public static let maxDepth = 10 + + /// How long the whole inspection may take. The panel is what the user is + /// waiting for; provenance that is not ready by now is not shown. + public static let deadlineSeconds: TimeInterval = 0.25 + + /// Executables that are plumbing rather than actors. A shell in the chain + /// says how something was started, not what is running. + static let shellNames: Set = [ + "sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login", "env", "xargs", + ] + + /// Executables that run somebody else's code. The signature on one of these + /// covers the interpreter, never the script it was handed. + static let interpreterNames: Set = [ + "node", "bun", "deno", "python", "python3", "ruby", "perl", "tsx", "ts-node", + ] + + /// Sub-commands to step over when looking for the script an interpreter was + /// given ("bun run agent.ts", "deno run main.ts"). + static let interpreterSubcommands: Set = ["run", "exec", "x", "-e", "--eval"] + + /// varlock itself is always in the chain, being the process that connected, + /// and is never the answer to "who is asking". + static let ownNames: Set = ["varlock", "varlock-local-encrypt", "VarlockEnclave"] + + /// The npm package varlock's JavaScript ships in, and the name a manifest + /// has to carry for a file under it to be varlock's own code. + static let ownPackageName = "varlock" + + /// Wrappers that exist to go and fetch the real command. + /// + /// `bunx varlock load` is varlock being run, and saying so is the whole job + /// of the line under the hop. + static let commandWrappers: Set = ["npx", "bunx", "pnpx", "dlx"] + + /// Words that follow an interpreter or package manager before the real + /// command starts. + static let wrapperSubcommands: Set = ["run", "exec", "x", "dlx", "--"] + + private let provider: ProcessProvider + private let posture: PostureProbe + private let sessionMetadata: AgentSessionMetadataReader + private let clock: () -> Date + + public init( + provider: ProcessProvider = LiveProcessProvider(), + posture: PostureProbe = LivePostureProbe(), + sessionMetadata: AgentSessionMetadataReader = LiveAgentSessionMetadataReader(), + clock: @escaping () -> Date = Date.init + ) { + self.provider = provider + self.posture = posture + self.sessionMetadata = sessionMetadata + self.clock = clock + } + + public func build(forPid pid: pid_t) -> ExecutionChain { + let started = clock() + var walked: [WalkedProcess] = [] + var current = pid + + for _ in 0.. 1, info.ppid != current else { break } + guard clock().timeIntervalSince(started) < Self.deadlineSeconds else { break } + current = info.ppid + } + + guard !walked.isEmpty else { return .empty } + + // Launcher first, the process that connected last: the order things + // happened in, which is the order a person reconstructs them in. + let ordered = Array(walked.reversed()) + // Which file each hop is really running, and which package that file + // came out of. Resolved once, up front, because three separate questions + // depend on the answer (is this varlock, what is this row called, which + // build is it) and they are not allowed to be answered from different + // evidence: that is exactly how a row came to say "cli.js" while the + // version line beside it said varlock. + let scripts = ordered.map { scriptPath(of: $0) } + let packages = scripts.map { path -> OwningPackage? in + // Bounded like every other read in this walk. A manifest that is not + // ready by the deadline costs a name and a version, never a panel. + guard let path, clock().timeIntervalSince(started) < Self.deadlineSeconds else { return nil } + return Self.ownerPackage(of: path) + } + let isOwnHop = zip(ordered, packages).map { walkedProcess, package in + walkedProcess.isOwnProcess || package?.name == Self.ownPackageName + } + // Where a "this session" grant will actually attach, and therefore which + // row has to say so. Found before the hops are built, because it changes + // which hop is the actor and which hops read as inside the session. + let anchor = SessionScoper(provider: provider).sessionAnchor(forPid: pid) + let rootIndex = sessionRootIndex(in: ordered, anchor: anchor) + let session = agentSession(in: ordered, startedAt: started) + let root = anchor.flatMap { anchor in + rootIndex.map { index in + SessionRootMark( + label: anchor.label, + kind: anchor.kind, + // The one tty id in the chain. It belongs here because a + // controlling terminal is inherited: the shell and varlock + // below it share this exact tty, and the app that launched + // them holds none of its own. + terminal: anchor.terminal, + // Decoration, not the reason the row exists, and only on + // positive evidence: a row is named after an agent when the + // session is anchored on the agent's own process. An + // inherited environment marker says the request came from + // inside a session, which the label already says, and is no + // reason to call some other program by the agent's name. + agent: index == session?.index && session?.isTheAgentItself == true + ? session?.session + : nil + ) + } + } + let importantIndex = importantHopIndex(ordered, isOwnHop: isOwnHop, sessionRootIndex: rootIndex) + + var hops: [ExecutionHop] = [] + for (index, walkedProcess) in ordered.enumerated() { + let isLauncher = walkedProcess.bundlePath != nil && index == 0 + let resolvedScript = scripts[index] + let isOwn = isOwnHop[index] + hops.append(ExecutionHop( + pid: walkedProcess.snapshot.pid, + name: isLauncher + ? launcherName(walkedProcess) + : Self.hopName(walkedProcess, isOwn: isOwn), + // "varlock via node" is true and useless: node is how varlock + // ships, not who is asking. The interpreter is still there in + // the path when the chain is opened. + via: walkedProcess.scriptName == nil || isOwn + ? nil + : "via \(walkedProcess.executableName)", + path: isLauncher ? launcherDetailPath(walkedProcess) : walkedProcess.path, + scriptPath: resolvedScript, + bundlePath: walkedProcess.bundlePath, + // The interpreter is a fact whether or not the row shows "via + // bun". `via` is a display choice; this is what the posture claim + // is actually about, and the two are not allowed to be the same + // decision ever again. + interpreterName: walkedProcess.scriptName == nil ? nil : walkedProcess.executableName, + interpreterPath: walkedProcess.scriptName == nil ? nil : walkedProcess.path, + interpreterPosture: walkedProcess.scriptName == nil + ? .unknown + : Self.posture(from: posture.posture(forPid: walkedProcess.snapshot.pid)), + release: Self.release(from: packages[index]), + // Read for every hop, because an auto-load's useful line is the + // host's command rather than varlock's own. Only the requester's + // is drawn. + invocation: Self.invocation(from: walkedProcess.arguments, scriptIsOwn: isOwn), + runTarget: Self.runTarget(from: walkedProcess.arguments, scriptIsOwn: isOwn), + posture: hopPosture(walkedProcess), + isRequester: walkedProcess.snapshot.pid == pid, + isLauncher: isLauncher, + isImportant: importantIndex.map { $0 == index } ?? false, + sessionRoot: index == rootIndex ? root : nil, + // Everything below the session root ran inside it, which the + // panel draws as one span rather than leaving to be inferred. + isInsideSession: rootIndex.map { index > $0 } ?? false + )) + } + + return ExecutionChain(hops: hops) + } + + /// Which hop is the ACTOR: the program the secrets are being loaded FOR. + /// + /// That is the whole definition, and everything below follows from it: + /// + /// - a script beats the interpreter running it. `bun` is interchangeable; + /// `agent.ts` is the thing the values are for, and the mutable one. + /// - a host that auto-loaded varlock (`next dev`, `vite`, a test runner) is + /// the actor. varlock ran on its behalf. + /// - varlock itself NEVER is. It is in every chain, being the process that + /// connected, so emphasising it says nothing about this request. Its + /// command line is still shown under its own hop, which is where the + /// recognisable information actually lives. + /// - shells never are. `zsh` is how a command was typed, not what it was + /// typed for. + /// - the session root never is. It has a treatment of its own, and marking + /// it twice would claim the session is what is running, when what is + /// running is whatever the session started. + /// - an app bundle never is, wherever it sits. Today the walk stops at the + /// first one it meets, so that is the launcher; but an Electron editor's + /// "Code Helper" is a bundle and is not a shell, so without this it would + /// answer to the second rule the moment one ever appeared lower down. + /// A windowed app is where a command was started, never the command. + /// + /// When nothing qualifies, nothing is bold. That is the honest state for a + /// command a person typed themselves: the values are for the command, the + /// command is varlock, and there is no third party in the picture. Do not + /// reintroduce a fallback here; a bold row that always exists is a bold row + /// that means nothing. + private func importantHopIndex( + _ ordered: [WalkedProcess], + isOwnHop: [Bool], + sessionRootIndex: Int? + ) -> Int? { + let candidates = ordered.enumerated().filter { + $0.element.bundlePath == nil && $0.offset != sessionRootIndex + } + if let script = candidates.last(where: { $0.element.scriptName != nil && !isOwnHop[$0.offset] }) { + return script.offset + } + return candidates.first(where: { !$0.element.isShell && !isOwnHop[$0.offset] })?.offset + } + + /// What a hop that is not the app at the top of the chain is called. + /// + /// varlock's own CLI is called varlock however it was reached. Installed as + /// a package it is entered through `node_modules/.bin/varlock`, which is a + /// symlink to `varlock/bin/cli.js`, so what a runner puts in argv is that + /// `cli.js` path and the file name says nothing. A row called "cli.js" sends + /// the reader looking for a script nobody wrote, on the one screen where + /// their job is to recognise what is asking. + private static func hopName(_ walkedProcess: WalkedProcess, isOwn: Bool) -> String { + if isOwn, walkedProcess.scriptName != nil { return ownPackageName } + return walkedProcess.displayName + } + + /// Which hop the session a grant attaches to begins at. + /// + /// The anchor comes from `SessionScoper`, the same code that computes the + /// identifier the grant is keyed by, so the row a person reads and the + /// identity they are granting to cannot drift apart. + /// + /// The anchor is always the peer or one of its ancestors. When the walk + /// stopped before reaching it (an app bundle at the top, the depth cap, the + /// deadline) the nearest hop this chain has is its topmost one, so the mark + /// goes there: the session exists either way, and a panel offering "This + /// session" as a scope has to be able to say which session that is. + private func sessionRootIndex(in ordered: [WalkedProcess], anchor: SessionAnchor?) -> Int? { + guard let anchor else { return nil } + guard !ordered.isEmpty else { return nil } + guard let anchorPid = anchor.pid else { + // An agent's own session id, with no process behind it. The outermost + // hop is as close as the chain can get to where that session begins. + return 0 + } + return ordered.firstIndex { $0.snapshot.pid == anchorPid } ?? 0 + } + + /// The command line, short enough to draw. + /// + /// The program is named by its own file name rather than by the path it was + /// found at, because "varlock load" is what a person typed and + /// "/opt/homebrew/bin/varlock load" is where it happened to live. Long + /// argument lists are cut at the end, so the subcommand and the first + /// arguments (the part that says what is happening) always survive. + /// + /// - Parameter scriptIsOwn: whether the file this command line runs was + /// established to be varlock's own, by the caller that could resolve it. + /// The token alone cannot always say so: an installed copy is entered + /// through a symlink and argv ends up naming `cli.js`. + public static func invocation(from arguments: [String], scriptIsOwn: Bool = false) -> String? { + guard let program = arguments.first else { return nil } + + // "bunx varlock load", "node .../node_modules/.bin/varlock load" and + // "/opt/homebrew/bin/varlock load" are the same act, and the only useful + // way to say it is the way the user typed it. So when varlock appears + // anywhere in the front of the command line, the line starts there. + var tokens = arguments + if let index = ownCommandIndex(in: tokens, scriptIsOwn: scriptIsOwn) { + tokens = ["varlock"] + tokens.dropFirst(index + 1) + } else if let script = scriptToken(in: tokens) { + // "node .../node_modules/.bin/next dev" is "next dev" to everyone + // except the person who wrote the launcher script. + tokens = [(script.token as NSString).lastPathComponent] + tokens.dropFirst(script.index + 1) + } else { + let name = (program as NSString).lastPathComponent + guard !name.isEmpty else { return nil } + tokens = [name] + tokens.dropFirst() + } + + // varlock's own line gets the trimming rule: it is the one a person is + // being asked to judge, so it is drawn without waiting for the chain to + // be opened, and it earns that place by saying only what matters. + if tokens.first == "varlock" { + return VarlockInvocation.fit( + VarlockInvocation.trimmed(tokens), + limit: maxVarlockInvocationLength + ) + } + + let line = tokens.joined(separator: " ") + guard line.count > maxInvocationLength else { return line } + return String(line.prefix(maxInvocationLength - 1)) + "\u{2026}" + } + + /// The command a `varlock run` will hand these values to, when that is what + /// this command line is. + public static func runTarget(from arguments: [String], scriptIsOwn: Bool = false) -> String? { + guard let index = ownCommandIndex(in: arguments, scriptIsOwn: scriptIsOwn) else { return nil } + return VarlockInvocation.runTarget(["varlock"] + arguments.dropFirst(index + 1)) + } + + /// Where varlock's own command starts in an argument list. + /// + /// The token's own name answers first and answers most of the time. When it + /// cannot, the file the command runs is the witness, and only the caller + /// that resolved that file can say so, which is what `scriptIsOwn` carries. + static func ownCommandIndex(in tokens: [String], scriptIsOwn: Bool) -> Int? { + if let index = tokens.firstIndex(where: { isOwnCommand($0) }) { return index } + guard scriptIsOwn else { return nil } + return scriptToken(in: tokens)?.index + } + + /// The script an interpreter or wrapper was pointed at, if that is the shape + /// of this command line. + static func scriptToken(in tokens: [String]) -> (index: Int, token: String)? { + guard let program = tokens.first else { return nil } + let name = (program as NSString).lastPathComponent + guard interpreterNames.contains(name) || commandWrappers.contains(name) else { return nil } + for (index, token) in tokens.enumerated().dropFirst() { + if token.hasPrefix("-") { continue } + if interpreterSubcommands.contains(token) || commandWrappers.contains(token) { continue } + let leaf = (token as NSString).lastPathComponent + guard !leaf.isEmpty else { continue } + guard token.contains("/") || leaf.contains(".") else { + // A bare word after a wrapper is the command itself ("bunx next"). + return commandWrappers.contains(name) ? (index, token) : nil + } + return (index, token) + } + return nil + } + + /// Whether one argv token NAMES varlock's own CLI. + /// + /// The cheap half of the question, and the one that answers a compiled + /// binary, a `node_modules/.bin` entry, and anything a person typed. It is + /// deliberately still just a name test: the other half, "does this file + /// belong to varlock's package", needs the file system and lives in + /// `ownerPackage(of:)`, because the name genuinely cannot answer it. A + /// `.bin/varlock` symlink resolves to `varlock/bin/cli.js`, and no amount of + /// looking at "cli" will tell you what it is. + static func isOwnCommand(_ token: String) -> Bool { + var name = (token as NSString).lastPathComponent + for suffix in [".js", ".mjs", ".cjs", ".ts"] where name.hasSuffix(suffix) { + name = String(name.dropLast(suffix.count)) + } + return ownNames.contains(name) + } + + /// Enough for a subcommand and its first arguments, and no more. + public static let maxInvocationLength = 56 + + /// varlock's own line is always on screen and is the one being judged, so it + /// gets more room than a passing mention of a host command does. + public static let maxVarlockInvocationLength = 72 + + /// What to call the app at the top of the chain. + /// + /// The name on screen is the one the user knows it by, which is the app's + /// own display name rather than the file its bundle happens to be called, + /// and it is read from the OUTERMOST enclosing bundle: Electron editors + /// spawn terminals from a helper nested inside the real app, and nobody + /// launched "Code Helper (Plugin)". + /// + /// The bundle's file name wins when it is the fuller form of the same name, + /// because that is the one Finder and the Dock show: "Visual Studio Code.app" + /// says CFBundleName "Code". It never wins over a different name, so + /// "iTerm.app" is still called iTerm2. + /// + /// Falls back to the executable, which is right often enough ("iTerm2"), and + /// only then to the bundle's file name. A nested bundle skips that step: the + /// executable there is the helper, which is the name being corrected. + private func launcherName(_ walkedProcess: WalkedProcess) -> String { + guard let bundlePath = walkedProcess.bundlePath else { return walkedProcess.displayName } + let fileName = walkedProcess.displayName + if let bundle = Bundle(path: bundlePath) { + for key in ["CFBundleDisplayName", "CFBundleName"] { + guard let name = bundle.object(forInfoDictionaryKey: key) as? String, !name.isEmpty else { + continue + } + return Self.isFullerForm(fileName, of: name) ? fileName : name + } + } + let executable = walkedProcess.executableName + if executable != "unknown", !walkedProcess.isNestedBundle { return executable } + return fileName + } + + /// Whether `candidate` is the same name as `name` with more of it: "Visual + /// Studio Code" for "Code". Whole words only, so "Xcode" is not a fuller + /// form of "code". + static func isFullerForm(_ candidate: String, of name: String) -> Bool { + guard candidate.count > name.count, !name.isEmpty else { return false } + return candidate.hasPrefix("\(name) ") + || candidate.hasSuffix(" \(name)") + || candidate.contains(" \(name) ") + } + + /// The script this hop is running, as a real file on disk. + /// + /// Worth the two syscalls because a file's own icon is the fastest way a + /// person recognises the thing they are being asked about, and the system + /// will only answer honestly for a path: an extension is ambiguous (".ts" is + /// registered for MPEG transport streams as well as TypeScript), so nothing + /// here ever guesses from one. An argument that does not resolve to a file + /// that exists is left as nil, and the panel draws a plain document. + /// + /// Resolved for varlock's own CLI too. It used to be skipped there, on the + /// reasoning that varlock does not need to be introduced to itself; but the + /// resolved path is the answer to "WHICH varlock is this", which for a + /// `node_modules` copy is the whole question. + private func scriptPath(of walkedProcess: WalkedProcess) -> String? { + guard let token = Self.scriptToken(in: walkedProcess.arguments)?.token else { return nil } + + var candidate = token + if candidate.hasPrefix("~") { + candidate = NSString(string: candidate).expandingTildeInPath + } + if !candidate.hasPrefix("/") { + // Relative to wherever the process was started, which only the + // kernel knows. Unreadable for a process we are not allowed to + // inspect, and that is a fine place to stop. + guard let directory = provider.workingDirectory(for: walkedProcess.snapshot.pid) else { return nil } + candidate = NSString(string: directory).appendingPathComponent(candidate) + } + candidate = NSString(string: candidate).standardizingPath + guard FileManager.default.fileExists(atPath: candidate) else { return nil } + // Through the symlink, because a package manager's `node_modules/.bin` + // entry is a link and the question the reader is asking is where the + // code actually is. `.../node_modules/.bin/varlock` and + // `.../node_modules/varlock/bin/cli.js` are the same file, and only the + // second one says which package it came out of. + return NSString(string: candidate).resolvingSymlinksInPath + } + + /// The package an entry script belongs to: the nearest `package.json` above + /// it, whatever that package turns out to be. + /// + /// This is the daemon's answer to BOTH questions it has about a file it + /// resolved: whether the file is varlock's own code, and which build of it + /// this is. They used to be answered from different evidence, and the two + /// disagreed in exactly the invocation people actually use: `bunx varlock + /// load` enters through a `node_modules/.bin/varlock` symlink that resolves + /// to `varlock/bin/cli.js`, so the version line read varlock's manifest and + /// said 1.17.1 while the row above it, going by the file name, said "cli.js". + /// One lookup means they cannot drift again. + /// + /// Anything found here is the NEAREST manifest, never a search for a wanted + /// answer. A package.json that says something else is that file's owner, and + /// the file is somebody else's code: walking further up to find a varlock + /// manifest would let a third party's script sitting inside our package tree + /// wear varlock's name. + /// + /// The compiled binary has no package around it, so nothing is returned for + /// it; the panel falls back to what the client reported and says so. + struct OwningPackage: Equatable { + let name: String? + let version: String? + } + + /// Which build of varlock is running, when the file that is running belongs + /// to varlock's own package. + /// + /// A version is worth drawing on this panel because a `-dev` suffix says the + /// running code is not the published artifact, and that is exactly the kind + /// of thing somebody should notice before approving. + static func release(from package: OwningPackage?) -> HopRelease? { + guard let package, package.name == ownPackageName else { return nil } + return package.version.map { HopRelease(version: $0, source: .readFromDisk) } + } + + /// The owning package, read once per resolved path. + /// + /// Cached because this runs while a modal is pending and the same file is + /// asked about repeatedly: a chain is rebuilt whenever the panel refreshes, + /// and every hop asks about its own entry file. The walk is capped by depth + /// and the manifest read is capped by size, so a miss is a handful of + /// `stat`s and a hit is a dictionary lookup. + static func ownerPackage(of scriptPath: String) -> OwningPackage? { + packageCacheLock.lock() + if let cached = packageCache[scriptPath] { + packageCacheLock.unlock() + return cached + } + packageCacheLock.unlock() + + let found = readOwnerPackage(of: scriptPath) + + packageCacheLock.lock() + // A flat cap rather than an eviction policy. This is a cache of paths a + // handful of processes were started from; if it ever grows past that, + // something is wrong and starting over is cheap. + if packageCache.count >= maxPackageCacheEntries { packageCache.removeAll() } + packageCache[scriptPath] = found + packageCacheLock.unlock() + return found + } + + private static func readOwnerPackage(of scriptPath: String) -> OwningPackage? { + var directory = (scriptPath as NSString).deletingLastPathComponent + for _ in 0.. (name: String?, version: String?)? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = (attributes[.size] as? NSNumber)?.intValue, + size > 0, size <= maxPackageBytes else { return nil } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedIfSafe]), + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { return nil } + return (json["name"] as? String, Self.versionText(json["version"])) + } + + /// A version string short enough and plain enough to draw. Anything else is + /// dropped: this is a file on disk that anyone running as the user can edit, + /// and it is display decoration either way. + public static func versionText(_ value: Any?) -> String? { + guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty, text.count <= 32 else { return nil } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-+_")) + guard text.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return text + } + + /// The path shown for the launcher once the chain is opened. + /// + /// The headline is the app a person launched; the evidence is the bundle the + /// process is actually in, which for an Electron editor is a helper nested + /// several directories down. Saying so is honest, and it is not the headline. + /// + /// The whole bundle path, not the directory it sits in. The directory used to + /// be shown on the theory that the bundle's name was already the row's name, + /// which made the evidence line under an app in `/Applications` read + /// "/Applications" and say nothing. Which COPY of an app this is, is the + /// question the line exists to answer, and only the full path answers it: an + /// agent CLI installed under `~/Library/Application Support` is not the one in + /// `/Applications`, and the version is in that path too. + private func launcherDetailPath(_ walkedProcess: WalkedProcess) -> String? { + if let inner = walkedProcess.innerBundlePath, inner != walkedProcess.bundlePath { + return inner + } + return walkedProcess.bundlePath + } + + /// What the daemon can honestly say about the code running at one hop. + /// + /// An interpreter's signature covers the interpreter and nothing else. When a + /// hop is `bun` running a `.mjs` file, the kernel reports a valid signature + /// and the Hardened Runtime, and every word of that is about bun: the file it + /// was handed is unsigned and any process running as the user can rewrite it + /// between now and the next read. So an interpreted hop gets + /// `.interpretedScript` and its interpreter's real posture is carried + /// separately, to be stated next to the interpreter's name. + /// + /// varlock's own CLI used to be exempt from this, on the grounds that the + /// daemon "verifies the peer's code signature before it will speak to it at + /// all". It does not. `verifyPeerProcess` checks the peer's binary NAME + /// against an allowlist that contains `node` and `bun` precisely so that + /// varlock's JavaScript can connect, and `PeerPosture.check` reads the same + /// status word this function reads, about the same interpreter process. The + /// effect of the exemption was that `bunx varlock load` drew a green shield + /// and the word "signed" on a row labelled "varlock", describing bun. That is + /// the one claim this panel must never make, so the exemption is gone. + private func hopPosture(_ walkedProcess: WalkedProcess) -> HopPosture { + if walkedProcess.scriptName != nil { return .interpretedScript } + return Self.posture(from: posture.posture(forPid: walkedProcess.snapshot.pid)) + } + + /// The posture of one process's own binary, from the kernel's status word. + static func posture(from facts: PeerPostureFacts) -> HopPosture { + guard facts.isReadable else { return .unknown } + guard facts.signatureValid else { return .unsigned } + return facts.hasHardenedRuntime ? .signedHardened : .signedOnly + } + + // MARK: - Agent sessions + + /// Products worth naming when their session is what the request came from. + /// + /// Matched on the process itself where possible, and otherwise on the + /// environment it exported, which is what survives into the shell an agent + /// runs commands in. + struct AgentMarker { + let product: AgentProduct + let executableNames: Set + let environmentKeys: Set + } + + static let agentMarkers: [AgentMarker] = [ + AgentMarker( + product: .claudeCode, + executableNames: ["claude"], + environmentKeys: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"] + ), + AgentMarker( + product: .codex, + executableNames: ["codex"], + environmentKeys: [] + ), + ] + + /// A session, which hop it was found at, and how strong that finding is. + struct FoundSession { + let index: Int + let session: AgentSession + /// Whether this hop IS the agent, rather than merely a process running + /// inside one. Only the first can put the agent's name on a row. + let isTheAgentItself: Bool + } + + /// The agent session this request came from, if it came from one. + /// + /// The executable is checked first because it is free: the walk already read + /// every argument list. Only if that finds nothing is the environment read, + /// which costs a syscall per process, and only until the deadline: a badge is + /// worth having, never worth making an unlock wait. + private func agentSession(in ordered: [WalkedProcess], startedAt: Date) -> FoundSession? { + for (index, walkedProcess) in ordered.enumerated() { + for marker in Self.agentMarkers where marker.executableNames.contains(walkedProcess.executableName) { + return found(marker: marker, index: index, process: walkedProcess, isTheAgentItself: true) + } + } + + for (index, walkedProcess) in ordered.enumerated() { + guard clock().timeIntervalSince(startedAt) < Self.deadlineSeconds else { return nil } + guard let environment = provider.environment(for: walkedProcess.snapshot.pid) else { continue } + for marker in Self.agentMarkers { + let matched = marker.environmentKeys.contains { key in + guard let value = environment[key] else { return false } + return !value.isEmpty && value != "0" + } + // An exported marker proves the request came from INSIDE that + // agent's session, never that this process is the agent: the + // variable is inherited by every descendant, so the topmost + // carrier is just the outermost process the walk could read. + // `next dev` started by an agent would answer to this too. + if matched { + return found(marker: marker, index: index, process: walkedProcess, isTheAgentItself: false) + } + } + } + return nil + } + + /// The session as the panel will say it: product, the session's own title + /// where the agent recorded one, and when it began. + private func found( + marker: AgentMarker, + index: Int, + process: WalkedProcess, + isTheAgentItself: Bool + ) -> FoundSession { + let processStart = process.snapshot.startTime > 0 ? process.snapshot.startTime : nil + let metadata = sessionMetadata.metadata( + for: marker.product, + pid: process.snapshot.pid, + processStartTime: process.snapshot.startTime + ) + return FoundSession( + index: index, + session: AgentSession( + productName: marker.product.displayName, + title: metadata?.title, + isTitleDerived: metadata?.isTitleDerived ?? false, + // The agent's own record of when the session began where there is + // one, since a session can outlive the process that started it. + startTime: metadata?.startTime ?? processStart, + kind: metadata?.kind, + workingDirectory: metadata?.workingDirectory, + entrypoint: metadata?.entrypoint, + version: metadata?.version + ), + isTheAgentItself: isTheAgentItself + ) + } +} + +/// One process as the walk found it, with the reading of it done once. +struct WalkedProcess { + let snapshot: ProcSnapshot + let path: String? + let arguments: [String] + + /// The executable's own name, whatever it is running. + var executableName: String { + guard let path, !path.isEmpty else { return "unknown" } + return (path as NSString).lastPathComponent + } + + /// The `.app` this process's executable sits directly inside, which for an + /// Electron editor is a helper bundle: "Code Helper (Plugin).app". + var innerBundlePath: String? { + guard let path, let range = path.range(of: ".app/Contents/MacOS/") else { return nil } + return String(path[path.startIndex.. Int32 + +/// `CS_OPS_STATUS`: read a process's code-signing status word. +private let csOpsStatus: UInt32 = 0 + +// Code-signing status bits, from . Not exported to Swift. +private let csValid: UInt32 = 0x0000_0001 +private let csRuntime: UInt32 = 0x0001_0000 +private let csDebugged: UInt32 = 0x1000_0000 + +/// `P_TRACED`, from : something has this process under ptrace. +private let pTraced: Int32 = 0x0000_0800 + +/// What the kernel will say about a process's own hardening. +/// +/// Read off the pid rather than taken from anything the peer sent. Two questions +/// matter here: +/// +/// - is somebody inside it right now (a debugger attached, a tracer running)? +/// A process under ptrace has no secrets from whoever is tracing it, so +/// handing plaintext to one hands it to the tracer too. +/// - was it built so somebody could not simply walk in? Hardened Runtime is +/// what refuses `task_for_pid`, library injection, and unsigned code at +/// load time. Without it, "the peer is not being debugged right now" is a +/// statement about this instant only. +public struct PeerPostureFacts: Equatable { + /// A debugger or tracer is attached. + public let isTraced: Bool + /// The process is running with Hardened Runtime. + public let hasHardenedRuntime: Bool + /// The kernel has a valid code signature for it. + public let signatureValid: Bool + /// Whether the status word could be read at all. False means the answers + /// above are guesses, not facts, and the caller has to decide what to do + /// about not knowing. + public let isReadable: Bool + + public init(isTraced: Bool, hasHardenedRuntime: Bool, signatureValid: Bool, isReadable: Bool) { + self.isTraced = isTraced + self.hasHardenedRuntime = hasHardenedRuntime + self.signatureValid = signatureValid + self.isReadable = isReadable + } + + /// Everything unknown. What an unreadable process looks like. + public static let unreadable = PeerPostureFacts( + isTraced: false, + hasHardenedRuntime: false, + signatureValid: false, + isReadable: false + ) +} + +/// Reads posture facts from the live kernel. +public struct PeerPostureReader { + public init() {} + + public func facts(forPid pid: pid_t) -> PeerPostureFacts { + var status: UInt32 = 0 + let result = withUnsafeMutablePointer(to: &status) { pointer -> Int32 in + return csops(pid, csOpsStatus, UnsafeMutableRawPointer(pointer), MemoryLayout.size) + } + guard result == 0 else { return .unreadable } + + // Asked twice on purpose. `CS_DEBUGGED` is the kernel's own view of the + // process having been opened up; `P_TRACED` catches a tracer that attached + // without that bit being set, which is the case on an unsigned process. + let traced = (status & csDebugged) != 0 || isTracedBySysctl(pid: pid) + + return PeerPostureFacts( + isTraced: traced, + hasHardenedRuntime: (status & csRuntime) != 0, + signatureValid: (status & csValid) != 0, + isReadable: true + ) + } + + /// The daemon's view of itself, which is how it decides how much to demand of + /// anyone else. + public func selfFacts() -> PeerPostureFacts { + return facts(forPid: getpid()) + } + + private func isTracedBySysctl(pid: pid_t) -> Bool { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var info = kinfo_proc() + var size = MemoryLayout.size + guard sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 else { return false } + return (info.kp_proc.p_flag & pTraced) != 0 + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/ProcessProvider.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ProcessProvider.swift index f6f793c78..5777e1b23 100644 --- a/packages/encryption-binary-swift/swift/Sources/SessionScoping/ProcessProvider.swift +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/ProcessProvider.swift @@ -5,7 +5,19 @@ import Darwin @_silgen_name("proc_pidpath") private func proc_pidpath(_ pid: Int32, _ buffer: UnsafeMutablePointer, _ buffersize: UInt32) -> Int32 +// Same story for proc_pidinfo, which is how a process's current directory is +// read. Only ever used to turn a relative script argument into a real path. +@_silgen_name("proc_pidinfo") +private func proc_pidinfo( + _ pid: Int32, + _ flavor: Int32, + _ arg: UInt64, + _ buffer: UnsafeMutableRawPointer?, + _ buffersize: Int32 +) -> Int32 + private let PROC_PIDPATHINFO_MAXSIZE: UInt32 = 4096 +private let PROC_PIDVNODEPATHINFO: Int32 = 9 /// A snapshot of the OS-level facts the session scoper needs about one process. /// @@ -36,10 +48,20 @@ public protocol ProcessProvider { func environment(for pid: pid_t) -> [String: String]? func arguments(for pid: pid_t) -> [String]? func path(for pid: pid_t) -> String? + /// The process's current directory, which is the only way a relative script + /// argument ("bun run scripts/agent.ts") becomes a real file on disk. + /// nil whenever it cannot be read, which costs one icon and nothing else. + func workingDirectory(for pid: pid_t) -> String? func ttyName(forDevice dev: dev_t) -> String? func sessionLeader(for pid: pid_t) -> pid_t } +extension ProcessProvider { + /// Providers written before this existed (and test doubles that do not care) + /// answer "cannot be read", which is a supported answer everywhere it is used. + public func workingDirectory(for pid: pid_t) -> String? { return nil } +} + /// The production `ProcessProvider`, backed by `sysctl(KERN_PROC*)`, /// `getsid`, `devname`, and `proc_pidpath`. public final class LiveProcessProvider: ProcessProvider { @@ -81,6 +103,24 @@ public final class LiveProcessProvider: ProcessProvider { return String(cString: buffer) } + public func workingDirectory(for pid: pid_t) -> String? { + var info = proc_vnodepathinfo() + let size = Int32(MemoryLayout.size) + let read = withUnsafeMutablePointer(to: &info) { pointer in + proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, UnsafeMutableRawPointer(pointer), size) + } + // Anything short of a full struct is a process we are not allowed to + // read (another user's, or one that exited): no directory, no guess. + guard read == size else { return nil } + var buffer = info.pvi_cdir.vip_path + let path = withUnsafeBytes(of: &buffer) { raw -> String? in + guard let base = raw.bindMemory(to: CChar.self).baseAddress else { return nil } + return String(cString: base) + } + guard let path, !path.isEmpty else { return nil } + return path + } + public func arguments(for pid: pid_t) -> [String]? { var argMax: Int32 = 0 var argMaxSize = MemoryLayout.size diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/RequesterDescription.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/RequesterDescription.swift new file mode 100644 index 000000000..476edcfce --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/RequesterDescription.swift @@ -0,0 +1,107 @@ +import Foundation +import Darwin + +/// A plain-language description of the process that is asking for something. +/// +/// This is the trust-bearing half of an approval panel: every line here is read +/// off the peer process by the daemon itself, so a caller cannot dress itself up +/// as something else. It goes through the same `ProcessProvider` abstraction as +/// session scoping, so it is testable against synthetic process trees. +public struct RequesterDescription: Equatable { + /// Executable names from the connecting process outward, nearest first. + public let processChain: [String] + /// Controlling terminal device name, when there is one. + public let terminalName: String? + + public init(processChain: [String], terminalName: String?) { + self.processChain = processChain + self.terminalName = terminalName + } + + /// "node ← claude ← zsh", or a fallback when nothing could be read. + public var chainSummary: String { + return processChain.isEmpty ? "unknown process" : processChain.joined(separator: " ← ") + } + + /// "Terminal ttys004" or "No terminal (background process)". + public var sessionSummary: String { + guard let terminalName else { return "No terminal (background process)" } + return "Terminal \(terminalName)" + } + + /// The lines a panel shows, most specific first. + public var panelLines: [String] { + return ["Requested by \(chainSummary)", sessionSummary] + } + + /// The one line the panel shows at rest. + /// + /// The nearest process is the one that actually connected, and the terminal is + /// how a person recognises their own window, so those two answer "is this me?" + /// without making anyone read an ancestry chain. The chain is still one click + /// away in `detailLines`. + public var summaryLine: String { + let nearest = processChain.first ?? "unknown process" + guard let terminalName else { return "Requested by \(nearest)" } + return "Requested by \(nearest) in \(terminalName)" + } + + /// The full picture, shown when the panel's disclosure is opened. + public var detailLines: [String] { + var lines = ["Process: \(chainSummary)"] + lines.append(sessionSummary) + return lines + } + + /// The same facts on one line, for the authorization log: + /// "node ← claude ← zsh (ttys004)". + public var auditSummary: String { + guard let terminalName else { return chainSummary } + return "\(chainSummary) (\(terminalName))" + } +} + +public struct RequesterDescriber { + /// How far up the tree to look. Deep enough to reach the app or shell that + /// started things, short enough to stay readable on a panel. + public static let maxChainLength = 5 + + private let provider: ProcessProvider + + public init(provider: ProcessProvider) { + self.provider = provider + } + + public func describe(forPid pid: pid_t) -> RequesterDescription { + var names: [String] = [] + var current = pid + var terminal: String? + + for _ in 0.. 0 { + terminal = provider.ttyName(forDevice: info.tty) + } + if let name = processName(current) { + // Collapse a repeated wrapper (a shell exec'ing a shell) so the + // line says something rather than repeating itself. + if names.last != name { names.append(name) } + } + guard let ppid = provider.info(for: current)?.ppid, ppid > 1 else { break } + current = ppid + } + + return RequesterDescription(processChain: names, terminalName: terminal) + } + + private func processName(_ pid: pid_t) -> String? { + guard let path = provider.path(for: pid) else { return nil } + let name = (path as NSString).lastPathComponent + return name.isEmpty ? nil : name + } +} + +/// Convenience wrapper using the live, OS-backed provider. +public func describeRequester(forPid pid: pid_t) -> RequesterDescription { + return RequesterDescriber(provider: LiveProcessProvider()).describe(forPid: pid) +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionLabel.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionLabel.swift new file mode 100644 index 000000000..1e7bd8303 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionLabel.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Turns a session identifier back into something a person recognises. +/// +/// Session ids are built to be stable and unforgeable, not readable: they carry +/// device numbers, start timestamps, and session UUIDs. The menu bar has to name +/// a session in a few words, so this reads the shapes `SessionScoper` produces +/// and says what each one is, dropping the parts that only exist to make the id +/// unique. The env-var forms deliberately lose their value: it identifies an +/// agent session and has no business being on screen. +public enum SessionLabel { + /// A short name for one session, or a truncated id when the shape is unknown. + public static func describe(sessionId: String) -> String { + // "env:KEY:value|": the anchor is the interesting half. + if sessionId.hasPrefix("env:") { + let parts = sessionId.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + let envKey = envKeyName(String(parts[0])) + if parts.count == 2, !parts[1].isEmpty { + return "\(describe(sessionId: String(parts[1]))), \(envKey)" + } + return envKey + } + + if let terminal = terminal(sessionId: sessionId) { return terminal } + + let fields = sessionId.split(separator: ":", omittingEmptySubsequences: false) + switch fields.first { + case "ptree": + guard fields.count >= 2, !fields[1].isEmpty else { break } + return "Process \(fields[1])" + default: + break + } + + return truncated(sessionId) + } + + /// The terminal a session is on, when it is on one: "Terminal ttys004", or + /// "Terminal ttys005 (tmux)" inside a multiplexer. + /// + /// A tty id is stated exactly once in the panel, on the session-root row, + /// and this is where that text comes from: read back out of the identifier + /// the grant is keyed by, so the row a person reads and the session a grant + /// attaches to can never name two different terminals. + public static func terminal(sessionId: String) -> String? { + // "env:KEY:value|": the terminal, if any, is in the anchor half. + if sessionId.hasPrefix("env:") { + let parts = sessionId.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[1].isEmpty else { return nil } + return terminal(sessionId: String(parts[1])) + } + + let fields = sessionId.split(separator: ":", omittingEmptySubsequences: false) + guard fields.first == "tty", fields.count >= 2, !fields[1].isEmpty else { return nil } + // A fourth field is the multiplexer signal, "TMUX=,,". + if fields.count >= 4, let multiplexer = multiplexerName(String(fields[3])) { + return "Terminal \(fields[1]) (\(multiplexer))" + } + return "Terminal \(fields[1])" + } + + /// "env:CLAUDE_CODE_SESSION_ID:abc123" -> "Claude Code session". + private static func envKeyName(_ field: String) -> String { + let parts = field.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count >= 2 else { return "agent session" } + switch parts[1] { + case "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID": return "Claude Code session" + case "CODEX_THREAD_ID": return "Codex session" + default: return "agent session" + } + } + + /// "TMUX=/private/tmp/tmux-501/default,123,0" -> "tmux". + private static func multiplexerName(_ field: String) -> String? { + guard let key = field.split(separator: "=", maxSplits: 1).first else { return nil } + switch key { + case "TMUX": return "tmux" + case "STY": return "screen" + case "ZELLIJ", "ZELLIJ_SESSION_NAME": return "zellij" + default: return nil + } + } + + private static func truncated(_ sessionId: String, limit: Int = 28) -> String { + guard sessionId.count > limit else { return sessionId } + return String(sessionId.prefix(limit)) + "..." + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionScoper.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionScoper.swift index fd016e542..2c92fb4b4 100644 --- a/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionScoper.swift +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/SessionScoper.swift @@ -15,6 +15,42 @@ import Darwin /// when present, is *combined with* the TTY/process-tree anchor — never trusted /// on its own. This means a rogue process that forges the env var still gets a /// distinct identity unless it is genuinely inside the same process subtree. +/// Where a session identity comes from: the process it is anchored to, and the +/// identifier computed from that process. +/// +/// A grant is scoped to the identifier. A person can only be asked about the +/// process, so both travel together: the panel's "session root" row is the hop +/// this names, and there is no second place deciding which one that is. +public struct SessionAnchor: Equatable { + public enum Kind: Equatable { + /// A controlling terminal, anchored at the outermost process on it. + case terminal + /// No terminal, so a stable ancestor in the process tree. + case processTree + /// Neither: an agent's own session id, with no process to point at. + case environment + } + + /// The process the identity is computed from. nil only in the anchorless + /// environment case, where the session is a value and not a process. + public let pid: pid_t? + public let kind: Kind + public let identifier: String + + public init(pid: pid_t?, kind: Kind, identifier: String) { + self.pid = pid + self.kind = kind + self.identifier = identifier + } + + /// What to call this session in a few words, e.g. "Terminal ttys004". + public var label: String { SessionLabel.describe(sessionId: identifier) } + + /// The terminal this session is on, when it is on one. nil for a session + /// anchored on the process tree or on an agent's own session id. + public var terminal: String? { SessionLabel.terminal(sessionId: identifier) } +} + public struct SessionScoper { private let provider: ProcessProvider @@ -77,15 +113,30 @@ public struct SessionScoper { /// Returns nil only when no ancestor can be determined (chain shorter than 2) /// and there is no LLM-session env identifier either. public func sessionIdentifier(forPid pid: pid_t) -> String? { + return sessionAnchor(forPid: pid)?.identifier + } + + /// The same answer, with the process it was computed from. + /// + /// The identifier alone is enough to scope a grant and not enough to show + /// one: "This session" is a promise about a particular process, and the panel + /// has to be able to point at the row that process is on. Both come from here + /// so the row the user reads and the identity the grant attaches to can never + /// be two different decisions. + public func sessionAnchor(forPid pid: pid_t) -> SessionAnchor? { guard let info = provider.info(for: pid) else { return nil } - let parentSessionId = parentSessionIdentifier(forPid: pid, info: info) + let anchor = parentSessionAnchor(forPid: pid, info: info) let aiSession = aiSessionFromEnvironment(forPid: pid) - if let aiSession, let parentSessionId { - return "env:\(aiSession.key):\(aiSession.value)|\(parentSessionId)" + if let aiSession, let anchor { + return SessionAnchor( + pid: anchor.pid, + kind: anchor.kind, + identifier: "env:\(aiSession.key):\(aiSession.value)|\(anchor.identifier)" + ) } - if let parentSessionId { - return parentSessionId + if let anchor { + return anchor } if let aiSession { // No TTY and no walkable process tree (e.g. a process reparented to @@ -95,7 +146,11 @@ public struct SessionScoper { // to `noTtySessionEnvKeys` can't scope a shared session. Fail closed // → the daemon re-authenticates on every call. if aiSession.value.count >= Self.minStandaloneSessionIdLength { - return "env:\(aiSession.key):\(aiSession.value)" + return SessionAnchor( + pid: nil, + kind: .environment, + identifier: "env:\(aiSession.key):\(aiSession.value)" + ) } return nil } @@ -104,11 +159,11 @@ public struct SessionScoper { // MARK: - Anchor selection - private func parentSessionIdentifier(forPid pid: pid_t, info: ProcSnapshot) -> String? { - if let ttyId = ttySessionIdentifier(forPid: pid, info: info) { - return ttyId + private func parentSessionAnchor(forPid pid: pid_t, info: ProcSnapshot) -> SessionAnchor? { + if let ttyAnchor = ttySessionAnchor(forPid: pid, info: info) { + return ttyAnchor } - return processTreeSessionIdentifier(forPid: pid) + return processTreeSessionAnchor(forPid: pid) } private func parentPid(of pid: pid_t) -> pid_t? { @@ -127,7 +182,7 @@ public struct SessionScoper { return nil } - private func ttySessionIdentifier(forPid pid: pid_t, info: ProcSnapshot) -> String? { + private func ttySessionAnchor(forPid pid: pid_t, info: ProcSnapshot) -> SessionAnchor? { // e_tdev is dev_t (Int32). NODEV is -1, so we require strictly > 0. let peerTty = info.tty guard peerTty > 0 else { return nil } @@ -179,12 +234,20 @@ public struct SessionScoper { if let mux = multiplexer { // Include the multiplexer value so separate tmux servers / sessions // stay distinct even if a TTY device name happens to collide. - return "tty:\(ttyName):\(startTimestamp):\(mux.key)=\(mux.value)" + return SessionAnchor( + pid: anchorPid, + kind: .terminal, + identifier: "tty:\(ttyName):\(startTimestamp):\(mux.key)=\(mux.value)" + ) } - return "tty:\(ttyName):\(startTimestamp)" + return SessionAnchor( + pid: anchorPid, + kind: .terminal, + identifier: "tty:\(ttyName):\(startTimestamp)" + ) } - private func processTreeSessionIdentifier(forPid pid: pid_t) -> String? { + private func processTreeSessionAnchor(forPid pid: pid_t) -> SessionAnchor? { // No TTY — walk up the process tree to find a scoping ancestor. // // Build the ancestry chain from the peer up to (but not including) PID 1. @@ -206,7 +269,11 @@ public struct SessionScoper { let chain = buildAncestryChain(from: pid) guard let scopePid = selectScopePid(from: chain) else { return nil } let startTime = provider.info(for: scopePid)?.startTime ?? 0 - return "ptree:\(scopePid):\(startTime)" + return SessionAnchor( + pid: scopePid, + kind: .processTree, + identifier: "ptree:\(scopePid):\(startTime)" + ) } // MARK: - Process-tree helpers diff --git a/packages/encryption-binary-swift/swift/Sources/SessionScoping/VarlockInvocation.swift b/packages/encryption-binary-swift/swift/Sources/SessionScoping/VarlockInvocation.swift new file mode 100644 index 000000000..7a17a755c --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/SessionScoping/VarlockInvocation.swift @@ -0,0 +1,107 @@ +import Foundation + +/// What of a varlock command line is worth putting on the panel. +/// +/// The whole line is read from the kernel's copy of the process's argv, so all of +/// it is trustworthy. Not all of it is useful, and a line nobody reads protects +/// nobody, so this keeps the parts that change what is being asked for and drops +/// the parts that only change how the answer is printed: +/// +/// ALWAYS the subcommand. "varlock" on its own says nothing; `varlock run` and +/// `varlock encrypt` are different requests. +/// +/// KEEP what changes the trust picture: everything after `--` (the command that +/// will receive the values), which environment was selected, which env files +/// were named, and any filter narrowing what is loaded. Anything unrecognised +/// is kept too: silence about a flag we do not know is the wrong default when +/// the point of the line is to be evidence. +/// +/// DROP presentation: output format, compactness, cache behaviour, verbosity, +/// colour. None of it changes which secrets go where. +/// +/// Length is handled by eliding the MIDDLE rather than the tail, so +/// `varlock run -- npm run build` never loses the half that says who is being +/// handed the values. +public enum VarlockInvocation { + /// Flags that carry no argument and say nothing about what is being asked + /// for. + static let droppedFlags: Set = [ + "--agent", "--compact", "--show-all", "--include-internal", "--summary-stderr", + "--clear-cache", "--skip-cache", "--cached", "--quiet", "--silent", "--verbose", + "-v", "--color", "--no-color", "--json", "--pretty", + ] + + /// Flags whose following token is their value, dropped as a pair. + static let droppedFlagsWithValue: Set = [ + "--format", "-f", "--summary-file", "--log-level", + ] + + /// Trim a normalised varlock command line ("varlock", subcommand, args...). + /// + /// Only ever called on a line that already starts at `varlock`; the caller + /// does that rewrite, since `bunx varlock load` and + /// `/opt/homebrew/bin/varlock load` are the same act said three ways. + public static func trimmed(_ tokens: [String]) -> [String] { + var kept: [String] = [] + var index = 0 + while index < tokens.count { + let token = tokens[index] + // Everything past `--` belongs to the command being run, and none of + // it is ours to judge. + if token == "--" { + kept.append(contentsOf: tokens[index...]) + break + } + let flag = token.split(separator: "=", maxSplits: 1).first.map(String.init) ?? token + if droppedFlagsWithValue.contains(flag) { + // "--format json" takes the next token with it; "--format=json" + // carries its value already. + index += token.contains("=") ? 1 : 2 + continue + } + if droppedFlags.contains(flag) { + index += 1 + continue + } + kept.append(token) + index += 1 + } + return kept + } + + /// The command a `varlock run` will start and hand the values to. + /// + /// That process does not exist yet, so it is in no chain and no ancestry. A + /// panel that stopped at varlock would imply the values stop there too. + public static func runTarget(_ tokens: [String]) -> String? { + guard tokens.first == "varlock" else { return nil } + guard let separator = tokens.firstIndex(of: "--") else { return nil } + let target = tokens[tokens.index(after: separator)...] + guard !target.isEmpty else { return nil } + return target.joined(separator: " ") + } + + /// Join a command line to fit, keeping the head and the `--` target. + /// + /// A tail truncation would cut off exactly the part worth reading, since the + /// target is always last. + public static func fit(_ tokens: [String], limit: Int) -> String { + let line = tokens.joined(separator: " ") + guard line.count > limit else { return line } + + guard let separator = tokens.firstIndex(of: "--") else { + return String(line.prefix(limit - 1)) + "\u{2026}" + } + let head = tokens[.. headBudget ? String(head.prefix(headBudget - 1)) + "\u{2026}" : head + let joined = shortHead + ellipsis + tail + guard joined.count > limit else { return joined } + return String(joined.prefix(limit - 1)) + "\u{2026}" + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/ApprovalPanel.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/ApprovalPanel.swift new file mode 100644 index 000000000..7fd792071 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/ApprovalPanel.swift @@ -0,0 +1,1367 @@ +import AppKit +import LocalAuthentication +import LocalAuthenticationEmbeddedUI +import IdentitySessions +import SessionScoping + +/// The panel the daemon draws when someone has to say yes. +/// +/// It answers three questions in the order a person asks them: what is being +/// unlocked, who is asking, and for how long. Then it offers one gesture. The +/// scan IS the approval: the presence check is armed as the panel opens, so on a +/// machine with Touch ID there is nothing to click in the common case. +/// +/// It is a window we draw ourselves rather than an `NSAlert`, because the layout +/// is the message: a key box that opens to say what the caller gets, and a +/// vertical chain that shows the line of processes leading to them with the one +/// hop that matters emphasised. An alert can hold text and buttons, and none of +/// that would fit inside one. +/// +/// The scan happens inside this window. `LAAuthenticationView`, bound to the +/// context this approval will run under, sits on its own above the buttons; +/// touching the sensor while the panel is up is the approval, and no system +/// alert appears over the top of it. +/// +/// It has to stand on its own. Inside the approve button it drew nothing, which +/// is the same blankness that got the view written off in the first place: every +/// place it has ever rendered (the probe window, and here) has had it as a plain +/// sibling view, unclipped and owning its own area, rather than nested in a +/// control that does its own drawing. +/// +/// That view was believed not to work. An earlier arc concluded it rendered +/// blank on macOS 26 and shipped a drawn glyph plus the system's alert instead. +/// The cause was never the view, the signature, the bundle, the window class, or +/// the modal session: `scripts/render-bisect.ts` measured all of them by +/// photographing the pixels, and the one axis that flipped rendering was HOW THE +/// PANEL IS PRESENTED FROM THE IPC THREAD. +/// +/// panel presented inside `DispatchQueue.main.sync` 1 distinct grey (blank) +/// same panel presented via `RunLoop.main.perform` 51 distinct greys (drawn) +/// +/// The daemon answers IPC on a background queue, so the panel used to be drawn +/// from inside a main-QUEUE work item that stays in flight for as long as the +/// modal is up. LocalAuthentication needs the main queue to render into the view +/// it was bound to, and a blocked main queue starves it: the view stays empty +/// forever, and because a bound view suppresses the system alert, nothing +/// anywhere asks for a finger. That is the same starvation that once stopped the +/// check from arming at all, which is why `MainLoop` exists; the presentation +/// itself was the last place still doing it the old way. +/// +/// So: present through the run loop, never through the main queue. +/// +/// The fallback is still there for the machines that cannot embed (no biometrics +/// enrolled, or the sensor locked out), and for `_VARLOCK_EMBEDDED_PROMPT=0`. +/// +/// This file is view only. What the panel says, which scopes it may offer, and +/// what a given answer means are decided in `IdentitySessions` +/// (`UnlockDecision.swift`, `PanelContent.swift`, `ApprovalFlow.swift`), so the +/// rules stay testable without a window server. +/// +/// It is drawn by the daemon on purpose. The daemon is the process that holds +/// the keys and the one that verified the peer, so it is the only party in a +/// position to say truthfully who is asking. A panel drawn by the caller would +/// be a panel the caller can lie on. +final class ApprovalPanel: NSObject { + /// How long the whole interaction may take before it counts as a refusal. + /// Below the client's 5 minute interactive timeout, so the caller gets a real + /// answer rather than a dead socket. + static let timeoutSeconds: TimeInterval = 120 + + /// How long the panel must have been on screen and in front before the scan + /// is armed. + /// + /// This used to be most of a second, for a good reason that has since gone + /// away: arming summoned the system's own sheet, which covered the panel, so + /// the delay was the only thing standing between a user and approving + /// something they never got to read. The scan happens inside the panel now. + /// There is nothing to occlude, so the wait buys nothing and costs the user a + /// sensor that lights up late. What is left is one frame's grace for the + /// window to finish coming up. + static let armingDelaySeconds: TimeInterval = 0.12 + + /// How long to keep waiting for the panel to actually be frontmost before + /// arming anyway. Something else stealing focus must not cost the user their + /// unlock. + static let readinessTimeoutSeconds: TimeInterval = 3 + + /// Ends the modal when the flow produced an answer, rather than when a + /// button's own handling did. + private static let flowFinishedResponse = NSApplication.ModalResponse(rawValue: 9001) + + /// What the panel answered, and the presence check that answered it. + struct Outcome { + let decision: PanelDecision + /// Present only when a presence check approved this. Handing this exact + /// context to the enclave is what keeps one scan covering the whole unlock. + let proof: IdentitySessionManager.PresenceProof? + } + + private var window: ApprovalPanelWindow? + /// The one "how long" control: `Once`, the timed rungs, and `This session`, + /// in one row that never hides anything. + private var windowControl: PanelSegmentedControl? + private var confirmButton: PanelButton? + private var passwordLink: PanelButton? + private var hintLabel: NSTextField? + private var statusLabel: NSTextField? + private var contentColumn: NSStackView? + /// Apple's scan surface, when this panel is on the embedded path. Owned by + /// the primary control; kept here so the panel can photograph it. + private var embeddedScanView: NSView? + /// The primary control on a machine that can scan: the button that is also + /// the sensor. + private var scanButton: PanelScanButton? + /// Deny and the approve control, as one row. Held so a render can measure + /// where it sits, which is the layout promise this panel makes. + private var actionRowView: NSView? + + /// The rungs this request may be answered with, in the order they are drawn. + private var windowOptions: [PanelWindowOption] = [] + /// The number and unit behind the `Custom` rung. + private var customControl: PanelDurationField? + /// What that sits in, so the row goes away with the rung it belongs to. + private var customRow: NSView? + /// Which rung `Custom` is, when the request offers one. + private var customOptionIndex: Int? + /// Preview only: draw the custom value in a unit other than the one it would + /// pick for itself, so the state a unit switch leaves behind can be + /// photographed. Never set on a panel anybody is looking at. + private var previewUnitOverride: DurationUnit? + /// The breadth checkbox, when this request has a breadth choice to make. + private var breadthControl: PanelCheckbox? + /// What the checkbox sits in, so hiding it takes its row with it. + private var breadthRow: NSView? + private var breadths: [SessionGrantBreadth] = [] + private var listedItemCount = 0 + private var vaultCount = 1 + /// The one sentence under the controls, kept current with all of them. + private var selectionSummaryLabel: NSTextField? + private var timedOut = false + private var flow: ApprovalFlow! + private var attempt: IdentitySessionManager.PresenceAttempt? + /// Bumped whenever the presence attempt is replaced, so a callback from an + /// attempt we walked away from cannot rewrite the panel's state. + private var attemptGeneration = 0 + private var presenceReason = "" + private var proof: IdentitySessionManager.PresenceProof? + /// Why the last check ended, which is what the hint line is about. + private var lastFailure: IdentitySessionManager.PresenceFailure.Kind = .failed + /// Guards against a presence callback arriving after the modal has ended. + private var modalRunning = false + /// The panel arms itself once, ever. Every later scan is a button press. + private var hasAutoArmed = false + + /// Show a panel and wait for the answer. + /// + /// Returns nil when the panel could not be drawn at all, which the caller + /// reports as `NO_UI`. A refusal comes back as a decision with `approved` + /// false, so callers can tell "the user said no" from "nobody could be asked". + /// + /// With an `attempt`, the panel carries a presence check: embedded (armed as + /// the panel opens, the scan is the answer) or the system dialog fallback + /// (raised by the approve button). Without one it is a plain button panel. + static func present( + content: PanelContent, + presenceReason: String = "", + attempt: IdentitySessionManager.PresenceAttempt? = nil + ) -> Outcome? { + guard UiAvailability.canShowUi() else { return nil } + + PanelDebug.note("present-called", [ + "mode": String(describing: attempt?.mode ?? .none), + "onMainThread": Thread.isMainThread, + ]) + var outcome: Outcome? + let work = { + let panel = ApprovalPanel() + outcome = panel.run(content: content, presenceReason: presenceReason, attempt: attempt) + } + if Thread.isMainThread { + work() + } else { + // Handed to the main thread's RUN LOOP, never wrapped in + // `DispatchQueue.main.sync`. That distinction is the whole reason the + // inline Touch ID view works at all; see the note at the top of this + // file. The IPC thread still waits here for the answer, which is what + // keeps the socket call synchronous. + let done = DispatchSemaphore(value: 0) + MainLoop.perform { + work() + done.signal() + } + done.wait() + } + PanelDebug.note("present-returned", ["approved": outcome?.decision.approved ?? false]) + return outcome + } + + /// A rendered panel, and the few measurements worth asserting about it. + struct Preview { + let png: Data + /// The panel's height in points. + let height: CGFloat + /// How far the bottom of the action row sits above the panel's bottom + /// edge. The property the layout actually owes the user: whatever the + /// content above it does, Deny and the scan control stay put, because + /// they are what a finger is already heading for while the sensor is + /// armed. Constant across every answer, and checked by + /// `scripts/panel-layout-check.ts` rather than left to the eye. + let actionRowInsetFromBottom: CGFloat + } + + /// Draw the panel to a PNG without showing it or asking anyone anything. + /// + /// The panel is the one part of this daemon whose correctness is visual, and + /// a screen it takes over is an awkward thing to inspect: it floats, it is + /// modal, and on a headless or remote session it cannot be looked at at all. + /// This renders the very same view tree the modal would put on screen, so a + /// layout can be checked by looking at the picture. Nothing is unlocked and + /// no presence check is started. + static func preview( + content: PanelContent, + mode: ApprovalPresenceMode, + expandChain: Bool = false, + expandKeys: Bool = false, + focusCustom: Bool = false, + customUnit: DurationUnit? = nil + ) -> Preview? { + let panel = ApprovalPanel() + panel.previewUnitOverride = customUnit + panel.windowOptions = content.windowOptions + panel.breadths = content.breadths + panel.listedItemCount = content.listedItemCount + panel.vaultCount = content.vaultCount + panel.flow = ApprovalFlow(content: content, presenceMode: mode) + let window = panel.buildWindow( + content: content, + mode: mode, + expandChain: expandChain, + expandKeys: expandKeys + ) + // A preview has no key window and so no field editor: the focused state + // is set on the view rather than reached by clicking, because a state + // nobody can render is a state nobody checks. + if focusCustom { panel.customControl?.setFocusedLook(true) } + guard let view = window.contentView else { return nil } + // Icons fill themselves in from the run loop, which a command that never + // runs one would never give them. Pump it briefly so the picture shows + // what the panel actually shows. + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + view.layoutSubtreeIfNeeded() + guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil } + view.cacheDisplay(in: view.bounds, to: rep) + guard let png = rep.representation(using: .png, properties: [:]) else { return nil } + let actionRowInset = panel.actionRowView.map { + view.convert($0.bounds, from: $0).minY + } ?? 0 + return Preview( + png: png, + height: view.bounds.height, + actionRowInsetFromBottom: actionRowInset + ) + } + + // MARK: - Running + + private func run( + content: PanelContent, + presenceReason: String, + attempt: IdentitySessionManager.PresenceAttempt? + ) -> Outcome { + SecureInputDialog.ensureEditMenu() + windowOptions = content.windowOptions + breadths = content.breadths + listedItemCount = content.listedItemCount + vaultCount = content.vaultCount + self.attempt = attempt + self.presenceReason = presenceReason + let mode = attempt?.mode ?? .none + flow = ApprovalFlow(content: content, presenceMode: mode) + if let attempt { + PanelDebug.note("presence-attempt", [ + "mode": String(describing: mode), + "contextInstance": String(UInt(bitPattern: ObjectIdentifier(attempt.context).hashValue), radix: 16), + "interactionNotAllowed": attempt.context.interactionNotAllowed, + ]) + } + + let window = buildWindow(content: content, mode: mode) + self.window = window + + // Float above whatever the user was looking at, and take focus, so an + // approval never ends up hidden behind an editor window. + window.level = .floating + positionOnScreen(window) + NSApp.activate(ignoringOtherApps: true) + PanelDebug.note("panel-shown", [ + "isVisible": window.isVisible, + "appIsActive": NSApp.isActive, + "mode": String(describing: mode), + ]) + + // Everything below is scheduled through `MainLoop`, never + // `DispatchQueue.main.async`. The daemon reaches this code from a + // background IPC thread via `DispatchQueue.main.sync`, so the main queue + // has a work item in flight for as long as the panel is up, and anything + // posted back to that queue would not run until the panel had already + // closed. That is what silently stopped the presence check from ever being + // armed: a panel with a glyph, a dead sensor, and no prompt anywhere. + timedOut = false + let deadline = MainLoop.after(Self.timeoutSeconds) { [weak self] in + guard let self, !self.timedOut, self.modalRunning else { return } + self.timedOut = true + PanelDebug.note("timed-out") + NSApp.abortModal() + } + + // Arm the prompt once the modal loop is running, so it has a live window. + modalRunning = true + MainLoop.perform { [weak self] in + guard let self else { return } + // The modal is up now, so take the front for real. If the system puts + // its own alert over us it is welcome to, but when that closes this + // panel has to be what the user is looking at, not something buried + // behind the window that stole focus. + self.bringToFront() + self.armWhenReadable(deadline: Date().addingTimeInterval(Self.readinessTimeoutSeconds)) + } + + let heartbeat = PanelDebug.isEnabled ? MainLoop.every(2) { [weak self] in + guard let self, let window = self.window else { return } + PanelDebug.note("heartbeat", [ + "state": String(describing: self.flow.state), + "isKeyWindow": window.isKeyWindow, + "isVisible": window.isVisible, + "appIsActive": NSApp.isActive, + "authAgentWindows": EmbeddedUnlockProbe.authAgentWindowOwners().joined(separator: ","), + ]) + } : nil + + _ = NSApp.runModal(for: window) + modalRunning = false + deadline.cancel() + heartbeat?.cancel() + window.orderOut(nil) + self.window = nil + + // The flow is the authority on what was answered, not the button code. + if timedOut { + _ = flow.apply(.timedOut) + } else if case .finished = flow.state { + // already answered, through the flow + } else { + _ = flow.apply(.cancelPressed) + } + + guard case .finished(let decision) = flow.state, decision.approved else { + invalidateProof() + return Outcome(decision: PanelDecision.denied(defaultScope: content.defaultScope), proof: nil) + } + return Outcome(decision: decision, proof: proof) + } + + /// Arm the scan only once the panel is genuinely readable. + /// + /// "Readable" is the whole point: on screen, in front, and there long enough + /// to have been read. The system's biometric sheet lands on top of us the + /// moment we evaluate, so anything armed before that is a question asked + /// behind a curtain. + private func armWhenReadable(deadline: Date) { + guard modalRunning, !hasAutoArmed else { return } + guard let window else { return } + + let readable = window.isVisible && window.isKeyWindow && NSApp.isActive + guard readable || Date() >= deadline else { + // Polled tightly: every tick here is a tick the sensor is not live. + _ = MainLoop.after(0.02) { [weak self] in self?.armWhenReadable(deadline: deadline) } + return + } + + // The conditions the inline view is supposed to need, asserted at the + // moment they matter rather than assumed. `sign-probe.ts` checks the same + // list; a panel that quietly fails one of these would look exactly like + // the bug this feature spent an arc chasing. + let scanView = embeddedScanView + PanelDebug.note("panel-readable", [ + "isKeyWindow": window.isKeyWindow, + "isVisible": window.isVisible, + "waitedForFront": readable, + "embeddedView": scanView != nil, + "embeddedFrame": scanView.map { "\(Int($0.frame.width))x\(Int($0.frame.height))" } ?? "-", + // Both frames, because a view with a fine size of its own can still + // be somewhere useless in the window, and that difference is exactly + // what nesting it in a button turned out to be. + "embeddedFrameInWindow": scanView.map { + let inWindow = $0.convert($0.bounds, to: nil) + return "\(Int(inWindow.origin.x)),\(Int(inWindow.origin.y)) \(Int(inWindow.width))x\(Int(inWindow.height))" + } ?? "-", + "embeddedAttached": scanView?.window != nil, + "embeddedVisible": scanView.map { !$0.isHidden && $0.alphaValue == 1 } ?? false, + ]) + _ = MainLoop.after(Self.armingDelaySeconds) { [weak self] in + guard let self, self.modalRunning, !self.hasAutoArmed else { return } + self.hasAutoArmed = true + PanelDebug.note("arming-after-delay", ["seconds": Self.armingDelaySeconds]) + self.perform(effect: self.flow.start()) + } + } + + /// Roughly how much of the middle of the screen macOS takes for its own + /// biometric sheet. Its exact size is not ours to know, so this is a + /// deliberate over-estimate: being too careful costs a little screen, being + /// too optimistic costs the user their view of what they are approving. + static let systemSheetHalfHeight: CGFloat = 190 + + /// Margin kept above the panel, and between the panel and the sheet. + static let screenMargin: CGFloat = 44 + + /// Put the panel where the system's own sheet cannot cover what matters. + /// + /// macOS centres its biometric sheet, and it lands on top of us: a panel + /// centred too was a panel the user could not read while the scan they were + /// answering was live. So the panel is anchored high instead, and the layout + /// puts everything that decides an answer (what is being unlocked, and who + /// is asking) above everything that merely takes it (scope, buttons). What + /// the sheet covers is the bottom of the panel. + /// + /// A panel too tall to clear the sheet entirely still sits as high as it + /// fits, so the top of it stays readable. + private func positionOnScreen(_ window: NSWindow) { + guard let screen = NSScreen.main else { return } + let frame = window.frame + let visible = screen.visibleFrame + + // As high as the screen allows. + let topAnchored = visible.maxY - Self.screenMargin - frame.height + // Or lower, if the panel is short enough to sit clear of the sheet and + // still be near the middle where the eye is. + let clearOfSheet = visible.midY + Self.systemSheetHalfHeight + Self.screenMargin + let y = max(min(topAnchored, clearOfSheet), visible.minY + Self.screenMargin) + + window.setFrameOrigin(NSPoint(x: visible.midX - frame.width / 2, y: y)) + PanelDebug.note("panel-positioned", [ + "y": Int(y), + "height": Int(frame.height), + "clearsSystemSheet": y >= clearOfSheet, + ]) + } + + /// Take the front, so the panel is what the user sees. + /// + /// Called once the modal is running, and again whenever a presence check ends + /// without an answer: the system's own alert steals focus while it is up, and + /// when it goes away the panel underneath has to come back rather than sit + /// behind whatever was in front before. + private func bringToFront() { + guard let window else { return } + window.level = .floating + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + PanelDebug.note("brought-to-front", [ + "isKeyWindow": window.isKeyWindow, + "isVisible": window.isVisible, + "appIsActive": NSApp.isActive, + ]) + } + + /// A context nobody is going to use must not be left alive. + private func invalidateProof() { + proof?.context.invalidate() + proof = nil + } + + // MARK: - Flow + + private func perform(effect: ApprovalFlowEffect) { + PanelDebug.note("flow-effect", [ + "effect": String(describing: effect), + "state": String(describing: flow.state), + "scope": flow.scope.rawValue, + ]) + updateGlyph() + switch effect { + case .beginScan: + setStatus(flow.presenceMode == .embedded + ? "Touch the sensor to approve." + : "Waiting for your password.") + confirmButton?.isEnabled = flow.presenceMode != .embedded + beginScan() + watchForSystemAlert() + case .showControls: + confirmButton?.isEnabled = true + setStatus(failureHint()) + // A check that just ended may have had a system alert over us. + if flow.failedScans > 0 { bringToFront() } + case .finish(let decision): + let code: NSApplication.ModalResponse = decision.approved + ? Self.flowFinishedResponse + : .cancel + // Let an approval land before the window goes. Closing on the same + // frame as the scan reads as the panel vanishing rather than as the + // unlock completing, and the glyph has just turned green to say so. + if decision.approved, glyphShowsSuccessAnimation { + _ = MainLoop.after(TouchIDGlyphView.successHoldSeconds) { + NSApp.stopModal(withCode: code) + } + } else { + NSApp.stopModal(withCode: code) + } + } + } + + /// Watch for the system drawing its own alert while the embedded view is + /// armed, which is the failure this path exists to avoid. + /// + /// Only under panel debugging: it costs a window-list scan per sample, and + /// its only reader is the end-to-end check that asserts the alert stays away. + private func watchForSystemAlert() { + guard PanelDebug.isEnabled, flow.presenceMode == .embedded else { return } + for delay in [0.4, 1.2, 2.5] { + _ = MainLoop.after(delay) { [weak self] in + guard let self, self.modalRunning else { return } + PanelDebug.note("auth-agent-scan", [ + "afterSeconds": delay, + "windows": EmbeddedUnlockProbe.authAgentWindowOwners().joined(separator: ","), + ]) + // And whether anything was actually drawn where the scan is + // supposed to be. A blank inline view with no system alert is the + // worst of the three outcomes: nothing anywhere is listening for + // a finger, and it looks like a working panel. + guard let scanView = self.embeddedScanView else { return } + PanelDebug.note("scan-pixels", WindowPixels.sample(scanView).asDictionary) + } + } + } + + /// Whether an approval is going to be animated, which is the only reason to + /// hold the panel open a moment longer. + private var glyphShowsSuccessAnimation: Bool { + // The system's own view animates its success too, and closing on the same + // frame as the scan would cut that off just as our drawn glyph's pop was + // being cut off before. + guard confirmButton?.glyphView != nil || embeddedScanView != nil else { return false } + return PanelGlyph.effect( + for: .approved, + reduceMotion: TouchIDGlyphView.reduceMotion + ).isAnimated + } + + /// Push the flow's current glyph state to the view. + private func updateGlyph() { + guard let glyphView = confirmButton?.glyphView else { return } + let effect = PanelGlyph.effect( + for: flow.glyphState, + reduceMotion: TouchIDGlyphView.reduceMotion + ) + PanelDebug.note("glyph", [ + "glyphState": String(describing: flow.glyphState), + "effect": String(describing: effect), + ]) + glyphView.apply(effect) + } + + private func beginScan() { + guard let attempt else { + PanelDebug.note("begin-scan-skipped", ["reason": "no presence attempt"]) + return + } + PanelDebug.note("evaluatePolicy-invoked", [ + "mode": String(describing: attempt.mode), + "contextInstance": String(UInt(bitPattern: ObjectIdentifier(attempt.context).hashValue), radix: 16), + "reason": presenceReason, + ]) + let generation = attemptGeneration + attempt.evaluate(reason: presenceReason) { [weak self] result in + switch result { + case .success: + PanelDebug.note("evaluatePolicy-completed", ["success": true]) + case .failure(let error): + PanelDebug.note("evaluatePolicy-completed", [ + "success": false, + "error": error.localizedDescription, + ]) + } + guard let self, self.modalRunning, generation == self.attemptGeneration else { + if case .success(let proof) = result { proof.context.invalidate() } + return + } + switch result { + case .success(let proof): + self.proof = proof + // Read the controls now, not when the panel opened: nothing was + // modal over them, so what is selected at this instant is what the + // user meant to approve. + self.syncSelectionIntoFlow() + self.perform(effect: self.flow.apply(.scanSucceeded)) + case .failure(let error): + // Not a refusal, and never re-armed on its own: the panel goes + // back to resting, and only a click asks the system again. + let failure = error as? IdentitySessionManager.PresenceFailure + self.lastFailure = failure?.kind ?? .failed + if failure?.kind == .wantsPassword { + // The user asked for the password from inside the sheet. + // Move the panel onto that path, but do not present anything: + // the next sheet is the one they click for. + self.switchToPassword(present: false) + } + self.perform(effect: self.flow.apply(.scanFailed)) + } + } + } + + /// What the panel says after a check ended without an answer. + /// + /// Dismissing the sheet is the common one and is not a failure of anything: + /// it says so plainly and points at the button, because that button is the + /// only thing that will ask again. + private func failureHint() -> String { + guard flow.failedScans > 0 else { return "" } + let button = scanButton?.isHidden == false ? "Approve with Touch ID" : (confirmButton?.title ?? "Approve") + // Once the approval has moved onto the password there is no scan to talk + // about, and a hint about Touch ID would be describing a control that is + // no longer on the panel. + let onPassword = flow.presenceMode != .embedded + switch lastFailure { + case .cancelled: + return onPassword + ? "Password canceled. Click \(button) to try again, or Deny to refuse." + : "Touch ID canceled. Click \(button) to scan again, or Deny to refuse." + case .wantsPassword: + return "Click \(button) to enter your password, or Deny to refuse." + case .failed: + return flow.failedScans > 1 + ? "Still not verified. Adjust how long to allow if you want, then click \(button), or Deny to refuse." + : "Not verified. Click \(button) to try again, or Deny to refuse." + } + } + + private func setStatus(_ text: String) { + statusLabel?.stringValue = text + statusLabel?.isHidden = text.isEmpty + relayout() + } + + // MARK: - Controls + + @objc private func confirmPressed(_ sender: Any) { + syncSelectionIntoFlow() + perform(effect: flow.apply(.confirmPressed)) + } + + @objc private func denyPressed(_ sender: Any) { + perform(effect: flow.apply(.cancelPressed)) + } + + /// The way out for a finger the sensor will not read. + /// + /// Not a second panel and not a second question: the same approval, checked + /// the other way. One click gets a password field, with no fingerprint asked + /// for on the way there; how that is arranged is `passwordFallback`'s note. + /// The biometric attempt is dropped first so the machine is never listening + /// on two contexts at once, and the callback from the one we walked away from + /// is ignored by generation. + @objc private func usePasswordPressed(_ sender: Any) { + // A click on the link is an invitation, so this one does present. + switchToPassword(present: true) + } + + /// Move this approval onto the device-password check. + /// + /// `present` says whether to raise the system sheet now. It is only ever true + /// for a click: the panel does not put a sheet on screen that nobody asked + /// for, however the biometric check ended. + private func switchToPassword(present: Bool) { + guard let fallback = attempt?.passwordFallback() else { + if present { setStatus("This Mac has no password check available.") } + return + } + PanelDebug.note("switch-to-password", ["present": present]) + attemptGeneration += 1 + attempt?.context.invalidate() + attempt = fallback + + let scope = flow.scope + let durationMs = flow.durationMs + flow = ApprovalFlow(defaultScope: scope, presenceMode: .systemDialog) + flow.select(scope: scope, durationMs: durationMs) + + // The scan control goes with the scan: leaving Apple's sensor on screen + // while a password is being asked for would invite a finger that nothing + // is listening for. The plain button was built for this moment. + scanButton?.isHidden = true + confirmButton?.isHidden = false + confirmButton?.title = "Approve with password" + confirmButton?.glyphView?.isHidden = true + passwordLink?.isHidden = true + hintLabel?.stringValue = "" + relayout() + guard present else { + perform(effect: .showControls) + return + } + perform(effect: flow.apply(.confirmPressed)) + } + + private func windowChanged() { + syncSelectionIntoFlow() + PanelDebug.note("window-chosen", [ + "scope": flow.scope.rawValue, + "ms": Int(flow.durationMs ?? 0), + ]) + // Picking the custom rung is picking a number, so the caret goes where + // the number is set. Focus approves nothing: it changes what a keystroke + // does, never what a scan would grant. + if windowControl?.selectedIndex == customOptionIndex { + customControl?.focusField() + } + // The checkbox and the custom row appear and disappear with the answer, + // so the content above the buttons is a different height. Re-fit around + // the action row rather than around the top edge. + relayout(anchor: .actionRow) + } + + /// The number changed, by typing or by a unit switch. + /// + /// Only the sentence under the controls moves. The rung says `Custom` + /// whatever the number is, so nothing in the ladder is relabelled and + /// nothing reflows while somebody types under an armed sensor. What a scan + /// would grant is still read live from the field; see `selectedWindow`. + private func customValueChanged() { + syncSelectionIntoFlow() + } + + /// Show the number only where there is a number to set. + private func syncCustomVisibility() { + guard let customRow else { return } + customRow.isHidden = windowControl?.selectedIndex != customOptionIndex + } + + /// Show the breadth checkbox only where there is a breadth to choose. + /// + /// `once` grants narrow and draws no checkbox: see + /// `ApprovalFlow.effectiveBreadth` for why that combination is the one worth + /// keeping. Hidden rather than disabled on purpose. A greyed-out control + /// asks "why can't I tick that?", and the honest answer is a paragraph + /// about how batches are put together, which is not something to make + /// somebody read while a sensor is waiting for their finger. The summary + /// sentence underneath still says what the grant covers, so nothing about + /// this is hidden state. + /// + /// Its whole row goes with it. Holding an empty row open so the panel keeps + /// one height was the old answer, and it left a visible band of nothing + /// under `once`. What actually matters is that the buttons do not move under + /// a pointer while the sensor is armed, and `relayout(anchor:)` keeps them + /// still by moving the window instead. + private func syncBreadthVisibility() { + breadthRow?.isHidden = selectedWindow(fallback: flow.window).scope == .once + } + + private func breadthChanged() { + syncSelectionIntoFlow() + relayout() + } + + /// Copy the controls' current state into the flow, so what a scan approves is + /// what the panel is showing. + private func syncSelectionIntoFlow() { + let window = selectedWindow(fallback: flow.window) + let breadth = selectedBreadth(fallback: flow.breadth) + flow.select( + scope: window.scope, + durationMs: window.durationMs, + breadth: breadth + ) + syncBreadthVisibility() + syncCustomVisibility() + // Taken from the flow rather than from the checkbox, so the sentence and + // the grant can never disagree: under `once` the checkbox is not the + // answer, and this is the line that has to say so. + selectionSummaryLabel?.stringValue = PanelContent.selectionSummary( + breadth: flow.effectiveBreadth, + itemCount: listedItemCount, + vaultCount: vaultCount, + scope: window.scope, + // The sentence says "45 minutes" where the rung says "45min": a row + // of rungs is a scale, and a sentence is prose. Derived from the + // same milliseconds the grant will carry, including a number still + // being typed, so the words are never a stale echo of the field. + durationLabel: window.durationMs.map { DurationText.prose($0) } + ) + } + + /// Ticked is broad. Absent (nothing to narrow to) keeps whatever the flow + /// was built with, which is the broad answer. + private func selectedBreadth(fallback: SessionGrantBreadth) -> SessionGrantBreadth { + guard let breadthControl else { return fallback } + return breadthControl.isChecked ? .wholeKey : .listedItems + } + + /// What a yes right now would carry, read off the controls at this instant. + /// + /// On the custom rung the value comes from the FIELD, not from the option + /// the row was built with. The sensor stays armed while somebody types, so a + /// scan can land between two keystrokes; reading the field here is what makes + /// the rule "you get what the panel is showing" true without anything having + /// to be committed first. A partial number is a prefix of the intended one, + /// so the worst a scan mid-word can do is grant a shorter window than was + /// being aimed at. + private func selectedWindow(fallback: GrantWindow) -> GrantWindow { + guard let index = windowControl?.selectedIndex, + index >= 0, + index < windowOptions.count else { + return fallback + } + let option = windowOptions[index] + guard option.kind == .custom, let live = customControl?.liveValue else { + return option.window + } + return GrantWindow(scope: .duration, durationMs: live.milliseconds) + } + + /// Which edge of the panel holds still when its content changes height. + private enum RelayoutAnchor { + /// The top edge, for a disclosure the user opened: what they clicked + /// stays where they clicked it and the panel grows downward. + case top + /// The action row, for a change in the approval controls: Deny and the + /// scan control keep their place on screen and the content above them + /// absorbs the difference. The sensor is armed the whole time the + /// controls are live, so this is the row that must not move. + case actionRow + } + + /// Re-fit the window after something opened or closed. The panel grows and + /// shrinks with its disclosures rather than scrolling, so the window has to + /// follow its content. + private func relayout(anchor: RelayoutAnchor = .top) { + guard let window, let contentColumn else { return } + contentColumn.layoutSubtreeIfNeeded() + let height = contentColumn.fittingSize.height + PanelStyle.contentInset * 2 + let width = PanelStyle.contentWidth + PanelStyle.contentInset * 2 + var frame = window.frame + let topEdge = frame.maxY + let bottomEdge = frame.minY + frame.size = window.frameRect(forContentRect: NSRect(x: 0, y: 0, width: width, height: height)).size + switch anchor { + case .top: + // Grow downward from where the panel already is, so an expanding row + // does not move the buttons out from under the pointer. + frame.origin.y = topEdge - frame.height + case .actionRow: + // The action row sits a fixed distance above the panel's bottom edge + // (its own row, the hint line under it, and the inset), so holding + // the bottom edge still holds the buttons still. + frame.origin.y = bottomEdge + } + // Unless growing downward would push it off the screen, in which case it + // climbs instead: the top of the panel is the part that has to stay. + if let visible = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame { + let lowest = visible.minY + Self.screenMargin + if frame.origin.y < lowest { + frame.origin.y = min(lowest, visible.maxY - Self.screenMargin - frame.height) + } + // And a panel already at the top of the screen that grows upward + // would push its own heading off it. Steadiness is worth a lot on + // this row, but not the part of the panel that says what is being + // approved, so in that one case the buttons move instead. + let highest = visible.maxY - Self.screenMargin + if frame.maxY > highest { + frame.origin.y = max(lowest, highest - frame.height) + } + } + window.setFrame(frame, display: true, animate: false) + } + + // MARK: - Building the view + + private func buildWindow( + content: PanelContent, + mode: ApprovalPresenceMode, + expandChain: Bool = false, + expandKeys: Bool = false + ) -> ApprovalPanelWindow { + let column = PanelStyle.column(spacing: 0) + column.translatesAutoresizingMaskIntoConstraints = false + contentColumn = column + + column.addArrangedSubview(topBar(content)) + column.setCustomSpacing(14, after: column.arrangedSubviews.last!) + + let hero = PanelStyle.heading(content.titleSegments, size: 17) + column.addArrangedSubview(centred(hero)) + if let subtitle = content.subtitle { + let sub = PanelStyle.label(subtitle, size: 12, color: PanelStyle.inkSecondary) + sub.alignment = .center + column.setCustomSpacing(3, after: hero.superview ?? hero) + column.addArrangedSubview(centred(sub)) + } + + if !content.keyRows.isEmpty { + let box = PanelKeyBoxView( + rows: content.keyRows, + startExpanded: expandKeys + ) { [weak self] in self?.relayout() } + box.translatesAutoresizingMaskIntoConstraints = false + column.setCustomSpacing(13, after: column.arrangedSubviews.last!) + column.addArrangedSubview(box) + box.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth).isActive = true + } + + for note in content.notes { + let label = PanelStyle.label(note, size: 11, color: PanelStyle.inkTertiary) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 3 + label.preferredMaxLayoutWidth = PanelStyle.contentWidth + column.setCustomSpacing(8, after: column.arrangedSubviews.last!) + column.addArrangedSubview(label) + } + + // How varlock came to be running. The command lines in it are read from + // the kernel; the mode that frames them was reported by the client, so a + // claim the chain contradicts is overruled here, and the disagreement is + // recorded rather than drawn: the user gets the conclusion, and whoever + // is debugging gets the argument. + let invocation = InvocationEvidence.note( + chain: content.requester.chain ?? .empty, + claimed: content.invocationMode + ) + if let disagreement = invocation.disagreement { + PanelDebug.note("invocation-mode-overruled", [ + "claimed": content.invocationMode?.rawValue ?? "-", + "reason": disagreement, + ]) + } + let chain = PanelChainView( + chain: content.requester.chain ?? .empty, + fallbackSummary: content.requester.summary, + invocation: invocation, + sessionAdvisories: content.sessionAdvisories, + reportedVarlockVersion: content.reportedVarlockVersion, + startExpanded: expandChain + ) { [weak self] in self?.relayout() } + chain.translatesAutoresizingMaskIntoConstraints = false + column.setCustomSpacing(14, after: column.arrangedSubviews.last!) + column.addArrangedSubview(chain) + chain.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth).isActive = true + + // How long, as ONE row: Once, the timed rungs, Custom, then This session. + // + // This was a mode pill plus a row of windows that appeared underneath it + // once "for a set time" was picked. Two controls for one question, where + // the second one was hidden most of the time, so the timed answers cost + // two clicks and the panel had to hold an empty band open under every + // other answer to keep its height. One row of rungs is the same choice + // with nothing hidden and nothing reserved, and the order says something + // the labels cannot: this is a ladder, and you are picking a rung on it. + // + // The rungs are not equal width, deliberately. "This session" is longer + // than "1hr" and reads better allowed to be; a row padded to the widest + // label would spend most of the panel's width on space. + // + // Every label here is fixed for the life of the panel, the custom rung + // included: it reads `Custom` whatever number is set on it. So the row + // is laid out once and never reflows, and nothing slides out from under + // a pointer while the sensor is armed. + customOptionIndex = content.windowOptions.firstIndex { $0.kind == .custom } + if content.windowOptions.count > 1 { + let control = PanelSegmentedControl( + labels: content.windowOptions.map { $0.label }, + selectedIndex: PanelContent.windowOptionIndex( + of: content.defaultWindow, + in: content.windowOptions + ), + onChange: { [weak self] _ in self?.windowChanged() }, + // Clicking the rung you are already on is not a change, but on + // this one it is a request to adjust the number, so it puts the + // caret back where the adjusting happens. + onReselect: { [weak self] index in + guard let self, index == self.customOptionIndex else { return } + self.customControl?.focusField() + } + ) + windowControl = control + column.setCustomSpacing(15, after: column.arrangedSubviews.last!) + column.addArrangedSubview(centred(control)) + + // The custom rung's value, on a row of its own directly under the + // ladder. Collapsed unless that rung is selected: nothing is + // reserved for it, because the action row is anchored to the bottom + // of the panel and absorbs the difference by moving the window + // rather than by padding this out. Same arrangement as the breadth + // checkbox, and for the same reason. + if customOptionIndex != nil { + // The unit a value opens in is derived from the value itself, so + // a remembered `2hr` opens in hours and a remembered `45min` in + // minutes. A preview may override it to photograph what a unit + // switch leaves behind, which is otherwise a state only a click + // can reach. + let seeded = content.customDuration ?? .unset + let field = PanelDurationField( + value: previewUnitOverride.map { seeded.converted(to: $0) } ?? seeded, + onChange: { [weak self] in self?.customValueChanged() }, + onCancel: { [weak self] in + guard let self else { return } + self.denyPressed(self) + } + ) + customControl = field + column.setCustomSpacing(10, after: column.arrangedSubviews.last!) + let row = centred(field) + customRow = row + column.addArrangedSubview(row) + } + } else if let only = content.windowOptions.first { + // One answer is not a choice, so it is stated rather than drawn as a + // control. `windowFactLine` is for the path where the window that is + // really granted is not the one the single rung names: see the + // legacy device-key panel, where macOS reuses a scan for minutes and + // saying "once" would be the panel understating what a finger buys. + let label = PanelStyle.label( + content.windowFactLine ?? "Allowed for: \(only.label.lowercased())", + size: 11.5, + color: PanelStyle.inkTertiary + ) + label.alignment = .center + column.setCustomSpacing(15, after: column.arrangedSubviews.last!) + column.addArrangedSubview(centred(label)) + } + + // The breadth control, in ONE place: directly under the window + // control, whatever the request names. It does not move into the vault + // rows when there happen to be several and back out again when there is + // one. A control that relocates by situation is a control you have to + // find before you can read it, on a panel whose whole job is to be read + // in the second before a finger lands. + // + // A checkbox rather than a second pill pair: this axis has a default, + // and the default is broad. Two equally weighted buttons would present a + // decision where there is really a setting. + if content.breadths.count > 1 { + let checkbox = PanelCheckbox( + title: PanelContent.breadthCheckboxLabel(vaultCount: content.vaultCount), + isChecked: content.defaultBreadth == .wholeKey + ) { [weak self] _ in self?.breadthChanged() } + breadthControl = checkbox + column.setCustomSpacing(13, after: column.arrangedSubviews.last!) + // The whole row goes when the checkbox does, so `once` gets a panel + // with nothing in it rather than a gap where a control used to be. + // What must not move is the action row, and that is held still by + // `relayout(anchor:)` moving the window, not by padding this out. + let row = centred(checkbox) + breadthRow = row + column.addArrangedSubview(row) + } + + // The controls said back as one sentence, so what is about to be + // approved is written somewhere in full rather than assembled in the + // reader's head from a pill and a tickbox. + if content.breadths.count > 1 || content.windowOptions.count > 1 { + let summary = PanelStyle.label("", size: 11.5, color: PanelStyle.inkSecondary) + summary.alignment = .center + summary.lineBreakMode = .byWordWrapping + summary.maximumNumberOfLines = 2 + summary.preferredMaxLayoutWidth = PanelStyle.contentWidth + selectionSummaryLabel = summary + // Pinned to its two-line height rather than fitting its text. The + // sentence is one line for some combinations and two for others, and + // a panel that grows and shrinks under the pointer as somebody reads + // their options is the same mis-click problem as a collapsing + // checkbox row. + summary.heightAnchor.constraint( + equalToConstant: ceil(summary.font.map { $0.boundingRectForFont.height } ?? 14) * 2 + ).isActive = true + column.setCustomSpacing(9, after: column.arrangedSubviews.last!) + column.addArrangedSubview(centred(summary)) + } + + // The caveat about the choice, next to the choice. The value cache is + // never item scoped, and a person picking "only these" must not walk + // away thinking they restricted it. + if content.hasUnlistableSource { + let caveat = PanelStyle.label( + PanelContent.unlistableSourceNote, + size: 11, + color: PanelStyle.inkTertiary + ) + caveat.alignment = .center + caveat.lineBreakMode = .byWordWrapping + caveat.maximumNumberOfLines = 3 + caveat.preferredMaxLayoutWidth = PanelStyle.contentWidth + column.setCustomSpacing(6, after: column.arrangedSubviews.last!) + column.addArrangedSubview(centred(caveat)) + } + + // Why the panel opened where it did, when something narrowed it. Said + // out loud rather than left as a preselection nobody can account for. + if let note = content.selectionNote { + let label = PanelStyle.label(note, size: 11, color: PanelStyle.inkTertiary) + label.alignment = .center + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 2 + label.preferredMaxLayoutWidth = PanelStyle.contentWidth + column.setCustomSpacing(6, after: column.arrangedSubviews.last!) + column.addArrangedSubview(centred(label)) + } + + // Says what happened when a check did not complete. Hidden until there is + // something to say, so the common one-gesture case stays quiet. + let status = PanelStyle.label("", size: 11, color: PanelStyle.inkSecondary) + status.lineBreakMode = .byWordWrapping + status.maximumNumberOfLines = 3 + status.preferredMaxLayoutWidth = PanelStyle.contentWidth + status.isHidden = true + statusLabel = status + column.setCustomSpacing(10, after: column.arrangedSubviews.last!) + column.addArrangedSubview(status) + + // The action row and the hint under it are the last two things in the + // column, and neither of them ever hides, so the row's distance from the + // bottom of the panel is the same whatever the controls above it are + // doing. That, plus `relayout(anchor: .actionRow)` holding the bottom + // edge still, is what keeps Deny and the sensor under the pointer. + let actions = actionRow(content: content, mode: mode) + actionRowView = actions + column.setCustomSpacing(14, after: column.arrangedSubviews.last!) + column.addArrangedSubview(actions) + column.setCustomSpacing(9, after: column.arrangedSubviews.last!) + column.addArrangedSubview(underActions(mode: mode)) + + // Fill the summary in from the controls as built, so the sentence under + // them is right the first time the panel is read rather than only after + // somebody touches something. + syncSelectionIntoFlow() + + let contentView = NSView() + contentView.wantsLayer = true + contentView.layer?.backgroundColor = PanelStyle.panelBackground.cgColor + contentView.addSubview(column) + NSLayoutConstraint.activate([ + column.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: PanelStyle.contentInset), + column.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -PanelStyle.contentInset), + column.topAnchor.constraint(equalTo: contentView.topAnchor, constant: PanelStyle.contentInset), + column.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -PanelStyle.contentInset), + column.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + contentView.layoutSubtreeIfNeeded() + + let window = ApprovalPanelWindow( + contentRect: NSRect( + x: 0, + y: 0, + width: PanelStyle.contentWidth + PanelStyle.contentInset * 2, + height: contentView.fittingSize.height + ), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.backgroundColor = PanelStyle.panelBackground + // Committed dark chrome: the panel looks the same whatever the user's + // theme, so it is recognisable as varlock asking rather than as whatever + // window happened to be in front. + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = contentView + window.title = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Varlock" + window.onCancel = { [weak self] in self?.denyPressed(self as Any) } + window.onConfirm = { [weak self] in + guard let self, self.confirmButton?.isEnabled == true else { return } + self.confirmPressed(self) + } + return window + } + + private func topBar(_ content: PanelContent) -> NSView { + let row = PanelStyle.row(spacing: 7) + row.addArrangedSubview(logoMark()) + row.addArrangedSubview(PanelStyle.label( + "varlock", + size: 12, + color: PanelStyle.wordmark, + weight: .semibold + )) + row.addArrangedSubview(PanelStyle.spacer()) + if let fact = content.factLine { + row.addArrangedSubview(PanelStyle.label(fact, size: 11, color: PanelStyle.inkQuiet)) + } + return fullWidth(row) + } + + /// varlock's own mark. Says who is asking before a single word is read, + /// which is the one job the top bar has, so it is the real app icon rather + /// than something that merely looks like a lock. + private func logoMark() -> NSView { + return PanelIconView( + side: 20, + placeholder: NSImage(systemSymbolName: "lock.fill", accessibilityDescription: "varlock") + ) { + PanelIcons.varlockMark() + } + } + + private func actionRow(content: PanelContent, mode: ApprovalPresenceMode) -> NSView { + let row = PanelStyle.row(spacing: 10) + let deny = PanelButton( + title: content.cancelButtonTitle, + style: .deny, + glyph: .stop, + target: self, + action: #selector(denyPressed(_:)) + ) + deny.setContentHuggingPriority(.required, for: .horizontal) + row.addArrangedSubview(deny) + + // On a machine that can scan, the primary IS the sensor: one control that + // reads as the button and answers to a finger. The plain button is built + // alongside it and kept hidden, because the password fallback needs + // something to become, and rebuilding the row mid-approval would move the + // panel under the pointer. + if mode == .embedded { + let scanButton = PanelScanButton( + title: confirmTitle(content: content, mode: mode), + context: attempt?.context + ) { [weak self] in + guard let self else { return } + self.confirmPressed(self) + } + self.scanButton = scanButton + // Only the real thing is worth photographing: a stand-in glyph would + // sail through the check that exists to catch a blank sensor. + if attempt != nil { embeddedScanView = scanButton.scanView } + scanButton.setContentHuggingPriority(.init(1), for: .horizontal) + row.addArrangedSubview(scanButton) + } + + let confirm = PanelButton( + title: mode == .embedded ? "Approve with password" : confirmTitle(content: content, mode: mode), + style: .primary, + glyph: mode == .embedded ? .lock : primaryGlyph(mode: mode), + target: self, + action: #selector(confirmPressed(_:)) + ) + confirmButton = confirm + confirm.isHidden = scanButton != nil + row.addArrangedSubview(confirm) + // The approve action is the wide one: the panel has an obvious yes and a + // quiet no, not two equal buttons. + confirm.setContentHuggingPriority(.init(1), for: .horizontal) + return fullWidth(row) + } + + /// What sits on the left of the approve button. + /// + /// On the embedded path it is the system's own scan surface rather than a + /// picture of one: the thing that reads the finger, in the place the design + /// always had a fingerprint. A preview has no run loop and no sensor, so it + /// gets the drawn glyph as a stand-in for where the live view goes. + private func primaryGlyph(mode: ApprovalPresenceMode) -> PanelButton.PanelButtonGlyph { + switch mode { + // The fingerprint on this path is the system's own view, standing above + // the buttons (or its stand-in, in a preview). A second one drawn on the + // button would be two sensors on a machine with one. + case .embedded: return .none + case .systemDialog: return .lock + case .none: return .none + } + } + + private func confirmTitle(content: PanelContent, mode: ApprovalPresenceMode) -> String { + switch mode { + case .embedded: return "Approve with Touch ID" + case .systemDialog: return "Approve with password" + case .none: return content.confirmButtonTitle + } + } + + private func underActions(mode: ApprovalPresenceMode) -> NSView { + let row = PanelStyle.row(spacing: 8) + let hint: String + switch mode { + case .embedded: hint = "Scanning approves without clicking" + case .systemDialog: hint = "No Touch ID available on this Mac" + case .none: hint = "" + } + let hintLabel = PanelStyle.label(hint, size: 11, color: PanelStyle.inkQuiet) + self.hintLabel = hintLabel + row.addArrangedSubview(hintLabel) + row.addArrangedSubview(PanelStyle.spacer()) + + // Offered only where there is something to fall back FROM. On a machine + // with no sensor the password path is already the primary action, and a + // link to it would be the same button twice. + if mode == .embedded { + let link = PanelButton( + title: "Use password\u{2026}", + style: .link, + target: self, + action: #selector(usePasswordPressed(_:)) + ) + passwordLink = link + row.addArrangedSubview(link) + } + return fullWidth(row) + } + + /// Wrap a view so it fills the panel's width inside the vertical stack. + private func fullWidth(_ view: NSView) -> NSView { + let box = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: box.leadingAnchor), + view.trailingAnchor.constraint(equalTo: box.trailingAnchor), + view.topAnchor.constraint(equalTo: box.topAnchor), + view.bottomAnchor.constraint(equalTo: box.bottomAnchor), + box.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + return box + } + + private func centred(_ view: NSView) -> NSView { + let box = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(view) + NSLayoutConstraint.activate([ + view.centerXAnchor.constraint(equalTo: box.centerXAnchor), + view.leadingAnchor.constraint(greaterThanOrEqualTo: box.leadingAnchor), + view.trailingAnchor.constraint(lessThanOrEqualTo: box.trailingAnchor), + view.topAnchor.constraint(equalTo: box.topAnchor), + view.bottomAnchor.constraint(equalTo: box.bottomAnchor), + box.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + return box + } +} + +/// The panel's window. +/// +/// A borderless-looking panel that can still take key events, so Escape refuses +/// and Return approves without either being a button the design has to find room +/// for. +final class ApprovalPanelWindow: NSPanel { + var onCancel: (() -> Void)? + var onConfirm: (() -> Void)? + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + override func cancelOperation(_ sender: Any?) { + onCancel?() + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: // escape + onCancel?() + case 36, 76: // return, enter + onConfirm?() + default: + super.keyDown(with: event) + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/BiometricSetupStore.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/BiometricSetupStore.swift new file mode 100644 index 000000000..548e6083f --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/BiometricSetupStore.swift @@ -0,0 +1,94 @@ +import Foundation +import CryptoKit +import LocalAuthentication +import IdentitySessions + +/// Remembers that Touch ID has been set up for varlock on this machine, and +/// against which enrolment. +/// +/// The record is a hash of `evaluatedPolicyDomainState`, which macOS changes +/// whenever the enrolled fingerprints do. That is exactly when the system starts +/// raising its own prompts again, and therefore exactly when the setup step has +/// to be repeated. Storing the hash rather than the state itself keeps a +/// biometric-derived blob off disk for no loss: all we ever do is compare it. +/// +/// It lives next to the key store, so it is scoped by `XDG_CONFIG_HOME` the same +/// way keys are, and a scratch config home behaves like a fresh machine. +enum BiometricSetupStore { + static var statePath: String { + let keyStore = SecureEnclaveManager.keyStorePath + let parent = (keyStore as NSString).deletingLastPathComponent + return parent + "/.biometric-setup.json" + } + + /// The enrolment we last completed setup against, if any. + static func recordedDomainState() -> String? { + guard let data = FileManager.default.contents(atPath: statePath), + let record = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + return nil + } + let state = record["domainState"] as? String + return (state?.isEmpty ?? true) ? nil : state + } + + /// Record that setup has just been completed against the current enrolment. + static func record(domainState: String?) { + let record: [String: Any] = [ + "version": 1, + "domainState": domainState ?? "", + "recordedAt": ISO8601DateFormatter().string(from: Date()), + ] + guard let data = try? JSONSerialization.data(withJSONObject: record) else { return } + let directory = (statePath as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? data.write(to: URL(fileURLWithPath: statePath), options: .atomic) + } + + /// What the system says the enrolment is right now, hashed. + /// + /// `evaluatedPolicyDomainState` is only populated once a policy has been + /// evaluated for availability, hence the `canEvaluatePolicy` call. Machines + /// with no biometrics report nothing, which is not a failure: there is no + /// enrolment to notice changes in. + static func currentDomainState() -> String? { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), + let state = context.evaluatedPolicyDomainState else { + context.invalidate() + return nil + } + context.invalidate() + return SHA256.hash(data: state).map { String(format: "%02x", $0) }.joined() + } + + /// Overrides the decision, for tests and for anyone the detection misjudges. + /// + /// Same shape as `_VARLOCK_EMBEDDED_PROMPT`: `0` says the setup step has + /// happened, `1` forces it. Nothing here weakens the check itself; the setup + /// scan is a user-experience step, and the approval scan is the one that + /// actually opens anything. + static let overrideEnvVar = "_VARLOCK_BIOMETRIC_SETUP" + + /// Whether this unlock has to do the setup scan before anything is drawn. + static func needsSetup() -> Bool { + switch ProcessInfo.processInfo.environment[overrideEnvVar]?.lowercased() { + case "0", "false": return false + case "1", "true": return true + default: break + } + return BiometricSetupPolicy.needsSetup( + recordedDomainState: recordedDomainState(), + currentDomainState: currentDomainState() + ) + } + + /// Remember the enrolment the setup scan was completed against. + static func markSetupComplete() { + record(domainState: currentDomainState()) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/CacheCiphertexts.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/CacheCiphertexts.swift new file mode 100644 index 000000000..cc9a70e4e --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/CacheCiphertexts.swift @@ -0,0 +1,97 @@ +import Foundation +import IdentitySessions + +/// The ciphertexts varlock's own value cache is holding for one key, by digest. +/// +/// This is how "the value cache is never item scoped" becomes something the +/// daemon ENFORCES rather than something it takes the client's word for. +/// +/// The alternative was a flag on the request saying "this batch is a cache +/// read", which is not a rule, it is a request to be excused from one: any +/// client could set it and a narrow approval would cover everything again. So +/// the daemon answers the question itself. It computes the cache's path from its +/// own idea of the user varlock directory, reads the file, and admits a +/// ciphertext only if the cache is actually holding it. No path, name, or +/// membership claim from the socket is involved. +/// +/// What this does NOT defend against: anything running as this user can write +/// into that file, so a determined client could park a ciphertext there and read +/// it back. That is worth being plain about. Item scope is a guard against a +/// legitimate client opening more than the panel described, and it holds +/// completely against that; it is not a boundary against a hostile process +/// running as the user, which the cache file was never one either. +/// +/// Cached in memory against the file's identity, so a batch of cache reads costs +/// one parse rather than one per payload, and a rewritten cache is picked up on +/// the next read rather than at some interval. +enum CacheCiphertexts { + /// Mirror of `CacheStore`'s own path in the TS library. + static func cacheFilePath(keyId: String) -> String { + return IdentityStore.userVarlockDir + "/cache/\(keyId).json" + } + + private struct Snapshot { + let modified: Date + let size: Int + let digests: Set + } + + private static var snapshots: [String: Snapshot] = [:] + private static let lock = NSLock() + + /// The digests the cache for this key is currently holding. + /// + /// Empty for a key with no cache file, which is the common case and costs a + /// single `stat`. + static func digests(keyId: String) -> Set { + let path = cacheFilePath(keyId: keyId) + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modified = attributes[.modificationDate] as? Date, + let size = (attributes[.size] as? NSNumber)?.intValue else { + lock.lock() + snapshots.removeValue(forKey: keyId) + lock.unlock() + return [] + } + + lock.lock() + if let cached = snapshots[keyId], cached.modified == modified, cached.size == size { + defer { lock.unlock() } + return cached.digests + } + lock.unlock() + + let digests = read(path: path) + lock.lock() + snapshots[keyId] = Snapshot(modified: modified, size: size, digests: digests) + lock.unlock() + return digests + } + + /// Parse the cache file's shape: `{ "": { "v": "", ... } }`. + /// + /// Only the ciphertexts are read. Cache keys name providers and resolver + /// paths and are nobody's business here; nothing is decrypted, and a file + /// that will not parse contributes nothing rather than failing a decrypt. + private static func read(path: String) -> Set { + guard let data = FileManager.default.contents(atPath: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [] + } + var digests = Set() + for (_, entry) in json { + guard let entry = entry as? [String: Any], + let ciphertext = entry["v"] as? String, + let payload = Data(base64Encoded: ciphertext) else { continue } + digests.insert(GrantItemDigest.of(payload)) + } + return digests + } + + /// Forget what was read, so a test can move the file around underneath us. + static func resetForTesting() { + lock.lock() + snapshots.removeAll() + lock.unlock() + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/EmbeddedUnlockProbe.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/EmbeddedUnlockProbe.swift new file mode 100644 index 000000000..0997572a3 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/EmbeddedUnlockProbe.swift @@ -0,0 +1,733 @@ +import Foundation +import AppKit +import CoreGraphics +import LocalAuthentication +import LocalAuthenticationEmbeddedUI +import SessionScoping + +/// Proves, on real hardware, that the panel's inline Touch ID prompt actually +/// arms, and that a context authenticated through it can still open the custody +/// key without a second prompt. +/// +/// `probe-session-unlock` settled the handoff question for `evaluatePolicy` and its +/// floating system dialog. The embedded prompt is a different mechanism: +/// `LAAuthenticationView` is bound to a context, and evaluating that context is +/// supposed to render into the view instead of raising the standard dialog. Two +/// separate things can go wrong there, so the probe reports on both: +/// +/// 1. does the inline prompt arm at all (is there a glyph, does the sensor +/// respond), and +/// 2. once scanned, does that context still open the enclave key silently. +/// +/// The first one has already failed in the field once, with the view drawing +/// nothing and the sensor doing nothing, so this probe logs every step of the +/// lifecycle with timestamps. `--verbose` streams that log to stderr as it +/// happens, and it is always included in the JSON, so a run that stalls says +/// where it stalled rather than just timing out. +/// +/// phase A (control): unauthenticated context, no interaction allowed +/// -> must FAIL, proving the key is presence gated +/// phase B (embedded): one scan in the probe's own window -> must SUCCEED +/// phase C (handoff): two unwraps under that context, no interaction allowed +/// -> must SUCCEED +/// +/// Run it with: +/// `varlock-enclave probe-embedded-unlock [--key-id ] [--verbose] [--timeout ]` +enum EmbeddedUnlockProbe { + private static let probePlaintext = Data("varlock-embedded-unlock-probe".utf8) + + // MARK: - Lifecycle log + + /// Timestamped record of what the probe did and what the system said back. + /// This is the actual deliverable when the prompt does not arm. + final class Log { + private let start = Date() + private let verbose: Bool + private(set) var entries: [[String: Any]] = [] + + init(verbose: Bool) { + self.verbose = verbose + } + + func note(_ event: String, _ detail: [String: Any] = [:]) { + let atMs = Int(Date().timeIntervalSince(start) * 1000) + var entry: [String: Any] = ["atMs": atMs, "event": event] + for (key, value) in detail { entry[key] = value } + entries.append(entry) + guard verbose else { return } + let rendered = detail.isEmpty + ? "" + : " " + detail.keys.sorted().map { "\($0)=\(detail[$0] ?? "")" }.joined(separator: " ") + FileHandle.standardError.write(Data("[\(atMs)ms] \(event)\(rendered)\n".utf8)) + } + } + + static func run(keyId: String, verbose: Bool, timeoutSeconds: TimeInterval) -> [String: Any] { + let log = Log(verbose: verbose) + log.note("probe-start", [ + "keyId": keyId, + "timeoutSeconds": Int(timeoutSeconds), + // A bare SwiftPM executable has no bundle and no identifier. Some + // AppKit and LocalAuthentication behaviour depends on being a real + // bundled app, so this is worth knowing before blaming the code. + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", + "bundlePath": Bundle.main.bundlePath, + "executable": Bundle.main.executablePath ?? "", + ]) + + guard SecureEnclaveManager.keyExists(keyId: keyId) else { + return finish(log: log, [ + "verdict": "inconclusive", + "reason": "no Secure Enclave key \"\(keyId)\" on this machine; create one with generate-key first", + ]) + } + guard UiAvailability.canShowUi() else { + return finish(log: log, [ + "verdict": "inconclusive", + "reason": "no window server session, so there is nowhere to draw the embedded view; " + + "run this from a normal Terminal window logged into the desktop", + ]) + } + + let wrapped: Data + do { + wrapped = try SecureEnclaveManager.encrypt(plaintext: probePlaintext, keyId: keyId) + } catch { + return finish(log: log, [ + "verdict": "inconclusive", + "reason": "could not encrypt probe payload: \(error.localizedDescription)", + ]) + } + + var phases: [[String: Any]] = [] + + // Phase A: control. A presence-gated key must refuse an unauthenticated, + // non-interactive context, or the probe proves nothing. + let unauthenticated = LAContext() + unauthenticated.interactionNotAllowed = true + var controlSucceeded = false + do { + _ = try SecureEnclaveManager.decrypt(payload: wrapped, keyId: keyId, context: unauthenticated) + controlSucceeded = true + } catch { + log.note("control-refused-as-expected", ["error": error.localizedDescription]) + phases.append([ + "phase": "control-unauthenticated", + "expected": "fail", + "passed": true, + "error": error.localizedDescription, + ]) + } + unauthenticated.invalidate() + + if controlSucceeded { + log.note("control-succeeded-unexpectedly") + return finish(log: log, [ + "verdict": "inconclusive", + "reason": "key \"\(keyId)\" does not require user presence, so a handoff cannot be observed; " + + "re-run against a key created without --no-auth", + ]) + } + + // Phase B: authenticate through the embedded view. + // + // Made here and used once: a reused or invalidated context is one of the + // stock explanations for a blank inline view, so the probe rules it out + // by construction rather than by inspection. + var checklist = Checklist() + let context = LAContext() + checklist.freshContext = true + let selfFacts = PeerPostureReader().selfFacts() + checklist.signatureValid = selfFacts.signatureValid + checklist.hardenedRuntime = selfFacts.hasHardenedRuntime + checklist.screenScanPermitted = CGPreflightScreenCaptureAccess() + log.note("context-created", [ + "instance": String(UInt(bitPattern: ObjectIdentifier(context).hashValue), radix: 16), + // A leaked `interactionNotAllowed` would suppress the UI while the + // surrounding labels still drew, which looks exactly like the field + // report, so it is asserted rather than assumed. + "interactionNotAllowed": context.interactionNotAllowed, + ]) + + var biometricError: NSError? + let canBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometricError) + log.note("canEvaluatePolicy", [ + "policy": "deviceOwnerAuthenticationWithBiometrics", + "result": canBiometrics, + "error": biometricError?.localizedDescription ?? "", + "errorCode": biometricError?.code ?? 0, + // Populated by canEvaluatePolicy. 0 = none, 1 = Touch ID, 2 = Face ID. + "biometryType": context.biometryType.rawValue, + ]) + checklist.canEvaluate = canBiometrics + checklist.canEvaluateError = biometricError?.localizedDescription ?? "" + guard canBiometrics else { + return finish(log: log, [ + "verdict": "inconclusive", + "checklist": checklist.asDictionary, + "reason": "biometrics are not available here, and the embedded view only drives biometrics: " + + (biometricError?.localizedDescription ?? "unknown"), + "hint": "the panel falls back to the system dialog in exactly this case", + "phases": phases, + ]) + } + + let scan = presentEmbeddedScan( + context: context, + keyId: keyId, + log: log, + timeoutSeconds: timeoutSeconds, + checklist: checklist + ) + guard scan.authenticated else { + context.invalidate() + return finish(log: log, [ + "verdict": scan.armed ? "inconclusive" : "embedded-never-armed", + "checklist": scan.checklist.asDictionary, + "reason": scan.error ?? "authentication did not complete", + "phases": phases, + "presentation": observedPresentation(scan), + "inlineViewDrewSomething": scan.inlineDrew, + "authAgentWindowsSeen": scan.agentWindows, + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", + "interpretation": scan.armed + ? "the evaluation was running but nobody completed it" + : "the inline prompt never became usable; see the lifecycle log for where it stopped", + ]) + } + phases.append([ + "phase": "embedded-authenticate", + "expected": "succeed", + "passed": true, + "durationMs": scan.durationMs, + ]) + + // Phase C: the handoff. No further UI allowed, so a second prompt becomes + // an error we can report instead of something a human has to notice. + context.interactionNotAllowed = true + var handoffPassed = true + for attempt in 1...2 { + let start = Date() + do { + _ = try SecureEnclaveManager.decrypt(payload: wrapped, keyId: keyId, context: context) + log.note("handoff-unwrap-ok", ["attempt": attempt]) + phases.append([ + "phase": "handoff-unwrap-\(attempt)", + "expected": "succeed", + "passed": true, + "durationMs": Int(Date().timeIntervalSince(start) * 1000), + ]) + } catch { + handoffPassed = false + log.note("handoff-unwrap-failed", ["attempt": attempt, "error": error.localizedDescription]) + phases.append([ + "phase": "handoff-unwrap-\(attempt)", + "expected": "succeed", + "passed": false, + "durationMs": Int(Date().timeIntervalSince(start) * 1000), + "error": error.localizedDescription, + ]) + } + } + context.invalidate() + + // Deliberately says nothing about WHERE the prompt appeared. An earlier + // verdict of "embedded-single-scan" was read as proof of inline rendering + // when all it ever established was the handoff, and the two came apart in + // the field: the scan really did carry over, and it really did happen in a + // separate system alert. + let presentation = observedPresentation(scan) + return finish(log: log, [ + "verdict": handoffPassed ? "embedded-handoff-ok" : "embedded-handoff-lost", + "checklist": scan.checklist.asDictionary, + "scansRequested": 1, + "policy": "deviceOwnerAuthenticationWithBiometrics", + "authenticationMs": scan.durationMs, + "phases": phases, + "presentation": presentation, + "inlineViewDrewSomething": scan.inlineDrew, + "authAgentWindowsSeen": scan.agentWindows, + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", + "interpretation": handoffPassed + ? "one scan covered every enclave operation that followed, wherever the prompt was drawn" + : "the context authenticated by the embedded view did not carry to the enclave; " + + "the panel would have to fall back to the system dialog", + "confirmVisually": presentation == "inline" + ? "the Touch ID prompt should have been inside the probe window, with no separate dialog" + : "a separate system authentication dialog is expected to have appeared; " + + "the probe window should read as an information card", + ]) + } + + private static func finish(log: Log, _ result: [String: Any]) -> [String: Any] { + var output = result + output["lifecycle"] = log.entries + return output + } + + // MARK: - Telling inline apart from the standard alert + + /// On-screen windows owned by one of the system's authentication agents. + /// + /// This is the only programmatic signal found for "the standard alert + /// presented instead of the inline view". The alert is drawn by a separate + /// process, so it never appears in `NSApp.windows`; what it does do is put a + /// window on screen under an owner name we can recognise. Window *names* need + /// screen-recording consent, but owner names do not, which is all this reads. + /// + /// Absence is weak evidence (the list of agent names is not guaranteed + /// complete, and the alert may not have opened yet), so this is reported as + /// an observation rather than treated as proof either way. + static func authAgentWindowOwners() -> [String] { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let infos = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + let ourPid = ProcessInfo.processInfo.processIdentifier + var owners = Set() + for info in infos { + if let pid = info[kCGWindowOwnerPID as String] as? pid_t, pid == ourPid { continue } + guard let owner = info[kCGWindowOwnerName as String] as? String else { continue } + let lowered = owner.lowercased() + if lowered.contains("auth") || lowered.contains("securityagent") || lowered.contains("biome") { + owners.insert(owner) + } + } + return owners.sorted() + } + + /// What the run observed about where the prompt was drawn. + /// + /// `inline` needs positive evidence that the view rendered. `system-alert` + /// needs an authentication agent window on screen while the view stayed empty. + /// Anything else is `unknown`, and says so rather than guessing, because + /// guessing here is exactly what produced a verdict that turned out to be + /// false when somebody finally watched the screen. + private static func observedPresentation(_ scan: ScanResult) -> String { + if scan.inlineDrew { return "inline" } + if !scan.agentWindows.isEmpty { return "system-alert" } + return "unknown" + } + + /// Whether the inline view ever drew anything of its own. + /// + /// An armed inline prompt has to render its glyph out of some layer or + /// subview. A view that stays completely empty for the whole evaluation never + /// engaged, whatever the authentication itself returned. + static func inlineViewDrewSomething(_ view: NSView) -> Bool { + return !view.subviews.isEmpty + || view.layer?.contents != nil + || !(view.layer?.sublayers ?? []).isEmpty + } + + // MARK: - The window + + /// Every condition the inline view is supposed to need, answered rather than + /// assumed. + /// + /// The advice for "LAAuthenticationView renders blank" is a list of things to + /// check, and a list that is merely believed is worth nothing. Each of these + /// is asserted at the moment it matters and reported per run, so the only + /// unexplained difference between a working and a non-working run is the one + /// variable left: the signature. + struct Checklist { + /// The embedded-UI framework is linked and its class is really there. + var embeddedUiLinked = false + /// The view had real area, full opacity, was not hidden, and was in a + /// visible key window BEFORE the evaluation started. + var viewReadyBeforeEvaluate = false + var viewFrame = "0x0" + var viewAlpha: Double = 0 + var viewHidden = true + var windowVisibleAndKey = false + /// The context was made for this attempt, not reused from an earlier one. + var freshContext = false + /// The context evaluated is the same instance the view was built around. + var sameContextAsView = false + var canEvaluate = false + var canEvaluateError = "" + /// The LAError code when the evaluation failed. 0 when it did not. + var evaluateErrorCode = 0 + var evaluateErrorDomain = "" + /// What the kernel says about this build's own hardening, which is the + /// variable the signing experiment moves. + var signatureValid = false + var hardenedRuntime = false + /// Whether this process may see other applications' windows at all. + /// + /// Without screen-recording permission the scan for the system's own + /// authentication alert can only ever come back empty, which is not the + /// same as the alert not being there. Saying so turns a misleading "none" + /// into an honest "cannot tell". + var screenScanPermitted = false + /// Which activation policy the probe ran under. + /// + /// The daemon is an `.accessory` app (no Dock icon) and the probe has + /// always been `.regular`. If the inline view behaves differently between + /// them, that difference belongs to the policy and not to the signature, + /// which is worth knowing before blaming a certificate. + var activationPolicy = "regular" + + var asDictionary: [String: Any] { + return [ + "embeddedUiLinked": embeddedUiLinked, + "viewReadyBeforeEvaluate": viewReadyBeforeEvaluate, + "viewFrame": viewFrame, + "viewAlpha": viewAlpha, + "viewHidden": viewHidden, + "windowVisibleAndKey": windowVisibleAndKey, + "freshContext": freshContext, + "sameContextAsView": sameContextAsView, + "canEvaluate": canEvaluate, + "canEvaluateError": canEvaluateError, + "evaluateErrorCode": evaluateErrorCode, + "evaluateErrorDomain": evaluateErrorDomain, + "signatureValid": signatureValid, + "hardenedRuntime": hardenedRuntime, + "screenScanPermitted": screenScanPermitted, + "activationPolicy": activationPolicy, + ] + } + } + + private struct ScanResult { + let authenticated: Bool + /// Whether the inline prompt ever looked usable: a view with real area, + /// in a visible key window, with the evaluation running. + let armed: Bool + /// Whether the inline view ever drew anything of its own. False means the + /// authentication happened somewhere else, whatever its result. + let inlineDrew: Bool + /// Authentication-agent windows seen on screen while evaluating. + let agentWindows: [String] + let durationMs: Int + let error: String? + var checklist = Checklist() + } + + /// Give the window a moment to actually become key before doing anything + /// that depends on it being key. + /// + /// `makeKeyAndOrderFront` is a request, not a fact: the window server gets to + /// it when it gets to it, and a run started from a terminal can be a beat + /// behind. Polling briefly is the difference between testing the signature + /// and testing who had focus. + private static func waitForKeyWindow( + window: NSWindow, + app: NSApplication, + log: Log, + attemptsLeft: Int = 20, + then work: @escaping () -> Void + ) { + if window.isKeyWindow || attemptsLeft <= 0 { + log.note("window-key-wait-finished", [ + "isKeyWindow": window.isKeyWindow, + "attemptsLeft": attemptsLeft, + ]) + work() + return + } + window.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + waitForKeyWindow(window: window, app: app, log: log, attemptsLeft: attemptsLeft - 1, then: work) + } + } + + /// Put an `LAAuthenticationView` on screen, bound to `context`, and evaluate. + /// + /// Binding is the whole mechanism: with a view paired to this context in a + /// visible window, `evaluatePolicy` renders into that view instead of raising + /// the standard authentication alert. The ordering matters, so it is explicit + /// here: build the view, install it, put the window on screen and make it key, + /// activate the app, and only then evaluate, from inside the running run loop. + private static func presentEmbeddedScan( + context: LAContext, + keyId: String, + log: Log, + timeoutSeconds: TimeInterval, + checklist: Checklist + ) -> ScanResult { + var checklist = checklist + let app = NSApplication.shared + // Overridable so the probe can be run the way the daemon actually runs: + // an accessory app with no Dock icon. + let wantsAccessory = ProcessInfo.processInfo.environment["_VARLOCK_PROBE_ACTIVATION"] == "accessory" + app.setActivationPolicy(wantsAccessory ? .accessory : .regular) + checklist.activationPolicy = wantsAccessory ? "accessory" : "regular" + log.note("activation-policy-set", ["policy": checklist.activationPolicy]) + + // The window can be built the probe's way or the panel's way, so a + // bisection can move one axis at a time between an environment where the + // inline view renders and one where it does not. + let wantsPanelWindow = ProcessInfo.processInfo.environment["_VARLOCK_PROBE_WINDOW"] == "panel" + let window: NSWindow = wantsPanelWindow + ? ApprovalPanelWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 230), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + : NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 230), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + if wantsPanelWindow { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.level = .floating + window.appearance = NSAppearance(named: .darkAqua) + log.note("window-class", ["class": "ApprovalPanelWindow", "level": "floating"]) + } + // A run that a person is watching has to say which run it is. Several of + // these windows look identical, and an experiment whose variants cannot + // be told apart is not an experiment. + let variantLabel = ProcessInfo.processInfo.environment["_VARLOCK_PROBE_LABEL"] + window.title = variantLabel.map { "Varlock probe: \($0)" } ?? "Varlock embedded unlock probe" + window.level = .floating + window.center() + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 14 + stack.translatesAutoresizingMaskIntoConstraints = false + + if let variantLabel { + let banner = NSTextField(labelWithString: variantLabel.uppercased()) + banner.font = NSFont.monospacedSystemFont(ofSize: 15, weight: .bold) + banner.textColor = .systemPink + stack.addArrangedSubview(banner) + } + + let heading = NSTextField(labelWithString: "Unlock varlock encryption key \(keyId)") + heading.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(heading) + + let explainer = NSTextField(wrappingLabelWithString: + "Touch the sensor. The prompt should appear inside this window, with no " + + "separate system dialog. If you see no fingerprint icon below, the inline " + + "prompt did not arm: quit with Cmd+Q and send the lifecycle log.") + explainer.alignment = .center + explainer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + explainer.textColor = .secondaryLabelColor + stack.addArrangedSubview(explainer) + + let boundContext = context + let authView = LAAuthenticationView(context: boundContext, controlSize: .large) + log.note("auth-view-created", [ + "boundToContext": String(UInt(bitPattern: ObjectIdentifier(authView.context).hashValue), radix: 16), + "sameInstanceAsEvaluated": authView.context === context, + "intrinsicWidth": authView.intrinsicContentSize.width, + "intrinsicHeight": authView.intrinsicContentSize.height, + "fittingWidth": authView.fittingSize.width, + "fittingHeight": authView.fittingSize.height, + ]) + + // A view with no area draws no glyph and catches no touch, which is exactly + // what the field report described. Never let auto layout collapse it: take + // the intrinsic size when there is one and fall back to a sane square. + authView.translatesAutoresizingMaskIntoConstraints = false + let intrinsic = authView.intrinsicContentSize + let width = intrinsic.width > 0 ? intrinsic.width : 64 + let height = intrinsic.height > 0 ? intrinsic.height : 64 + NSLayoutConstraint.activate([ + authView.widthAnchor.constraint(greaterThanOrEqualToConstant: width), + authView.heightAnchor.constraint(greaterThanOrEqualToConstant: height), + ]) + stack.addArrangedSubview(authView) + + let content = NSView(frame: window.contentRect(forFrameRect: window.frame)) + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), + ]) + window.contentView = content + content.layoutSubtreeIfNeeded() + log.note("auth-view-installed", [ + "frameWidth": authView.frame.width, + "frameHeight": authView.frame.height, + "isHidden": authView.isHidden, + "hasWindow": authView.window != nil, + ]) + // The class has to exist at runtime, not just compile: a build that + // linked the framework and a build that did not look identical in source. + checklist.embeddedUiLinked = NSClassFromString("LAAuthenticationView") != nil + + window.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + log.note("window-shown", [ + "isVisible": window.isVisible, + "isKeyWindow": window.isKeyWindow, + "appIsActive": app.isActive, + "occlusion": window.occlusionState.contains(.visible) ? "visible" : "occluded", + ]) + + var result = ScanResult( + authenticated: false, armed: false, inlineDrew: false, agentWindows: [], + durationMs: 0, + error: "the probe window closed before anything happened", + checklist: checklist + ) + let start = Date() + var finished = false + var evaluateInvoked = false + // Sampled throughout, because the answer is "did this EVER happen", not + // whatever happened to be true at the final instant. + var inlineEverDrew = false + var agentWindowsSeen = Set() + + let finish: (ScanResult) -> Void = { outcome in + guard !finished else { return } + finished = true + result = outcome + window.orderOut(nil) + if ProcessInfo.processInfo.environment["_VARLOCK_PROBE_MODAL"] == "1" { + NSApp.stopModal() + } else { + NSApp.stop(nil) + } + // stop(_:) only takes effect once the loop processes another event. + NSApp.postEvent( + NSEvent.otherEvent( + with: .applicationDefined, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, subtype: 0, data1: 0, data2: 0 + )!, + atStart: true + ) + } + + // Evaluate from inside the running loop, once the window is genuinely on + // screen AND key. Doing it before `run()` was the other ordering suspect, + // and evaluating into a window that had not become key yet would leave + // the checklist answering a question nobody asked. + waitForKeyWindow(window: window, app: app, log: log) { + log.note("evaluatePolicy-invoked", [ + "policy": "deviceOwnerAuthenticationWithBiometrics", + "onContext": String(UInt(bitPattern: ObjectIdentifier(context).hashValue), radix: 16), + "interactionNotAllowed": context.interactionNotAllowed, + "authViewFrameWidth": authView.frame.width, + "authViewFrameHeight": authView.frame.height, + "windowIsKey": window.isKeyWindow, + "appIsActive": app.isActive, + ]) + // Everything the view is supposed to need, checked at the last + // moment before the evaluation rather than taken on trust. + authView.layoutSubtreeIfNeeded() + checklist.viewFrame = "\(Int(authView.frame.width))x\(Int(authView.frame.height))" + checklist.viewAlpha = Double(authView.alphaValue) + checklist.viewHidden = authView.isHidden + checklist.windowVisibleAndKey = window.isVisible && window.isKeyWindow + checklist.viewReadyBeforeEvaluate = authView.frame.width >= 44 + && authView.frame.height >= 44 + && authView.alphaValue == 1 + && !authView.isHidden + && authView.window != nil + && checklist.windowVisibleAndKey + checklist.sameContextAsView = boundContext === context + log.note("checklist-before-evaluate", checklist.asDictionary) + // Measured again shortly after the evaluation starts, since the view + // draws in response to it rather than before. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + log.note("scan-pixels", WindowPixels.sample(authView).asDictionary) + } + + evaluateInvoked = true + context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "unlock varlock encryption key \(keyId)" + ) { success, error in + let nsError = error as NSError? + DispatchQueue.main.async { + log.note("evaluatePolicy-completed", [ + "success": success, + "error": nsError?.localizedDescription ?? "", + "errorDomain": nsError?.domain ?? "", + "errorCode": nsError?.code ?? 0, + ]) + checklist.evaluateErrorCode = nsError?.code ?? 0 + checklist.evaluateErrorDomain = nsError?.domain ?? "" + inlineEverDrew = inlineEverDrew || inlineViewDrewSomething(authView) + agentWindowsSeen.formUnion(authAgentWindowOwners()) + finish(ScanResult( + authenticated: success, + armed: true, + inlineDrew: inlineEverDrew, + agentWindows: agentWindowsSeen.sorted(), + durationMs: Int(Date().timeIntervalSince(start) * 1000), + error: success ? nil : "authentication did not complete: " + + (nsError?.localizedDescription ?? "unknown"), + checklist: checklist + )) + } + } + } + + // Heartbeat, so a run that stalls still says what the window was doing. + var heartbeats = 0 + let heartbeat = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + heartbeats += 1 + inlineEverDrew = inlineEverDrew || inlineViewDrewSomething(authView) + agentWindowsSeen.formUnion(authAgentWindowOwners()) + log.note("heartbeat", [ + "n": heartbeats, + "authViewFrameWidth": authView.frame.width, + "authViewFrameHeight": authView.frame.height, + "windowIsKey": window.isKeyWindow, + "windowIsVisible": window.isVisible, + "appIsActive": app.isActive, + "evaluateInvoked": evaluateInvoked, + // An armed inline prompt has to draw its glyph out of something. + // An empty view with no layer content is the difference between + // "waiting for a finger" and "never engaged at all", which is the + // one thing a run with nobody present cannot otherwise tell apart. + "authViewSubviews": authView.subviews.count, + "authViewHasLayerContents": authView.layer?.contents != nil, + "authViewSublayers": authView.layer?.sublayers?.count ?? 0, + // The standard alert is drawn by another process, so a window + // belonging to one of the system's authentication agents appearing + // while we are evaluating means the inline view was bypassed. + "authAgentWindows": authAgentWindowOwners().joined(separator: ","), + ]) + } + RunLoop.main.add(heartbeat, forMode: .common) + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + let hadArea = authView.frame.width > 0 && authView.frame.height > 0 + log.note("timed-out", [ + "evaluateInvoked": evaluateInvoked, + "authViewHadArea": hadArea, + ]) + finish(ScanResult( + authenticated: false, + // "Armed" means the prompt was genuinely presentable. Saying so + // honestly is what tells a stalled run from an unanswered one. + armed: evaluateInvoked && hadArea, + inlineDrew: inlineEverDrew, + agentWindows: agentWindowsSeen.sorted(), + durationMs: Int(Date().timeIntervalSince(start) * 1000), + error: "nobody answered the embedded prompt within \(Int(timeoutSeconds))s" + + (hadArea ? "" : "; the inline view had no drawable area, so there was nothing to touch"), + checklist: checklist + )) + } + + // The panel runs its window in a nested modal session; the probe has + // always used the plain run loop. Which of those the inline view can + // live with is exactly the sort of thing this probe exists to find out. + if ProcessInfo.processInfo.environment["_VARLOCK_PROBE_MODAL"] == "1" { + log.note("run-mode", ["mode": "runModal"]) + _ = app.runModal(for: window) + } else { + log.note("run-mode", ["mode": "run"]) + app.run() + } + heartbeat.invalidate() + log.note("run-loop-exited") + return result + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/FirstRunSetup.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/FirstRunSetup.swift new file mode 100644 index 000000000..ae80e8e8a --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/FirstRunSetup.swift @@ -0,0 +1,89 @@ +import AppKit + +/// The one-time "we are setting biometrics up" moment. +/// +/// Creating the custody key is the point where varlock starts using Touch ID, and +/// the first time a user meets that should read as setup rather than as an +/// unexplained system prompt appearing out of nowhere. This shows a short panel +/// once, ever, immediately before the first gated key is generated, and then +/// records that it has been shown. +/// +/// It is informational on purpose. The key is being created because the user just +/// asked for it, so there is nothing here to approve or refuse; a second button +/// that could only mean "then nothing works" would be theatre. The real consent +/// surface is the unlock panel, which shows up every time something wants the key. +enum FirstRunSetup { + /// The panel closes itself after this long. `generate-key` runs as a one-shot + /// under a caller timeout, so an unattended machine must not sit on a modal + /// until the parent gives up and kills it. + static let autoDismissSeconds: TimeInterval = 20 + + static var markerPath: String { + let keyStore = SecureEnclaveManager.keyStorePath + let parent = (keyStore as NSString).deletingLastPathComponent + return parent + "/.setup-shown" + } + + static var hasBeenShown: Bool { + return FileManager.default.fileExists(atPath: markerPath) + } + + static func markShown() { + let dir = (markerPath as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: markerPath, contents: Data()) + } + + /// Show the setup panel if this is the first gated key on this machine. + /// + /// Does nothing when the key needs no gate (CI keys created with `--no-auth`), + /// when a key already exists, when it has been shown before, or when there is + /// no window server to draw on. + static func showIfNeeded(requireAuth: Bool) { + guard requireAuth else { return } + guard !hasBeenShown else { return } + guard SecureEnclaveManager.listKeys().isEmpty else { + // An existing user upgrading into this build has already lived through + // the prompts. Record it and stay quiet. + markShown() + return + } + guard UiAvailability.canShowUi() else { return } + + markShown() + show() + } + + private static func show() { + let work = { + NSApplication.shared.setActivationPolicy(.accessory) + + let alert = NSAlert() + alert.messageText = "Setting up biometrics for varlock" + alert.informativeText = """ + varlock is creating an encryption key in this Mac's Secure Enclave. \ + The key never leaves the enclave, and macOS asks for Touch ID (or your \ + password) before anything can use it. + + Next time something needs a secret, varlock will ask you here first, and \ + you can choose to allow it once or for the rest of your session. + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + + alert.window.level = .floating + NSApp.activate(ignoringOtherApps: true) + + let deadline = DispatchWorkItem { NSApp.abortModal() } + DispatchQueue.main.asyncAfter(deadline: .now() + autoDismissSeconds, execute: deadline) + _ = alert.runModal() + deadline.cancel() + } + + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.sync { work() } + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IPCServer.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IPCServer.swift index e2ae11c19..e9eb64bd4 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IPCServer.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IPCServer.swift @@ -16,8 +16,12 @@ final class IPCServer { private let handlersQueue = DispatchQueue(label: "dev.varlock.ipc.handlers") private var isRunning = false - /// Handler for incoming messages. Second parameter is the peer's TTY identity (nil if unknown). - var messageHandler: ((_ message: [String: Any], _ sessionId: String?) -> [String: Any])? + /// Handler for incoming messages. + /// + /// `sessionId` is the peer's session identity (nil if unknown). `peerPid` is + /// the connecting process, which approval panels use to describe who is asking; + /// both are read from the socket, never from the message body. + var messageHandler: ((_ message: [String: Any], _ sessionId: String?, _ peerPid: pid_t?) -> [String: Any])? /// Called after accept (new client) and after each successfully parsed JSON message. var onConnectionActivity: (() -> Void)? @@ -255,23 +259,31 @@ final class IPCServer { } } - // Verify the connecting process is an allowed client + // Verify the connecting process is an allowed client, and that it is in a + // state worth handing secrets to. Both are read off the socket's peer, + // never from anything in the message. if let peerPid = getPeerPid(fd: fd) { + let path = getProcessPath(pid: peerPid) ?? "unknown" guard verifyPeerProcess(pid: peerPid) != nil else { - let path = getProcessPath(pid: peerPid) ?? "unknown" fputs("varlock: rejected IPC connection from unauthorized process (pid=\(peerPid), path=\(path))\n", stderr) - sendResponse(fd: fd, response: ["error": "Unauthorized client process"]) + sendResponse(fd: fd, response: [ + "error": "Unauthorized client process", + "errorCode": "PEER_NOT_ALLOWED", + ]) + return + } + if let violation = PeerPosture.check(pid: peerPid, path: path) { + sendResponse(fd: fd, response: [ + "error": violation.clientMessage, + "errorCode": violation.code, + ]) return } } - // Resolve the peer's session identity once per connection - let sessionId: String? - if let peerPid = getPeerPid(fd: fd) { - sessionId = getSessionIdentifier(forPid: peerPid) - } else { - sessionId = nil - } + // Resolve the peer's identity once per connection + let peerPid = getPeerPid(fd: fd) + let sessionId: String? = peerPid.flatMap { getSessionIdentifier(forPid: $0) } while isRunning { // Read 4-byte length prefix (little-endian) @@ -301,8 +313,8 @@ final class IPCServer { onConnectionActivity?() - // Handle message with the peer's TTY identity - let response = messageHandler?(json, sessionId) ?? ["error": "No handler"] + // Handle message with the peer's identity as read off the socket + let response = messageHandler?(json, sessionId, peerPid) ?? ["error": "No handler"] sendResponse(fd: fd, id: json["id"] as? String, response: response) } } diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentitySessionManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentitySessionManager.swift new file mode 100644 index 000000000..5b6e39469 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentitySessionManager.swift @@ -0,0 +1,1133 @@ +import Foundation +import CryptoKit +import LocalAuthentication +import IdentitySessions + +/// Holds identity keys on behalf of unlocked sessions. +/// +/// Two enclave keys are involved, and they do different jobs: +/// +/// - the CUSTODY key is the existing biometric device key. It wraps the identity +/// private key at rest (that wrap blob is what the identity file stores), so +/// opening a session always goes through user presence. +/// - the SESSION key is created per unlock, with `.privateKeyUsage` only and no +/// presence requirement. Its key data lives in this process's memory and is +/// never written to disk. +/// +/// At unlock the identity key is unwrapped once through the custody key and +/// immediately re-wrapped under the session key. Only that session-wrapped blob is +/// held. Each later decrypt unwraps it silently through the session key, uses the +/// identity key for the batch, and drops it again. Ending a session scrubs the +/// session key data, which crypto-erases every blob held under it. +/// +/// Nothing here is persisted. A daemon restart loses all sessions on purpose: a +/// session-wrapped blob on disk plus a no-presence enclave key would open silently +/// after a reboot, which is exactly the biometric gate this is built to keep. +final class IdentitySessionManager { + /// Max wait for the biometric prompt before giving up, matching `SessionManager`. + static let biometricTimeoutSeconds: TimeInterval = 60 + + /// How often expired grants are swept, so a hard-cap expiry erases key material + /// even on a daemon nobody is talking to. + static let pruneIntervalSeconds: TimeInterval = 60 + + /// Which LocalAuthentication policy an unlock ran under. + enum UnlockPolicy: String { + case biometrics = "biometrics" + case deviceOwner = "device-owner" + /// The custody key carries no presence requirement (created with `--no-auth` + /// for CI), so there was nothing to prompt for. + case none = "no-presence-required" + } + + struct UnlockOutcome { + let grants: [SessionGrantInfo] + let policy: UnlockPolicy + /// The resolved lock policy and where it came from + let lockOn: SessionLockPolicy + let lockOnSource: LockPolicyResolution.Source + /// Whether the user was actually shown the approval panel for this call. + let prompted: Bool + } + + /// What the daemon knows about who is asking, gathered before any panel. + /// + /// `requester` is derived by the daemon from the peer process itself and is + /// the trust-bearing part. `display` is decoration the client sent: it changes + /// the wording, never the decision. + struct UnlockRequestContext { + var requester: PanelRequester = PanelRequester(summary: "") + var display: UnlockDisplayInfo = UnlockDisplayInfo() + /// The ciphertexts this unlock is being asked to cover, by key id. + /// + /// The one part of a request that is neither derived nor decoration. The + /// client sends payloads; the daemon hashes them itself, and those + /// digests are what an item-scoped grant is bound to. A label the client + /// attached to a payload never reaches here: that decides what the panel + /// says, this decides what the grant opens. + var itemDigests: [String: Set] = [:] + } + + /// The answer to an approval panel, or the reason there wasn't one. + enum UnlockPromptOutcome { + /// Approved, with the presence check that approved it. The proof is what + /// keeps a single scan covering the enclave work that follows. + case approved(PanelDecision, PresenceProof?) + case denied + /// No window server, so nobody could be asked. + case noUi + } + + enum IdentitySessionError: LocalizedError { + case noSessionIdentity + case noKeysRequested + case biometricFailed(String) + case sessionKeyMissing + case notUtf8 + case approvalDenied + case noUi + + var errorDescription: String? { + switch self { + case .noSessionIdentity: + return "Cannot scope an unlock session for this process; no session identity could be determined" + case .noKeysRequested: + return "No key ids were named to unlock; send keyIds (or keyId) in the payload" + case .biometricFailed(let msg): + return "Biometric authentication failed: \(msg)" + case .sessionKeyMissing: + return "The unlock session is no longer held by the daemon; unlock again" + case .notUtf8: + return "Decrypted data is not valid UTF-8" + case .approvalDenied: + return "The unlock was not approved" + case .noUi: + return "This Mac has no screen available to approve on; run this from a desktop session" + } + } + + var code: String { + switch self { + case .noSessionIdentity: return "NO_SESSION_IDENTITY" + case .noKeysRequested: return "NO_KEYS_REQUESTED" + case .biometricFailed: return "BIOMETRIC_FAILED" + case .sessionKeyMissing: return "SESSION_KEY_MISSING" + case .notUtf8: return "NOT_UTF8" + case .approvalDenied: return "APPROVAL_DENIED" + case .noUi: return "NO_UI" + } + } + } + + /// In-memory material for one unlocked session. + private struct SessionMaterial { + /// Ephemeral Secure Enclave key data representation. Opaque to us, useless + /// without the enclave, and dropped (scrubbed) when the session ends. + var sessionKeyData: Data + /// identityId+keyId -> identity private key (PKCS#8 DER) wrapped to the session key + var wrappedIdentities: [String: Data] = [:] + } + + private var material: [String: SessionMaterial] = [:] + private let grants: SessionGrantTable + private let audit: AuthorizationAuditLog + private let queue = DispatchQueue(label: "dev.varlock.identity-session") + private var pruneTimer: DispatchSourceTimer? + + /// Shows the approval panel. Injected so the manager never imports the view, + /// and so a caller with no display can be told `NO_UI` instead of guessing. + /// + /// The panel is handed the presence attempt so it can bind the embedded prompt + /// to that context, and hands back the authenticated context it produced. + var promptHandler: ((PanelContent, String, PresenceAttempt?) -> UnlockPromptOutcome)? + + /// How often a given key must be re-approved. Injected for the same reason: + /// the policy lives on disk next to the key, which is not this type's job. + var keyPolicy: (String) -> KeyAuthPolicy = { _ in .standard } + + /// Whether Touch ID still has to be set up for varlock on this machine (or + /// set up again, after the enrolment changed). Injected because where that is + /// recorded is the store's business, not this type's. + var needsBiometricSetup: () -> Bool = { false } + + /// Remember that the setup scan just happened. + var recordBiometricSetup: () -> Void = {} + + init( + grants: SessionGrantTable = SessionGrantTable(), + audit: AuthorizationAuditLog = AuthorizationAuditLog(directoryPath: IdentityStore.auditDir) + ) { + self.grants = grants + self.audit = audit + startPruneTimer() + } + + deinit { + pruneTimer?.cancel() + } + + // MARK: - Unlock + + /// Open (or extend) a session: one approval, one user-presence check, however + /// many keys. + /// + /// The approval and the scan are the same gesture. The panel arms an embedded + /// Touch ID prompt bound to the context this unlock will run under, so the + /// window that says who is asking is also the window the finger lands on, and + /// no separate system dialog appears. The context that scan authenticated is + /// then handed straight to the enclave operation, which is what keeps one scan + /// covering the whole unlock. `probe-embedded-unlock` proves that handoff on a + /// real machine; see the package README. + func unlock( + sessionId: String?, + keyIds: [String], + identityId: String, + scope: SessionGrantScope, + durationMs: Int64?, + lockOnOverride: String? = nil, + requestContext: UnlockRequestContext = UnlockRequestContext() + ) throws -> UnlockOutcome { + guard let sessionId, !sessionId.isEmpty else { + throw IdentitySessionError.noSessionIdentity + } + // A caller that named no key has asked for nothing. Picking a key for it + // would hand it a grant it never requested, so say so instead. + guard !keyIds.isEmpty else { + throw IdentitySessionError.noKeysRequested + } + let identity = try IdentityStore.read(identityId: identityId) + + // What this unlock asked for, else the machine config, else the default. + // Read fresh so editing the config file needs no daemon restart. + let lockPolicy = LockPolicyResolution.resolve( + overrideWireValue: lockOnOverride, + machineConfigData: IdentityStore.readMachineConfigData() + ) + + // Fail before prompting if none of the requested keys can open this identity + let usableKeyIds = keyIds.filter { identity.wraps[$0] != nil } + guard !usableKeyIds.isEmpty else { + throw IdentityStore.IdentityStoreError.noWrapForKey( + identityId: identityId, + keyId: keyIds.first ?? "unknown" + ) + } + + // A key created with `--no-auth` has no gate to satisfy, so there is + // nothing to approve and nothing to scan: that path stays silent. + let silentContext = silentContextIfUngated( + probeWrap: identity.wraps[usableKeyIds[0]], + probeKeyId: usableKeyIds[0] + ) + let needsPresence = silentContext == nil + let mustAsk = needsPresence || UiAvailability.isPromptForced + + var keysToOpen = usableKeyIds + var carriedGrants: [SessionGrantInfo] = [] + var chosenScope = scope + var chosenDurationMs = durationMs + // The broad answer, which is what an unlock covered before there was a + // choice. Only a panel can narrow it: a caller cannot ask for item scope + // and a caller cannot ask to be let out of one. + // + // Held per vault even though one checkbox currently sets it for all of + // them, so that a per-vault control (broad on your own local vault, + // narrow on a shared team one) is a change to the panel and nothing + // else. See `UnlockBreadthSelection`. + var chosenBreadth = UnlockBreadthSelection.uniform(.wholeKey) + var prompted = false + var presenceProof: PresenceProof? + + if mustAsk { + let plan = planUnlock( + sessionId: sessionId, + keyIds: usableKeyIds, + scope: scope, + durationMs: durationMs, + display: requestContext.display, + itemDigests: requestContext.itemDigests + ) + + guard plan.requiresPrompt else { + // Everything asked for is already covered by a live grant. Asking + // again would be a prompt that changes nothing, so we hand back + // what the session already holds. + silentContext?.invalidate() + let live = liveGrants(sessionId: sessionId, keyIds: usableKeyIds) + return UnlockOutcome( + grants: live, + policy: needsPresence ? .biometrics : .none, + // Nothing was re-granted, so the live grants keep the policy + // they were opened under rather than taking this call's. + lockOn: live.first?.lockOn ?? lockPolicy.policy, + lockOnSource: lockPolicy.source, + prompted: false + ) + } + + // Where the two controls open, and why. One rule, in one place: the + // narrowest of the broad default, the risk this request carries, and + // any narrowing the user chose here before. + let projectPath = requestContext.display.projectPath + let remembered = rememberedNarrowing(projectPath: projectPath, keyIds: plan.promptKeys.map { $0.keyId }) + let preselection = UnlockDefaults.preselect( + signals: UnlockRiskSignals.read( + chain: requestContext.requester.chain, + projectPath: projectPath, + seenBefore: remembered?.approvedBefore ?? false + ), + remembered: remembered, + offeredBreadths: plan.offeredBreadths, + offeredScopes: plan.offeredScopes + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: requestContext.requester, + display: requestContext.display, + preselection: preselection + ) + // The system's sheet is built from the same content the panel draws, + // so the two can never tell different stories. It stays a short verb + // phrase: the sheet lands on top of the panel, and a sheet that + // repeats who is asking is the panel's job done twice and worse. + let reason = content.presenceReason + // Setup first, alone, and only then the panel: see + // `runBiometricSetupIfNeeded`. The attempt the panel arms is created + // afterwards, so the scan that set Touch ID up cannot be the scan + // that approves this unlock. + if needsPresence { + do { + try runBiometricSetupIfNeeded() + } catch { + silentContext?.invalidate() + throw error + } + } + + // No presence check to make when the key is ungated: the panel is only + // up because a prompt was forced, so it keeps its plain button. + let attempt = needsPresence ? beginPresence() : nil + + switch promptHandler?(content, reason, attempt) ?? .noUi { + case .noUi: + silentContext?.invalidate() + attempt?.context.invalidate() + throw IdentitySessionError.noUi + case .denied: + silentContext?.invalidate() + attempt?.context.invalidate() + throw IdentitySessionError.approvalDenied + case .approved(let decision, let proof): + chosenScope = decision.scope + chosenDurationMs = decision.durationMs + chosenBreadth = UnlockBreadthSelection.granted(by: decision, offered: plan.offeredBreadths) + presenceProof = proof + // Remember only a narrowing, and forget one by choosing the + // default again. Best effort: a preference that will not write + // must never cost somebody an unlock they just approved. + UnlockPreferenceStore.record( + projectPath: projectPath, + keyIds: plan.promptKeys.map { $0.keyId }, + // What the user CHOSE, not what the grant got. Under `once` + // these differ: the grant is narrow and the choice is nil, + // so a duration answer writes down nothing about breadth. + breadth: decision.chosenBreadth, + window: GrantWindow(scope: chosenScope, durationMs: chosenDurationMs) + ) + } + + prompted = true + keysToOpen = plan.promptKeys.map { $0.keyId } + carriedGrants = liveGrants(sessionId: sessionId, keyIds: plan.coveredKeys.map { $0.keyId }) + } + + let context: LAContext + let policy: UnlockPolicy + // The scan (or password) the user already gave. Reusing that exact context + // is the single-scan promise: a fresh one here would raise a second prompt. + let probeKeyId = keysToOpen.first + let usableProof = presenceProof.flatMap { proof in + proofOpensKey(proof, keyId: probeKeyId, wrap: probeKeyId.flatMap { identity.wraps[$0] }) + ? proof + : nil + } + if let silentContext { + context = silentContext + policy = .none + } else if let usableProof { + context = usableProof.context + policy = usableProof.policy + } else { + // Nothing has asked yet, or what the panel came back with turned out + // not to satisfy this key's gate. Asking again costs the user a second + // prompt, which is better than failing an unlock they already answered. + presenceProof?.context.invalidate() + (context, policy) = try authenticate(reason: UnlockPanelContent.presenceReason( + forKeyIds: keysToOpen, + display: requestContext.display + )) + } + defer { context.invalidate() } + + return try queue.sync { + var granted: [SessionGrantInfo] = carriedGrants + + for keyId in keysToOpen { + guard let wrapBase64 = identity.wraps[keyId] else { continue } + let vaultId = requestContext.display.vaultId(forKey: keyId) + guard let wrapData = Data(base64Encoded: wrapBase64) else { + throw IdentityStore.IdentityStoreError.malformed(identityId) + } + + // Custody unwrap, under the context we already authenticated + var identityKeyBase64 = try SecureEnclaveManager.decrypt( + payload: wrapData, + keyId: keyId, + context: context + ) + defer { scrub(&identityKeyBase64) } + var identityKeyDer = try RawBase64.decode(identityKeyBase64) + defer { scrub(&identityKeyDer) } + + let sessionPublicKey = try ensureSessionKeyLocked(sessionId: sessionId) + let rewrapped = try Ecies.encrypt( + plaintext: identityKeyDer, + to: sessionPublicKey, + version: Ecies.devicePayloadVersion + ) + material[sessionId]?.wrappedIdentities[Self.blobKey(identityId, keyId)] = rewrapped + + // A key set to ask every time only ever takes a `once` grant, no + // matter what the rest of the batch was approved for. + let policyForKey = keyPolicy(keyId) + granted.append(grants.grant( + ref: SessionGrantRef(sessionId: sessionId, keyId: keyId), + identityId: identityId, + scope: UnlockPlanner.effectiveScope(chosen: chosenScope, policy: policyForKey), + durationMs: UnlockPlanner.effectiveDurationMs( + chosen: chosenScope, + chosenDurationMs: chosenDurationMs, + policy: policyForKey + ), + lockOn: lockPolicy.policy, + // A narrow grant is bound to the digests the daemon computed + // for this request, and to nothing else. A key that arrived + // with no digests cannot be narrowed to them (that would be a + // grant that opens nothing), so it keeps the whole key. + // + // Resolved through the vault, not read off a single answer: + // one checkbox sets every vault today, and this is the line + // that stops being true first when it stops setting them all. + coveredItems: coveredItems( + breadth: chosenBreadth.breadth(forVault: vaultId), + digests: requestContext.itemDigests[keyId] + ), + // What the user was shown, so a broad approval can never + // reach a vault that was not on the panel. + vaultId: vaultId + )) + } + + // A session the daemon holds with no record of who opened it is the + // hole this log exists to close, so an unlock that cannot be recorded + // gives its keys straight back. + do { + try audit.append(AuthorizationRecord( + kind: .unlock, + sessionId: sessionId, + keyIds: keysToOpen.sorted(), + identityId: identityId, + scope: chosenScope.rawValue, + breadth: chosenBreadth.narrowest.rawValue, + coveredItemCount: chosenBreadth.narrowest == .listedItems + ? granted.compactMap { $0.coveredItemCount }.reduce(0, +) + : nil, + requester: requestContext.requester.summary + )) + } catch { + for keyId in keysToOpen { + grants.invalidate(sessionId: sessionId, keyId: keyId) + } + reconcileLocked() + throw error + } + + reconcileLocked() + return UnlockOutcome( + grants: granted, + policy: policy, + lockOn: lockPolicy.policy, + lockOnSource: lockPolicy.source, + prompted: prompted + ) + } + } + + // MARK: - Breadth + + /// What a chosen breadth means for one key's grant. + /// + /// `nil` is the whole key. A key that arrived with no digests keeps the + /// whole key even under a narrow choice: binding it to an empty set would + /// be a grant that opens nothing, which is a broken unlock dressed up as a + /// careful one. The panel only offers the narrow choice when every key in + /// the question brought digests, so this is the belt to that braces. + private func coveredItems(breadth: SessionGrantBreadth, digests: Set?) -> Set? { + guard breadth == .listedItems, let digests, !digests.isEmpty else { return nil } + return digests + } + + /// The narrowing this Mac remembers for a batch, if any. + /// + /// Several keys in one question are folded into the narrowest thing any of + /// them remembers, and `approvedBefore` holds only when EVERY key has been + /// approved here before. Both fold in the restrictive direction on purpose: + /// a memory can only ever tighten a preselection, so a fold that guesses + /// wrong costs a panel rather than an over-broad grant. + private func rememberedNarrowing(projectPath: String?, keyIds: [String]) -> UnlockNarrowing? { + guard projectPath != nil, !keyIds.isEmpty else { return nil } + let rows = keyIds.map { UnlockPreferenceStore.narrowing(projectPath: projectPath, keyId: $0) } + let breadths = rows.compactMap { $0?.breadth } + let windows = rows.compactMap { $0?.window } + let seenAll = rows.allSatisfy { $0?.approvedBefore == true } + let folded = UnlockNarrowing( + breadth: breadths.isEmpty ? nil : SessionGrantBreadth.narrowest(breadths), + window: windows.isEmpty ? nil : GrantWindow.narrowest(windows), + approvedBefore: seenAll + ) + return folded.isEmpty ? nil : folded + } + + // MARK: - Planning + + /// Work out what this unlock still has to ask about. + /// + /// The rules themselves live in `UnlockPlanner`, which knows nothing about + /// enclaves or windows and is unit tested on its own. All this does is read + /// the live grants and each key's policy and hand them over. + private func planUnlock( + sessionId: String, + keyIds: [String], + scope: SessionGrantScope, + durationMs: Int64?, + display: UnlockDisplayInfo, + itemDigests: [String: Set] = [:] + ) -> UnlockPlan { + return queue.sync { + var existing: [String: ExistingGrantSnapshot] = [:] + for keyId in keyIds { + let ref = SessionGrantRef(sessionId: sessionId, keyId: keyId) + guard let live = grants.liveGrant(ref: ref) else { continue } + existing[keyId] = ExistingGrantSnapshot( + scope: live.scope, + remainingMs: live.remainingMs, + coveredItems: grants.coveredItems(ref: ref), + vaultId: grants.vaultId(ref: ref) + ) + } + let requested = keyIds.map { keyId in + RequestedKey( + keyId: keyId, + policy: keyPolicy(keyId), + itemCount: display.itemCounts[keyId], + itemDigests: itemDigests[keyId] ?? [], + // Which of a key's sources item scope cannot reach. Read off + // the source kind, so a kind added later is not silently + // assumed to be narrowable. + hasUnlistableSource: display.keys[keyId]?.sources.contains { !$0.isItemScopable } ?? false, + vaultId: display.vaultId(forKey: keyId) + ) + } + return UnlockPlanner.plan( + requested: requested, + requestedScope: scope, + requestedDurationMs: durationMs, + existing: existing + ) + } + } + + /// Live grants for the named keys, for the keys that still have one. + private func liveGrants(sessionId: String, keyIds: [String]) -> [SessionGrantInfo] { + return queue.sync { + keyIds.compactMap { grants.liveGrant(ref: SessionGrantRef(sessionId: sessionId, keyId: $0)) } + } + } + + // MARK: - Decrypt + + /// Decrypt a batch of v2 payloads under a live grant. No prompt, no key on the wire. + /// + /// The batch is one grant use: a `once` grant covers this call and is then spent, + /// however many payloads it carried. + /// + /// Nothing is decrypted until the authorization is on disk. If the record + /// cannot be written the call is refused, which does spend a `once` grant on a + /// batch that returned nothing. That is the safe direction to fail in: the + /// alternative is handing back secrets with no record that it happened. + func decryptV2( + sessionId: String?, + keyId: String, + identityId: String, + payloads: [Data], + requester: String? = nil + ) throws -> (plaintexts: [String], grant: SessionGrantInfo) { + guard let sessionId, !sessionId.isEmpty else { + throw IdentitySessionError.noSessionIdentity + } + + return try queue.sync { + let ref = SessionGrantRef(sessionId: sessionId, keyId: keyId) + let consumed: (info: SessionGrantInfo, change: SessionGrantChange) + do { + // The enforcement point. Digests are computed HERE, from the + // payloads about to be opened, so what a grant covers is decided + // by the bytes rather than by anything the caller said about + // them. A batch carrying something an item-scoped grant was not + // approved over is refused whole, before the audit record, before + // the session key is touched, and without charging the grant. + consumed = try grants.consume( + ref: ref, + itemDigests: GrantItemDigest.of(payloads), + // The value cache, which is never item scoped: read out of + // varlock's own cache file at a path this process computes, + // never a membership the caller asserted. See CacheCiphertexts. + alsoCovered: { CacheCiphertexts.digests(keyId: keyId) } + ) + } catch { + reconcileLocked() + throw error + } + + try audit.append(AuthorizationRecord( + kind: .decrypt, + sessionId: sessionId, + keyIds: [keyId], + identityId: identityId, + payloadCount: payloads.count, + scope: consumed.info.scope.rawValue, + breadth: consumed.info.breadth.rawValue, + coveredItemCount: consumed.info.coveredItemCount, + requester: requester + )) + + guard let held = material[sessionId], + let wrapped = held.wrappedIdentities[Self.blobKey(identityId, keyId)] else { + reconcileLocked() + throw IdentitySessionError.sessionKeyMissing + } + + // Silent unwrap through the session key: no presence flag, no prompt + let sessionKey = try SecureEnclave.P256.KeyAgreement.PrivateKey( + dataRepresentation: held.sessionKeyData + ) + var identityKeyDer = try Ecies.decrypt( + payload: wrapped, + using: sessionKey, + acceptedVersions: [Ecies.devicePayloadVersion] + ) + defer { scrub(&identityKeyDer) } + let identityKey = try IdentityKeyImport.p256KeyAgreementKey(fromPkcs8: identityKeyDer) + + var plaintexts: [String] = [] + plaintexts.reserveCapacity(payloads.count) + for payload in payloads { + var decrypted = try Ecies.decrypt( + payload: payload, + using: identityKey, + acceptedVersions: [Ecies.identityPayloadVersion] + ) + defer { scrub(&decrypted) } + guard let text = String(data: decrypted, encoding: .utf8) else { + throw IdentitySessionError.notUtf8 + } + plaintexts.append(text) + } + + if !consumed.change.closedSessions.isEmpty { + reconcileLocked() + } + return (plaintexts, consumed.info) + } + } + + // MARK: - Listing and invalidation + + /// Every live grant, typed. The menu bar reads this; `listGrants` is the same + /// thing flattened for the wire. + func liveGrantInfos() -> [SessionGrantInfo] { + return queue.sync { + reconcileLocked() + return grants.list() + } + } + + func listGrants() -> [[String: Any]] { + return liveGrantInfos().map { $0.toDictionary() } + } + + /// Drop grants and crypto-erase any session left holding nothing. + /// + /// Omitting both arguments drops everything, which is what the argument-less + /// `invalidate-session` has always done. + @discardableResult + func invalidate(sessionId: String? = nil, keyId: String? = nil, requester: String? = nil) -> Int { + return queue.sync { + let change = grants.invalidate(sessionId: sessionId, keyId: keyId) + reconcileLocked() + + // Recorded best effort, unlike the two paths above. Refusing to erase + // key material because a log line would not write is the wrong way + // round: the erase is the safe outcome, and blocking it to protect the + // record would leave the daemon holding keys it was told to drop. + if change.dropped > 0 { + do { + try audit.append(AuthorizationRecord( + kind: .invalidate, + sessionId: sessionId ?? "*", + keyIds: keyId.map { [$0] } ?? ["*"], + payloadCount: 0, + requester: requester + )) + } catch { + fputs("varlock: could not record an invalidation: \(error.localizedDescription)\n", stderr) + } + } + return change.dropped + } + } + + /// Handle a system lock event, erasing only the sessions whose own policy says + /// this event ends them. + /// + /// Separate from `invalidate()`, which is the explicit lock and always erases + /// everything. Called by the notification observers, and directly by tests. + @discardableResult + func handleLockEvent(_ event: SessionLockEvent) -> Int { + return queue.sync { + let change = grants.invalidate(onLockEvent: event) + reconcileLocked() + return change.dropped + } + } + + /// The lock policy a live session resolved to, for tests and diagnostics. + func lockPolicy(forSession sessionId: String) -> SessionLockPolicy? { + return queue.sync { + return grants.lockPolicy(forSession: sessionId) + } + } + + /// Whether the daemon is holding anything. Gates the idle auto-quit. + func hasLiveSessions() -> Bool { + return queue.sync { + reconcileLocked() + return grants.hasLiveSessions() + } + } + + // MARK: - Authentication + + /// A context the custody unwrap can run under with no gate at all, if this key + /// turns out not to have one. Returns nil when the key is presence gated. + /// + /// A key created with `--no-auth` (CI) has no presence requirement, and asking + /// the machine is more reliable than trying to read the access control back off + /// a stored key. So we try one non-interactive unwrap first: it either works, + /// which proves there was no gate to satisfy, or it fails and the caller has to + /// ask for real. A gated key can never be opened by that probe, so this cannot + /// weaken the gate; it only avoids asking where there is nothing to ask about. + private func silentContextIfUngated(probeWrap: String?, probeKeyId: String) -> LAContext? { + guard let probeWrap, let probeData = Data(base64Encoded: probeWrap) else { return nil } + let silent = LAContext() + silent.interactionNotAllowed = true + if var probed = try? SecureEnclaveManager.decrypt( + payload: probeData, + keyId: probeKeyId, + context: silent + ) { + scrub(&probed) + return silent + } + silent.invalidate() + return nil + } + + /// Whether the context the panel came back with can really open the key. + /// + /// A biometric proof is taken as read: that handoff is what + /// `probe-embedded-unlock` proves on real hardware, and probing it again + /// would cost every unlock an extra enclave round trip. The password path + /// pre-authorizes through an access control instead of a policy, so it is + /// asked once, silently (the proof context refuses interaction by now), and a + /// credential the enclave will not take costs the user another prompt rather + /// than costing them the unlock. + private func proofOpensKey(_ proof: PresenceProof, keyId: String?, wrap: String?) -> Bool { + guard proof.policy != .biometrics else { return true } + guard let keyId, let wrap, let wrapData = Data(base64Encoded: wrap) else { return true } + guard var probed = try? SecureEnclaveManager.decrypt( + payload: wrapData, + keyId: keyId, + context: proof.context + ) else { return false } + scrub(&probed) + return true + } + + /// Do the first-use Touch ID setup, on its own, before any panel exists. + /// + /// macOS raises its own sheet the moment a policy is evaluated, and on first + /// use (or after a re-enrolment) that sheet lands on top of whatever is + /// behind it. With the approval panel behind it, one finger satisfied both: + /// the setup and the approval, before anyone had read what was being + /// unlocked. So this runs alone, with nothing drawn, says what it is, and + /// throws its context away afterwards. The approval is a separate scan taken + /// while the panel is on screen, which costs a first run two scans on + /// purpose. + /// + /// Does nothing when setup is already recorded for this enrolment, or when + /// there is no screen to ask on (the unlock then fails as `NO_UI`, which is + /// the honest answer rather than a prompt nobody can see). + func runBiometricSetupIfNeeded() throws { + guard needsBiometricSetup(), UiAvailability.canShowUi() else { return } + + PanelDebug.note("setup-presence-begin") + do { + // A context of its own, invalidated immediately: a setup scan must + // never be able to stand in for an approval. + let (context, _) = try authenticate(reason: BiometricSetupPolicy.setupReason) + context.invalidate() + } catch { + PanelDebug.note("setup-presence-completed", ["success": false]) + throw error + } + recordBiometricSetup() + PanelDebug.note("setup-presence-completed", ["success": true]) + } + + /// One user-presence check with no key operation attached, used by + /// `request-approval` when the caller asks for a biometric on top of the panel. + func verifyUserPresence(reason: String) throws { + let (context, _) = try authenticate(reason: reason) + context.invalidate() + } + + /// Why a presence check ended without an answer. + /// + /// Dismissing the system sheet is not a refusal of the request, and it is not + /// a sensor failure either: it means "not now, not this way". The panel says + /// something different for each, and re-arms for none of them. + struct PresenceFailure: LocalizedError { + enum Kind { + /// The user dismissed the sheet. + case cancelled + /// The user asked for the password instead, from inside the sheet. + case wantsPassword + /// The check ran and did not succeed. + case failed + } + + let kind: Kind + let message: String + + var errorDescription: String? { message } + + init(error: Error?) { + message = error?.localizedDescription ?? "Authentication failed" + switch (error as? LAError)?.code { + case .userCancel, .systemCancel, .appCancel: + kind = .cancelled + case .userFallback: + kind = .wantsPassword + default: + kind = .failed + } + } + } + + /// A satisfied user-presence check, and the context it was satisfied under. + /// + /// The context is the valuable half. It is already authenticated, so handing it + /// to the enclave operation is what keeps one scan covering the whole unlock + /// rather than raising a second sheet. + struct PresenceProof { + let context: LAContext + let policy: UnlockPolicy + } + + /// A presence check the panel can build its UI around before running it. + /// + /// The context has to exist before the panel is drawn, because the embedded + /// prompt is bound to it: `LAAuthenticationView(context:)` is what makes + /// `evaluatePolicy` render inside our own window instead of raising the + /// standard system dialog. So this hands the panel the context first and lets + /// it start the evaluation when it is ready. + /// + /// The same instance serves a retry: the view stays bound to this context, so + /// re-evaluating it keeps the prompt where the user is already looking. + final class PresenceAttempt { + /// What this attempt asks the system for. + /// + /// A policy is the usual one. An access control is how the password path + /// gets a password field instead of a fingerprint: see `passwordFallback`. + enum Check { + case policy(LAPolicy) + case accessControl(SecAccessControl) + } + + let context: LAContext + let mode: ApprovalPresenceMode + private let check: Check + private let resolvedPolicy: UnlockPolicy + + convenience init( + context: LAContext, + mode: ApprovalPresenceMode, + policy: LAPolicy, + resolvedPolicy: UnlockPolicy + ) { + self.init(context: context, mode: mode, check: .policy(policy), resolvedPolicy: resolvedPolicy) + } + + init(context: LAContext, mode: ApprovalPresenceMode, check: Check, resolvedPolicy: UnlockPolicy) { + self.context = context + self.mode = mode + self.check = check + self.resolvedPolicy = resolvedPolicy + } + + /// The same approval, checked the other way: a password, asked for as a + /// password. + /// + /// A sensor that will not read a particular finger is common, and the way + /// out has to stay inside the one approval. It also has to be the way out + /// it says it is, and `evaluatePolicy(.deviceOwnerAuthentication)` is not: + /// on a Mac with an enrolled sensor that policy draws the Touch ID sheet + /// first and hides the password behind its "Use Password..." button, so a + /// user who has just said "not my finger" is asked for their finger again. + /// Apple documents that ordering, and there is no policy that skips it. + /// + /// `evaluateAccessControl` does skip it. An access control constrained to + /// `.devicePasscode` cannot be satisfied by biometry, so the system goes + /// straight to the password field. The context that comes back is + /// authenticated for device-owner presence, which is what the custody + /// key's own `.userPresence` gate accepts. + /// + /// Falls back to the policy (and its sheet) on a machine that will not + /// build that access control, and returns nil when there is no password + /// check to be had at all, where the caller keeps what it had. + func passwordFallback() -> PresenceAttempt? { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + context.invalidate() + return nil + } + if let passcodeOnly = Self.devicePasscodeAccessControl() { + return PresenceAttempt( + context: context, + mode: .systemDialog, + check: .accessControl(passcodeOnly), + resolvedPolicy: .deviceOwner + ) + } + return PresenceAttempt( + context: context, + mode: .systemDialog, + policy: .deviceOwnerAuthentication, + resolvedPolicy: .deviceOwner + ) + } + + /// "The device password, and only that", as an access control to evaluate. + /// + /// The protection class matches the one the custody key is created under, + /// so the two are asking about the same thing. `.privateKeyUsage` is + /// deliberately not included: LocalAuthentication refuses to evaluate an + /// access control carrying it ("Operation is not allowed"), and it says + /// nothing about which credential is wanted, which is all this is for. + static func devicePasscodeAccessControl() -> SecAccessControl? { + var error: Unmanaged? + let control = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + .devicePasscode, + &error + ) + error?.release() + return control + } + + /// Run the check. `completion` lands on the main queue. + /// + /// No timeout here on purpose: the prompt has the system's own, and the + /// panel bounds the whole interaction, so a second one would only give the + /// two a way to disagree. + func evaluate(reason: String, completion: @escaping (Result) -> Void) { + let finish = { [context, resolvedPolicy] (success: Bool, error: Error?) in + // Not `DispatchQueue.main.async`: the panel is drawn from inside a + // main-queue work item, so a block posted to that queue would wait + // for the panel to close before delivering the panel's own answer. + MainLoop.perform { + guard success else { + // Deliberately not invalidated: the panel may offer another + // go, on this same context. + completion(.failure(PresenceFailure(error: error))) + return + } + // From here on the context must not raise UI of its own, so a + // handed-off context that still wanted a prompt fails loudly + // instead of showing a second dialog. + context.interactionNotAllowed = true + completion(.success(PresenceProof(context: context, policy: resolvedPolicy))) + } + } + + switch check { + case .policy(let policy): + context.evaluatePolicy(policy, localizedReason: reason) { success, error in + finish(success, error) + } + case .accessControl(let accessControl): + context.evaluateAccessControl( + accessControl, + operation: .useKeyDecrypt, + localizedReason: reason + ) { success, error in + finish(success, error) + } + } + } + } + + /// Pick how this machine can ask for user presence right now. + /// + /// Biometrics get the embedded prompt, which is the whole point: one window, + /// one gesture. Anything else (no sensor, no enrolment, biometrics locked out + /// after too many failures) falls back to the standard system dialog driven by + /// the panel's button, which still accepts the device password. Returns nil + /// when there is no way to ask at all. + func beginPresence() -> PresenceAttempt? { + let context = LAContext() + + var biometricError: NSError? + if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometricError), + UiAvailability.embeddedPromptEnabled { + return PresenceAttempt( + context: context, + mode: .embedded, + policy: .deviceOwnerAuthenticationWithBiometrics, + resolvedPolicy: .biometrics + ) + } + + var fallbackError: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &fallbackError) else { + context.invalidate() + return nil + } + return PresenceAttempt( + context: context, + mode: .systemDialog, + policy: .deviceOwnerAuthentication, + resolvedPolicy: .deviceOwner + ) + } + + /// One user-presence check, then hand the authenticated context to the enclave. + private func authenticate(reason: String) throws -> (LAContext, UnlockPolicy) { + let context = LAContext() + + // Prefer biometrics. Machines with no enrolled sensor (or a locked-out one) + // fall back to the broader policy, which also accepts Apple Watch and the + // device password, rather than losing the feature entirely. + var policy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics + var resolved: UnlockPolicy = .biometrics + var policyError: NSError? + if !context.canEvaluatePolicy(policy, error: &policyError) { + policy = .deviceOwnerAuthentication + resolved = .deviceOwner + var fallbackError: NSError? + guard context.canEvaluatePolicy(policy, error: &fallbackError) else { + throw IdentitySessionError.biometricFailed( + fallbackError?.localizedDescription ?? "Authentication not available" + ) + } + } + + let semaphore = DispatchSemaphore(value: 0) + var evalError: Error? + context.evaluatePolicy(policy, localizedReason: reason) { success, error in + if !success { evalError = error } + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + Self.biometricTimeoutSeconds) == .timedOut { + context.invalidate() + throw IdentitySessionError.biometricFailed( + "Prompt timed out after \(Int(Self.biometricTimeoutSeconds))s" + ) + } + if let evalError { + throw IdentitySessionError.biometricFailed(evalError.localizedDescription) + } + + // From here on the context must not raise UI of its own. If the enclave + // operation still wanted a prompt we would rather fail loudly than show the + // user a second sheet for one unlock. + context.interactionNotAllowed = true + return (context, resolved) + } + + // MARK: - Session key material + + /// Create the session's enclave key if it has none, and return its public key. + /// Caller must hold `queue`. + @discardableResult + private func ensureSessionKeyLocked(sessionId: String) throws -> P256.KeyAgreement.PublicKey { + if let existing = material[sessionId] { + let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: existing.sessionKeyData) + return key.publicKey + } + let key = try SecureEnclaveManager.createEphemeralSessionKey() + material[sessionId] = SessionMaterial(sessionKeyData: key.dataRepresentation) + return key.publicKey + } + + /// Erase material for every session the grant table no longer considers live. + /// Caller must hold `queue`. + private func reconcileLocked() { + grants.pruneExpired() + let live = Set(grants.liveSessionIds()) + for sessionId in material.keys where !live.contains(sessionId) { + eraseLocked(sessionId: sessionId) + } + } + + /// Crypto-erase: scrubbing the session key data makes every blob wrapped under + /// it unreadable, since the enclave half of that key is unreachable without it. + /// Caller must hold `queue`. + private func eraseLocked(sessionId: String) { + guard var held = material.removeValue(forKey: sessionId) else { return } + scrub(&held.sessionKeyData) + for key in Array(held.wrappedIdentities.keys) { + if var blob = held.wrappedIdentities.removeValue(forKey: key) { + scrub(&blob) + } + } + } + + private static func blobKey(_ identityId: String, _ keyId: String) -> String { + return "\(identityId)\u{0}\(keyId)" + } + + private func startPruneTimer() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + Self.pruneIntervalSeconds, repeating: Self.pruneIntervalSeconds) + timer.setEventHandler { [weak self] in + self?.reconcileLocked() + } + timer.resume() + pruneTimer = timer + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentityStore.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentityStore.swift new file mode 100644 index 000000000..3753a996d --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/IdentityStore.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Reads the identity files the TypeScript side writes. +/// +/// An identity is a software P-256 key pair whose private key is never stored in +/// the clear: it is ECIES-wrapped to one or more device keys, so unwrapping it goes +/// through whatever gate the device backend applies. The file format is owned by +/// `packages/varlock/src/lib/local-encrypt/identity.ts`: +/// +/// { version, id, publicKey, wraps: { : }, createdAt } +/// +/// Only ciphertext and public keys live here, so ordinary String handling is fine. +/// The unwrapped private key never passes through this type. +enum IdentityStore { + static let fileVersion = 1 + static let defaultIdentityId = "default" + + struct StoredIdentity { + let id: String + /// base64 uncompressed P-256 public key, as written by the TS side + let publicKeyBase64: String + /// device key id -> wrapped identity private key (base64 v1 payload) + let wraps: [String: String] + } + + enum IdentityStoreError: LocalizedError { + case notFound(String) + case malformed(String) + case unsupportedVersion(Int) + case noWrapForKey(identityId: String, keyId: String) + + var errorDescription: String? { + switch self { + case .notFound(let id): + return "No local identity \"\(id)\" found on this machine" + case .malformed(let id): + return "Invalid identity file format for identity: \(id)" + case .unsupportedVersion(let version): + return "unsupported identity file version \(version); upgrade varlock" + case .noWrapForKey(let identityId, let keyId): + return "Identity \"\(identityId)\" has no wrap for key \"\(keyId)\" on this machine" + } + } + + var code: String { + switch self { + case .notFound: return "IDENTITY_NOT_FOUND" + case .malformed: return "IDENTITY_MALFORMED" + case .unsupportedVersion: return "IDENTITY_VERSION_UNSUPPORTED" + case .noWrapForKey: return "IDENTITY_NO_WRAP_FOR_KEY" + } + } + } + + /// Mirror of `getUserVarlockDir()` in the TS library, legacy directory included, + /// so both sides look in the same place. + static var userVarlockDir: String { + if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty { + return xdg + "/varlock" + } + let legacy = NSHomeDirectory() + "/.varlock" + if FileManager.default.fileExists(atPath: legacy) { + return legacy + } + return NSHomeDirectory() + "/.config/varlock" + } + + static func identityFilePath(_ identityId: String) -> String { + return userVarlockDir + "/identities/\(identityId).json" + } + + /// The user-level config file varlock already keeps (telemetry settings live + /// here too). Machine-wide, never project-level: a project must not be able to + /// weaken how long this machine holds keys. + static var machineConfigPath: String { + return userVarlockDir + "/config.json" + } + + /// Where the append-only authorization log lives. Under the user varlock dir + /// so it inherits that directory's owner-only access. + static var auditDir: String { + return userVarlockDir + "/audit" + } + + /// Replace the config file's contents, owner-readable only. + /// + /// Written to a temporary file in the same directory and renamed over the + /// original, so a crash mid-write cannot leave a half-written config that the + /// next unlock would report as unparseable. + static func writeMachineConfigData(_ data: Data) throws { + try FileManager.default.createDirectory( + atPath: userVarlockDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let tempPath = machineConfigPath + ".tmp-\(ProcessInfo.processInfo.processIdentifier)" + try data.write(to: URL(fileURLWithPath: tempPath), options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tempPath) + _ = try FileManager.default.replaceItemAt( + URL(fileURLWithPath: machineConfigPath), + withItemAt: URL(fileURLWithPath: tempPath) + ) + } + + /// Read the config file's contents, or nil when there is nothing to read. + /// + /// Read fresh at each unlock rather than cached or watched, so editing the file + /// takes effect on the next unlock with no daemon restart. + static func readMachineConfigData() -> Data? { + return FileManager.default.contents(atPath: machineConfigPath) + } + + static func read(identityId: String) throws -> StoredIdentity { + let path = identityFilePath(identityId) + guard let data = FileManager.default.contents(atPath: path) else { + throw IdentityStoreError.notFound(identityId) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw IdentityStoreError.malformed(identityId) + } + guard let version = json["version"] as? Int else { + throw IdentityStoreError.malformed(identityId) + } + guard version == fileVersion else { + throw IdentityStoreError.unsupportedVersion(version) + } + guard let publicKey = json["publicKey"] as? String, + let wraps = json["wraps"] as? [String: String], + !publicKey.isEmpty else { + throw IdentityStoreError.malformed(identityId) + } + return StoredIdentity( + id: (json["id"] as? String) ?? identityId, + publicKeyBase64: publicKey, + wraps: wraps + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeyAuthPolicyStore.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeyAuthPolicyStore.swift new file mode 100644 index 000000000..353e185ab --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeyAuthPolicyStore.swift @@ -0,0 +1,55 @@ +import Foundation +import IdentitySessions + +/// Per-key record of whether the user must be asked, and how often. +/// +/// The enclave knows whether a key is presence gated only as an access-control +/// flag baked into the key, which cannot be read back, and it knows nothing at +/// all about whether the user wanted that gate on every single read. Both are +/// recorded next to the key when it is created, in a small sidecar file: +/// +/// /.policy.json +/// -> { "version": 1, "authMode": "every-time", "requireAuth": true } +/// +/// A key with no sidecar reads as gated and `standard`, which is what every key +/// created before this file existed is assumed to be. That includes keys made +/// with `--no-auth` before the flag was recorded: they keep taking the daemon +/// path they always took until they are regenerated, which is the safe way to be +/// wrong. The file carries no secrets, so losing it is a downgrade in strictness +/// and never a leak. The daemon re-reads it per unlock rather than caching, so +/// editing the file takes effect on the next question rather than the next +/// daemon restart. +/// +/// Parsing and serializing live in `IdentitySessions.KeyAuthRecord`, which is +/// unit-tested; this type is only the file handling around it. +enum KeyAuthPolicyStore { + static func policyFilePath(for keyId: String) -> String { + return SecureEnclaveManager.keyStorePath + "/\(keyId).policy.json" + } + + static func record(for keyId: String) -> KeyAuthRecord { + guard let data = FileManager.default.contents(atPath: policyFilePath(for: keyId)), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return KeyAuthRecord(json: nil) + } + return KeyAuthRecord(json: json) + } + + static func policy(for keyId: String) -> KeyAuthPolicy { + return record(for: keyId).policy + } + + /// Record how a key must be authorized. Only called when creating one. + static func write(record: KeyAuthRecord, for keyId: String) throws { + let path = policyFilePath(for: keyId) + let dir = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: record.jsonObject) + try data.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) + } + + static func remove(for keyId: String) { + try? FileManager.default.removeItem(atPath: policyFilePath(for: keyId)) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/LARightProbe.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/LARightProbe.swift new file mode 100644 index 000000000..c95849930 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/LARightProbe.swift @@ -0,0 +1,292 @@ +import Foundation +import AppKit +import Security +import LocalAuthentication +import LocalAuthenticationEmbeddedUI + +/// Spike: does the `LARight` API give us the inline biometric prompt, and could +/// its key hold our custody wrap? +/// +/// Two separate questions, and this answers as much of each as a machine can. +/// +/// 1. PRESENTATION. `LAAuthenticationView` pairs with an `LAContext`, and driving +/// that context with `evaluatePolicy` has not produced an inline prompt here: +/// macOS presents its own alert instead. `LARight.authorize` is a different +/// entry point, and the suggestion is that the inline experience belongs to it. +/// The probe drives a right and logs everything, but where the prompt is drawn +/// is not something the process can see. A person has to watch. It also puts an +/// `LAAuthenticationView` on screen next to it, so there is something to watch +/// for: if the glyph animates in OUR window, inline works. +/// +/// 2. CUSTODY. Our unwrap is ECIES: ECDH against the device key, then HKDF and +/// AES-GCM. `LAPrivateKey` (from a persisted right) advertises +/// `exchangeKeys(publicKey:algorithm:parameters:)`, which is that ECDH. This +/// part needs no human at all: the key is asked, synchronously, whether it can +/// perform the operations our format needs, and the answer decides whether an +/// `LARight`-backed custody key is even possible. +/// +/// Run it with: `varlock-enclave probe-laright [--verbose] [--timeout ]`. +/// It creates a right under a throwaway identifier and removes it afterwards. +enum LARightProbe { + private static let rightIdentifier = "dev.varlock.probe.laright" + + /// - Parameter custodyOnly: skip the half that needs a person. Custody is the + /// machine-checkable question (can an `LARight` key hold our wrap, or does + /// the keychain refuse it for want of an entitlement), which is exactly + /// what a signing experiment wants to compare across variants without a + /// finger in the loop. + static func run(verbose: Bool, timeoutSeconds: TimeInterval, custodyOnly: Bool = false) -> [String: Any] { + let log = EmbeddedUnlockProbe.Log(verbose: verbose) + log.note("probe-start", [ + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "", + "timeoutSeconds": Int(timeoutSeconds), + ]) + + guard UiAvailability.canShowUi() else { + return finish(log: log, [ + "verdict": "inconclusive", + "reason": "no window server session; run this from a desktop session", + ]) + } + + // -- question 2 first, since it needs nobody and can rule the idea out -- + let custody = probeCustodyCapability(log: log) + + if custodyOnly { + return finish(log: log, [ + "verdict": custody["canExchangeKeys"] as? Bool == true + ? "custody-available" + : "custody-refused", + "custody": custody, + ]) + } + + // -- question 1: drive a right and let a person watch where it draws -- + let presentation = probePresentation(log: log, timeoutSeconds: timeoutSeconds) + + var verdict = "laright-unusable" + if custody["canExchangeKeys"] as? Bool == true, presentation["authorized"] as? Bool == true { + verdict = "laright-viable" + } else if presentation["authorized"] as? Bool == true { + verdict = "laright-authorizes-but-no-key-path" + } + + return finish(log: log, [ + "verdict": verdict, + "custody": custody, + "presentation": presentation, + "confirmVisually": "the question a program cannot answer: did the Touch ID prompt appear " + + "INSIDE the probe window, or as a separate system dialog?", + ]) + } + + private static func finish(log: EmbeddedUnlockProbe.Log, _ result: [String: Any]) -> [String: Any] { + var output = result + output["lifecycle"] = log.entries + return output + } + + // MARK: - Can an LARight key hold our custody wrap? + + /// Asks a right-backed key whether it can do what our wrap format needs. + /// + /// Creating the right may itself require authorization, so a failure here is + /// reported rather than treated as a verdict. The capability answers, when we + /// get them, are synchronous and need no scan. + private static func probeCustodyCapability(log: EmbeddedUnlockProbe.Log) -> [String: Any] { + let requirement = LAAuthenticationRequirement.biometry( + fallback: LABiometryFallbackRequirement.devicePasscode + ) + let right = LARight(requirement: requirement) + log.note("custody-right-built") + + var result: [String: Any] = ["attempted": true] + let done = DispatchSemaphore(value: 0) + + // A fresh identifier each run, so a leftover from a previous run cannot + // answer for this one. + let identifier = "\(rightIdentifier).\(UUID().uuidString.prefix(8))" + LARightStore.shared.saveRight(right, identifier: identifier) { persisted, error in + if let error { + log.note("custody-save-failed", ["error": error.localizedDescription]) + result["saved"] = false + result["error"] = error.localizedDescription + done.signal() + return + } + guard let persisted else { + result["saved"] = false + done.signal() + return + } + log.note("custody-right-saved", ["identifier": identifier]) + result["saved"] = true + + // The whole question: our unwrap is an ECDH against the device key. + let key = persisted.key + let ecdh = key.canExchangeKeys(using: .ecdhKeyExchangeCofactorX963SHA256) + let ecies = key.canDecrypt(using: .eciesEncryptionStandardVariableIVX963SHA256AESGCM) + log.note("custody-key-capabilities", [ + "canExchangeKeysECDH": ecdh, + "canDecryptECIES": ecies, + ]) + result["canExchangeKeys"] = ecdh + result["canDecryptECIES"] = ecies + + LARightStore.shared.removeRight(persisted) { removeError in + if let removeError { + log.note("custody-cleanup-failed", ["error": removeError.localizedDescription]) + } + done.signal() + } + } + + if done.wait(timeout: .now() + 20) == .timedOut { + log.note("custody-timed-out") + result["timedOut"] = true + } + return result + } + + // MARK: - Where does an LARight draw its prompt? + + private static func probePresentation( + log: EmbeddedUnlockProbe.Log, + timeoutSeconds: TimeInterval + ) -> [String: Any] { + let app = NSApplication.shared + app.setActivationPolicy(.regular) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 240), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = "Varlock LARight probe" + window.level = .floating + window.center() + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 14 + stack.translatesAutoresizingMaskIntoConstraints = false + + let heading = NSTextField(labelWithString: "LARight authorization") + heading.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(heading) + + let explainer = NSTextField(wrappingLabelWithString: + "Touch the sensor. WATCH WHERE THE PROMPT APPEARS. If it animates in the " + + "box below, the inline experience works and we can build on it. If a " + + "separate system dialog opens instead, it does not.") + explainer.alignment = .center + explainer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + explainer.textColor = .secondaryLabelColor + stack.addArrangedSubview(explainer) + + // A view bound to its own context, purely as something to watch. The right + // is not paired to it: nothing in the headers offers that pairing, which is + // itself part of the finding. + let watchContext = LAContext() + let authView = LAAuthenticationView(context: watchContext, controlSize: .large) + authView.translatesAutoresizingMaskIntoConstraints = false + let fitting = authView.fittingSize + NSLayoutConstraint.activate([ + authView.widthAnchor.constraint(equalToConstant: max(fitting.width, 64)), + authView.heightAnchor.constraint(equalToConstant: max(fitting.height, 64)), + ]) + stack.addArrangedSubview(authView) + + let content = NSView(frame: window.contentRect(forFrameRect: window.frame)) + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), + ]) + window.contentView = content + window.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + log.note("window-shown", ["isKeyWindow": window.isKeyWindow, "appIsActive": app.isActive]) + + var outcome: [String: Any] = ["authorized": false] + var finished = false + let start = Date() + + let stop: ([String: Any]) -> Void = { result in + guard !finished else { return } + finished = true + outcome = result + window.orderOut(nil) + NSApp.stop(nil) + NSApp.postEvent( + NSEvent.otherEvent( + with: .applicationDefined, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, subtype: 0, data1: 0, data2: 0 + )!, + atStart: true + ) + } + + let requirement = LAAuthenticationRequirement.biometry( + fallback: LABiometryFallbackRequirement.devicePasscode + ) + let right = LARight(requirement: requirement) + + DispatchQueue.main.async { + right.checkCanAuthorize { error in + log.note("checkCanAuthorize", ["error": error?.localizedDescription ?? ""]) + } + log.note("authorize-invoked", ["state": right.state.rawValue]) + right.authorize(localizedReason: "unlock varlock encryption keys") { error in + DispatchQueue.main.async { + let nsError = error as NSError? + log.note("authorize-completed", [ + "error": nsError?.localizedDescription ?? "", + "errorCode": nsError?.code ?? 0, + "state": right.state.rawValue, + "inlineViewDrewSomething": EmbeddedUnlockProbe.inlineViewDrewSomething(authView), + "authAgentWindows": EmbeddedUnlockProbe.authAgentWindowOwners().joined(separator: ","), + ]) + stop([ + "authorized": error == nil, + "error": nsError?.localizedDescription ?? NSNull(), + "rightState": right.state.rawValue, + "durationMs": Int(Date().timeIntervalSince(start) * 1000), + "inlineViewDrewSomething": EmbeddedUnlockProbe.inlineViewDrewSomething(authView), + "authAgentWindowsSeen": EmbeddedUnlockProbe.authAgentWindowOwners(), + ]) + } + } + } + + var beats = 0 + let heartbeat = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + beats += 1 + log.note("heartbeat", [ + "n": beats, + "rightState": right.state.rawValue, + "inlineViewDrewSomething": EmbeddedUnlockProbe.inlineViewDrewSomething(authView), + "authAgentWindows": EmbeddedUnlockProbe.authAgentWindowOwners().joined(separator: ","), + ]) + } + RunLoop.main.add(heartbeat, forMode: .common) + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + log.note("timed-out", ["rightState": right.state.rawValue]) + stop([ + "authorized": false, + "error": "nobody answered within \(Int(timeoutSeconds))s", + "rightState": right.state.rawValue, + "inlineViewDrewSomething": EmbeddedUnlockProbe.inlineViewDrewSomething(authView), + "authAgentWindowsSeen": EmbeddedUnlockProbe.authAgentWindowOwners(), + ]) + } + + app.run() + heartbeat.invalidate() + return outcome + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelChainView.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelChainView.swift new file mode 100644 index 000000000..670c39738 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelChainView.swift @@ -0,0 +1,847 @@ +import AppKit +import IdentitySessions +import SessionScoping + +/// "Who is asking", drawn as the line of processes that leads to the caller. +/// +/// The chain is always visible, because one process name is not an answer: `bun` +/// says nothing, and `agent.ts via bun, launched from iTerm2` says everything. +/// Size carries the meaning: the actor (the program the values are for) is large, +/// the app that was launched is small context at the top, and the plumbing in +/// between folds away until someone asks for it. Nothing is large when nothing +/// qualifies, which is the honest state for a command a person typed. +/// +/// One row is the session root, tinted purple and tagged: the process a "this +/// session" grant attaches to, which the panel is asking about in the same +/// breath. The rail below it is tinted to match, so everything inside that +/// session is a span you can see rather than a relationship you have to work +/// out, and it is never folded away. When the session belongs to a coding agent, +/// that row also carries the product, the session's title, and its start time, +/// and says so when nobody is watching it or when it is working somewhere other +/// than the project being unlocked. +/// +/// That row is also the only place a tty id appears. A controlling terminal is +/// inherited, so every hop below the session root is on the same one, and the app +/// at the top of the chain holds none of its own: saying "ttys004" anywhere else +/// is either the same fact twice or a fact about the wrong row. +/// +/// The line under varlock's own hop says how it came to be running: a typed +/// command with its command line, or the host that auto-loaded it. That is never +/// hidden behind the expander, because it is the difference between a person +/// asking and a program asking. Nor is the line saying WHICH varlock is running, +/// the compiled binary or its JavaScript under an interpreter, because those two +/// draw the same row and are not the same thing. +/// +/// Evidence (paths, versions, interpreters, signatures) lives on full-width lines +/// under the hop it belongs to, shown when the chain is opened. It used to be +/// crammed into the right-hand end of the hop's own row, where a path had a few +/// dozen points to live in and truncated to things like "~/Libra\u{2026}2.1.234". +/// Evidence you cannot read is not evidence. +final class PanelChainView: NSView { + private let onLayoutChanged: () -> Void + private var expanded = false + private var foldedRows: [NSView] = [] + /// Rows that only appear once the chain is opened, whatever else is folded. + private var evidenceRows: [NSView] = [] + /// The expander's label and what it says, so the preview can open the chain. + private var expander: (field: NSTextField, label: String)? + /// Marks that grow a word when the chain is opened. + private var postureMarks: [PostureMarkView] = [] + /// Every rail segment in the column, in order, so the last VISIBLE one can + /// stop drawing its half of the rail instead of trailing into a hidden row. + private var rails: [ChainRailView] = [] + + /// `startExpanded` is for the preview command, which has nobody to click the + /// expander and still has to be able to show what the opened chain looks like. + init( + chain: ExecutionChain, + fallbackSummary: String, + invocation: InvocationNote = InvocationNote(kind: .unknown), + sessionAdvisories: [String] = [], + reportedVarlockVersion: String? = nil, + startExpanded: Bool = false, + onLayoutChanged: @escaping () -> Void + ) { + self.onLayoutChanged = onLayoutChanged + super.init(frame: .zero) + defer { if startExpanded { openEverything() } else { updateRails() } } + + let card = PanelStyle.card(background: PanelStyle.chainBackground, border: PanelStyle.chainBorder) + card.translatesAutoresizingMaskIntoConstraints = false + addSubview(card) + NSLayoutConstraint.activate([ + card.leadingAnchor.constraint(equalTo: leadingAnchor), + card.trailingAnchor.constraint(equalTo: trailingAnchor), + card.topAnchor.constraint(equalTo: topAnchor), + card.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let column = PanelStyle.column(spacing: 0) + column.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(column) + NSLayoutConstraint.activate([ + column.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12), + column.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12), + column.topAnchor.constraint(equalTo: card.topAnchor, constant: 10), + column.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10), + ]) + + guard !chain.isEmpty else { + // Nothing could be read off the peer. Say the one line we do have + // rather than drawing an empty rail. + column.addArrangedSubview(PanelStyle.label( + fallbackSummary.isEmpty ? "Requested by an unidentified process" : fallbackSummary, + size: 12, + color: PanelStyle.ink + )) + return + } + + let collapsing = chain.collapsesWhenResting + for (index, hop) in chain.hops.enumerated() { + let row = hopRow(hop, isFirst: index == 0) + column.addArrangedSubview(row) + if collapsing, hop.isMinor { + row.isHidden = true + foldedRows.append(row) + } + + /// Everything under a hop shares its fold and its rail tint. + func addSubRow(_ view: NSView, hidden: Bool = false) { + column.addArrangedSubview(view) + if collapsing, hop.isMinor { + view.isHidden = true + foldedRows.append(view) + } else if hidden { + view.isHidden = true + evidenceRows.append(view) + } + } + + // WHICH varlock this is. Never folded away: a compiled binary and a + // directory of JavaScript files draw the same row, and the difference + // between them is not detail. + if let form = hop.runtimeForm { + addSubRow(runtimeFormRow( + form, + isCaution: hop.runtimeFormIsCaution, + insideSession: hop.isInsideSession + )) + } + + // How varlock came to be running, and what receives the values. Read + // from the kernel rather than from anything the client said, and + // always on screen: a typed command and an auto-load are different + // requests, and which one this is should never have to be inferred + // (or found behind a disclosure). + if hop.isRequester { + for line in invocation.commandLines { + addSubRow(commandRow(line, insideSession: hop.isInsideSession)) + } + } + + if let advisory = hop.advisory { + addSubRow(advisoryRow(advisory, insideSession: hop.isInsideSession)) + } + // What is unusual about the session itself: nobody watching it, or an + // agent working somewhere other than the project being unlocked. + if hop.isSessionRoot { + for advisory in sessionAdvisories { + addSubRow(advisoryRow(advisory, insideSession: true)) + } + } + + for evidence in evidenceLines(for: hop, reportedVarlockVersion: reportedVarlockVersion) { + addSubRow(evidenceRow(evidence, insideSession: hop.isInsideSession), hidden: true) + } + } + + // The expander appears whenever there is anything behind it, which for a + // short chain means the evidence lines. It used to be tied to folding + // hops away, so the commonest panel of all (an app, a shell, and varlock) + // had no control at all and its paths, versions, and signatures were + // unreachable: hidden with no way to ask. + if collapsing || !evidenceRows.isEmpty { + // The folded hops sit inside the session when the last one does, so + // the expander's own rail segment is tinted to match rather than + // breaking the span in half. + let insideSession = collapsing + ? (chain.collapsibleHops.last?.isInsideSession ?? false) + : (chain.hops.last?.isInsideSession ?? false) + column.addArrangedSubview(expanderView( + foldedLabel: collapsing ? chain.expanderLabel : nil, + insideSession: insideSession + )) + } + } + + required init?(coder: NSCoder) { fatalError("not used") } + + /// Open the chain without a click, for the preview. + private func openEverything() { + guard let (field, label) = expander else { + // Nothing folds away, but the evidence lines still have to appear. + expanded = true + applyExpansion() + return + } + toggleExpanded(field: field, label: label) + } + + private func hopRow(_ hop: ExecutionHop, isFirst: Bool) -> NSView { + let container = ChainRailView( + isFirst: isFirst, + emphasis: hop.isSessionRoot ? .session : (hop.isImportant ? .actor : .quiet), + // The tint starts halfway down the session root's own row, which is + // where the session actually begins. + railAbove: hop.isInsideSession ? PanelStyle.sessionRail : PanelStyle.chainRail, + railBelow: hop.isInsideSession || hop.isSessionRoot + ? PanelStyle.sessionRail + : PanelStyle.chainRail + ) + container.translatesAutoresizingMaskIntoConstraints = false + rails.append(container) + + let row = hop.isSessionRoot ? sessionRow(hop) : processRow(hop) + row.translatesAutoresizingMaskIntoConstraints = false + + // The session root is a tinted, labelled row rather than one more line + // with a different coloured dot. Which session a request came from is + // the fact most likely to change the answer, so it has to be impossible + // to skim past. + // Every row gets the same padding, whether or not anything is painted + // behind it. Only the session root has a background, and giving that + // background its own inset is what used to push its icon and text out of + // line with the rows around it. + let host = NSView() + host.translatesAutoresizingMaskIntoConstraints = false + if hop.isSessionRoot { + host.wantsLayer = true + host.layer?.backgroundColor = PanelStyle.sessionRowBackground.cgColor + host.layer?.cornerRadius = 7 + } + host.addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: ChainGrid.rowPadding), + row.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -ChainGrid.rowPadding), + row.topAnchor.constraint(equalTo: host.topAnchor, constant: hop.isSessionRoot ? 6 : 0), + row.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: hop.isSessionRoot ? -6 : 0), + ]) + + container.addSubview(host) + NSLayoutConstraint.activate([ + host.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: ChainGrid.rowInset), + host.trailingAnchor.constraint(equalTo: container.trailingAnchor), + host.topAnchor.constraint(equalTo: container.topAnchor, constant: hop.isSessionRoot ? 2 : 3), + host.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: hop.isSessionRoot ? -2 : -3), + container.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth - 24), + ]) + return container + } + + /// The one grid every row in the chain is laid out on. + /// + /// Rows used to set their own leading constants, and the session root's + /// tinted background gave it padding nothing else had, so its icon and text + /// sat further right than the rows above and below it. Every row now carries + /// the same padding whether or not anything is drawn behind it, and every + /// line of text starts at `textInset`, icons or no icons. + private enum ChainGrid { + /// The rail's width: where a row's own leading edge begins. + static let rowInset: CGFloat = 16 + /// Breathing room inside a row. Applied to all of them, so the session + /// root's background can be painted without moving its contents. + static let rowPadding: CGFloat = 8 + /// Between an icon and the text beside it. + static let iconSpacing: CGFloat = 8 + /// Where text starts, measured from the rail's leading edge. + static var textInset: CGFloat { rowInset + rowPadding + PanelIcons.side + iconSpacing } + /// The same column, measured from inside a row that already has an icon. + static var textIndent: CGFloat { PanelIcons.side + iconSpacing } + } + + /// A sub-line under a hop: same rail, same tint, text on the same column. + /// + /// Every one of these is built here so a new kind of line cannot quietly + /// arrive at a different indent, which is how the chain drifted off its grid + /// the last time. + private func subRow(insideSession: Bool, content: NSView, bottomPadding: CGFloat = 3) -> NSView { + let rail = ChainRailView( + isFirst: false, + emphasis: .none, + railAbove: insideSession ? PanelStyle.sessionRail : PanelStyle.chainRail, + railBelow: insideSession ? PanelStyle.sessionRail : PanelStyle.chainRail + ) + rails.append(rail) + content.translatesAutoresizingMaskIntoConstraints = false + rail.addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: rail.leadingAnchor, constant: ChainGrid.textInset), + content.trailingAnchor.constraint(lessThanOrEqualTo: rail.trailingAnchor), + content.topAnchor.constraint(equalTo: rail.topAnchor), + content.bottomAnchor.constraint(equalTo: rail.bottomAnchor, constant: -bottomPadding), + rail.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth - 24), + ]) + return rail + } + + /// The command line under the hop that ran it, drawn as a command. + /// + /// A tinted strip, a dimmed sigil, a monospaced face: this is the one line on + /// the panel a person can check word for word against what they typed, and it + /// used to be the same small grey text as every note around it. + private func commandRow(_ line: InvocationLine, insideSession: Bool) -> NSView { + let row = PanelStyle.row(spacing: 6) + if let prefix = line.prefix { + row.addArrangedSubview(PanelStyle.label(prefix, size: 10.5, color: PanelStyle.inkTertiary)) + } + row.addArrangedSubview(commandStrip(sigil: line.sigil, command: line.command)) + if let suffix = line.suffix { + let label = PanelStyle.label(suffix, size: 10.5, color: PanelStyle.inkTertiary) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + row.addArrangedSubview(label) + } + row.addArrangedSubview(PanelStyle.spacer()) + return subRow(insideSession: insideSession, content: row, bottomPadding: 4) + } + + /// The strip itself: `$` in the quiet ink, the command in the bright one. + private func commandStrip(sigil: String?, command: String) -> NSView { + let box = NSView() + box.wantsLayer = true + box.layer?.backgroundColor = PanelStyle.commandStrip.cgColor + box.layer?.borderColor = PanelStyle.commandStripBorder.cgColor + box.layer?.borderWidth = 1 + box.layer?.cornerRadius = 4 + + let font = NSFont.monospacedSystemFont(ofSize: 10.5, weight: .regular) + let text = NSMutableAttributedString() + if let sigil { + text.append(NSAttributedString( + string: "\(sigil) ", + attributes: [.font: font, .foregroundColor: PanelStyle.commandSigil] + )) + } + text.append(NSAttributedString( + string: command, + attributes: [.font: font, .foregroundColor: PanelStyle.commandInk] + )) + + let field = NSTextField(labelWithAttributedString: text) + // Elided in the middle: a command's subcommand and its `--` target are + // its two identifying ends, and a tail cut takes one of them away. + field.lineBreakMode = .byTruncatingMiddle + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + field.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 6), + field.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -6), + field.topAnchor.constraint(equalTo: box.topAnchor, constant: 2), + field.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -2), + ]) + return box + } + + /// Which varlock is running, in words, under the varlock row. + private func runtimeFormRow(_ text: String, isCaution: Bool, insideSession: Bool) -> NSView { + let label = PanelStyle.label( + isCaution ? "\u{25B2} " + text : text, + size: 10.5, + color: isCaution ? PanelStyle.warn : PanelStyle.inkTertiary + ) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 2 + label.preferredMaxLayoutWidth = PanelStyle.contentWidth - 70 + return subRow(insideSession: insideSession, content: label) + } + + /// The amber line under a hop, saying the one thing about it worth knowing. + private func advisoryRow(_ text: String, insideSession: Bool) -> NSView { + let label = PanelStyle.label("\u{25B2} " + text, size: 10.5, color: PanelStyle.warn) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 3 + label.preferredMaxLayoutWidth = PanelStyle.contentWidth - 70 + return subRow(insideSession: insideSession, content: label) + } + + /// One evidence line: a quiet label, the value, and where relevant the mark + /// for what was checked about it. + private func evidenceRow(_ evidence: HopEvidence, insideSession: Bool) -> NSView { + let row = PanelStyle.row(spacing: 6) + + let label = PanelStyle.label(evidence.label, size: 9.5, color: PanelStyle.inkQuiet) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + label.widthAnchor.constraint(equalToConstant: 62).isActive = true + row.addArrangedSubview(label) + + let value = PanelStyle.label( + evidence.isPath ? abbreviate(evidence.value) : evidence.value, + size: 9.5, + color: PanelStyle.inkTertiary, + mono: evidence.isPath + ) + // The tail of a path names the package and the entry file, which is the + // half that identifies it, so long ones lose their middle. + value.lineBreakMode = evidence.isPath ? .byTruncatingMiddle : .byTruncatingTail + value.toolTip = evidence.isPath ? evidence.value : nil + row.addArrangedSubview(value) + + if let posture = evidence.posture { + row.addArrangedSubview(postureMark( + posture, + subject: evidence.postureSubject ?? evidence.value, + alwaysShowsWord: true + )) + } + row.addArrangedSubview(PanelStyle.spacer()) + return subRow(insideSession: insideSession, content: row) + } + + /// The evidence under one hop, plus the fallback version for varlock's row. + /// + /// The daemon reads a version off the package when varlock is running as + /// JavaScript, because it can resolve that package itself. The compiled + /// binary carries no package, so the only answer available is the client's + /// own, and it is labelled as the client's own. + private func evidenceLines(for hop: ExecutionHop, reportedVarlockVersion: String?) -> [HopEvidence] { + var lines = hop.evidence + if hop.isVarlock, hop.release == nil, let reportedVarlockVersion { + lines.append(HopEvidence( + label: "version", + value: HopRelease(version: reportedVarlockVersion, source: .clientReported).displayValue + )) + } + return lines + } + + /// An ordinary process: what it is, what is running it, and what was checked. + private func processRow(_ hop: ExecutionHop) -> NSStackView { + let row = PanelStyle.row(spacing: 8) + + row.addArrangedSubview(icon(for: hop)) + + // No tty here, ever. A controlling terminal is inherited, so every hop + // below the session root shares the one the session-root row already + // names, and the app at the top holds none of its own. + row.addArrangedSubview(PanelStyle.label( + hop.name, + size: hop.isImportant ? 14.5 : 11.5, + color: hop.isImportant ? PanelStyle.ink : PanelStyle.inkTertiary, + weight: hop.isImportant ? .semibold : .regular, + mono: !hop.isLauncher + )) + if let via = hop.via { + row.addArrangedSubview(PanelStyle.label(via, size: 11.5, color: PanelStyle.inkTertiary)) + } + row.addArrangedSubview(postureMark( + hop.posture, + subject: hop.postureSubject, + interpreter: hop.interpreterName + )) + row.addArrangedSubview(PanelStyle.spacer()) + return row + } + + /// The session root: which session a "this session" grant attaches to. + /// + /// Every chain has one, because every grant has one. Usually that is the + /// shell on the controlling terminal; inside a coding agent it is the agent + /// itself, and the row then says which product, what the session is called, + /// and when it started. Two lines, because the second line is what tells one + /// session from another and squeezing it onto the first would truncate it. + /// The row is allowed to be taller than the others: it answers the question + /// the scope buttons are asking. + private func sessionRow(_ hop: ExecutionHop) -> NSStackView { + let column = PanelStyle.column(spacing: 2) + guard let root = hop.sessionRoot else { return column } + let session = root.agent + + let heading = PanelStyle.row(spacing: 8) + heading.addArrangedSubview(icon(for: hop)) + // The agent's product where there is one, and the process itself + // otherwise: "zsh" is what this session is, and naming it anything + // grander would be inventing a session that does not exist. + let name = PanelStyle.label( + session?.productName ?? hop.name, + size: 12.5, + color: PanelStyle.sessionInk, + weight: .semibold + ) + name.setContentCompressionResistancePriority(.required, for: .horizontal) + heading.addArrangedSubview(name) + heading.addArrangedSubview(sessionRootTag()) + // This row makes a claim too. It names an agent, and "is this really + // Claude Code" is a fair question to ask of the row a grant is about to + // attach to; leaving it as the one unmarked row in the chain would read + // as the one row nobody had to check. + heading.addArrangedSubview(postureMark( + hop.posture, + subject: session.map { "\u{201C}\($0.productName)\u{201D}" } ?? hop.postureSubject, + interpreter: hop.interpreterName + )) + heading.addArrangedSubview(PanelStyle.spacer()) + if let started = startedLabel(session?.startTime) { + let time = PanelStyle.label("started \(started)", size: 9.5, color: PanelStyle.inkQuiet) + // The one thing that earns a place at the end of this row: it is what + // tells two of the same agent's sessions apart. Paths used to be here + // too, in whatever few points were left over, and were unreadable. + time.setContentCompressionResistancePriority(.required, for: .horizontal) + heading.addArrangedSubview(time) + } + column.addArrangedSubview(heading) + heading.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + + // What this session is: the name it goes by everywhere else ("Terminal + // ttys004"), which is what the menu bar lists it under and what ending it + // will be called, led by the session's own title where it has one. + // The tty is stated here and nowhere else in the chain. + let secondary = PanelStyle.label( + root.descriptionLine, + size: 11.5, + color: PanelStyle.sessionTitleInk + ) + // The agent's words in italics, ours upright, so a title cannot be + // mistaken for something varlock is asserting. The quotation marks around + // it are the mark's own business: it drops them for a name the agent + // generated rather than one a person typed. + if let quoted = root.quotedTitle { + let upright = NSFont.systemFont(ofSize: 11.5) + let text = NSMutableAttributedString( + string: root.descriptionLine, + attributes: [.foregroundColor: PanelStyle.sessionTitleInk, .font: upright] + ) + let titleRange = (root.descriptionLine as NSString).range(of: quoted) + if titleRange.location != NSNotFound { + text.addAttribute( + .font, + value: NSFontManager.shared.convert(upright, toHaveTrait: .italicFontMask), + range: titleRange + ) + } + secondary.attributedStringValue = text + } + if root.agent?.isTitleDerived == true { + secondary.toolTip = "This name was generated by " + + "\(session?.productName ?? "the agent") from the directory it was opened in. " + + "Nobody typed it." + } + // Wraps rather than truncates: this is the line that tells one session + // apart from another. + secondary.lineBreakMode = .byWordWrapping + secondary.maximumNumberOfLines = 3 + secondary.preferredMaxLayoutWidth = PanelStyle.contentWidth - 72 + // Indented past the icon so it starts where the name above it starts, + // rather than under the icon in a column of its own. + let secondaryRow = NSView() + secondary.translatesAutoresizingMaskIntoConstraints = false + secondaryRow.addSubview(secondary) + NSLayoutConstraint.activate([ + secondary.leadingAnchor.constraint( + equalTo: secondaryRow.leadingAnchor, + constant: ChainGrid.textIndent + ), + secondary.trailingAnchor.constraint(lessThanOrEqualTo: secondaryRow.trailingAnchor), + secondary.topAnchor.constraint(equalTo: secondaryRow.topAnchor), + secondary.bottomAnchor.constraint(equalTo: secondaryRow.bottomAnchor), + ]) + column.addArrangedSubview(secondaryRow) + secondaryRow.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + return column + } + + /// The chip that says out loud what the tint means. + private func sessionRootTag() -> NSView { + let box = NSView() + box.wantsLayer = true + box.layer?.backgroundColor = PanelStyle.sessionTagBackground.cgColor + box.layer?.cornerRadius = 4 + let field = PanelStyle.label("SESSION ROOT", size: 8.5, color: PanelStyle.sessionInk, weight: .bold) + field.translatesAutoresizingMaskIntoConstraints = false + field.setContentCompressionResistancePriority(.required, for: .horizontal) + box.addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), + field.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), + field.topAnchor.constraint(equalTo: box.topAnchor, constant: 1.5), + field.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1.5), + ]) + return box + } + + /// The mark for what was checked about a hop, and its explanation. + /// + /// A bare coloured dot used to carry this, which meant it carried nothing: a + /// green circle is a legend a reader was never given. The shape says the + /// answer, the tooltip spells out what was and was not checked, and the word + /// beside it once the chain is opened matches both. + private func postureMark( + _ posture: HopPosture, + subject: String, + interpreter: String? = nil, + alwaysShowsWord: Bool = false + ) -> NSView { + let mark = PostureMarkView( + posture: posture, + explanation: posture.explanation(subject: subject, interpreter: interpreter), + alwaysShowsWord: alwaysShowsWord + ) + postureMarks.append(mark) + mark.setWordVisible(alwaysShowsWord || expanded) + return mark + } + + private func expanderView(foldedLabel: String?, insideSession: Bool) -> NSView { + let label = foldedLabel ?? "" + let field = PanelStyle.label(collapsedLabel(label), size: 10.5, color: PanelStyle.inkQuiet) + expander = (field, label) + let rail = ChainRailView( + isFirst: false, + emphasis: .none, + railAbove: insideSession ? PanelStyle.sessionRail : PanelStyle.chainRail, + railBelow: insideSession ? PanelStyle.sessionRail : PanelStyle.chainRail + ) + rails.append(rail) + rail.onClick = { [weak self] in self?.toggleExpanded(field: field, label: label) } + field.translatesAutoresizingMaskIntoConstraints = false + rail.addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: rail.leadingAnchor, constant: 16), + field.trailingAnchor.constraint(lessThanOrEqualTo: rail.trailingAnchor), + field.topAnchor.constraint(equalTo: rail.topAnchor, constant: 2), + field.bottomAnchor.constraint(equalTo: rail.bottomAnchor), + rail.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth - 24), + ]) + return rail + } + + private func collapsedLabel(_ label: String) -> String { + guard !label.isEmpty else { return "\u{2304} paths, versions, and signatures \u{25B8}" } + return "\u{2304} \(label) \u{00B7} paths and signatures \u{25B8}" + } + + private func toggleExpanded(field: NSTextField, label: String) { + expanded.toggle() + let closed = label.isEmpty ? "\u{2303} less detail" : "\u{2303} fewer steps" + field.stringValue = expanded ? closed : collapsedLabel(label) + applyExpansion() + } + + private func applyExpansion() { + for row in foldedRows { row.isHidden = !expanded } + for row in evidenceRows { row.isHidden = !expanded } + for mark in postureMarks { mark.setWordVisible(expanded) } + updateRails() + onLayoutChanged() + } + + /// Stop the rail at the last row that is actually on screen. + /// + /// The evidence lines under the last hop are hidden at rest, so without this + /// the bottom row would draw its half of the rail down into nothing. + private func updateRails() { + let visible = rails.filter { !isHiddenInColumn($0) } + for rail in rails { rail.drawsRailBelow = rail !== visible.last } + } + + /// Whether a rail's own row has been folded away. The rail is the row here, + /// so this is just its own hidden flag, checked through the view it sits in + /// so a future wrapper cannot silently break it. + private func isHiddenInColumn(_ rail: ChainRailView) -> Bool { + var view: NSView? = rail + while let current = view, current !== self { + if current.isHidden { return true } + view = current.superview + } + return false + } + + /// "2:14 PM": the thing a person can check against their own screen. + private func startedLabel(_ startTime: Int?) -> String? { + guard let startTime, startTime > 0 else { return nil } + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(startTime))) + } + + /// This hop's picture: the app's own icon where there is one, a tool tile + /// where there is not, and a terminal when we know nothing. Filled in from the + /// run loop, so a cold LaunchServices lookup cannot delay the panel. + private func icon(for hop: ExecutionHop) -> NSView { + return PanelIconView(side: PanelIcons.side, placeholder: PanelIcons.genericTerminal()) { + PanelIcons.icon(for: hop) ?? PanelIcons.genericTerminal() + } + } + + private func abbreviate(_ path: String) -> String { + let home = NSHomeDirectory() + if !home.isEmpty, path.hasPrefix(home) { + return "~" + path.dropFirst(home.count) + } + return path + } +} + +/// The mark saying what was checked about one thing, with the words for it. +/// +/// A glyph rather than a dot, because a dot has to be explained and a shield does +/// not: a shield with a tick is something checked out, a warning triangle is +/// something that was not, and a question mark is a process the kernel would not +/// talk about. The tooltip is where the exact claim lives, in full sentences, +/// including what was NOT checked; the short word beside it appears when the +/// chain is opened and always agrees with the shape. +final class PostureMarkView: NSStackView { + private let word: NSTextField + private let alwaysShowsWord: Bool + + init(posture: HopPosture, explanation: String, alwaysShowsWord: Bool) { + let tint: NSColor + if posture.isVerified { + tint = PanelStyle.ok + } else if posture.isCaution { + tint = PanelStyle.warn + } else { + tint = PanelStyle.inkQuiet + } + + word = PanelStyle.label(posture.inlineLabel, size: 9.5, color: tint) + word.setContentCompressionResistancePriority(.required, for: .horizontal) + self.alwaysShowsWord = alwaysShowsWord + super.init(frame: .zero) + + orientation = .horizontal + alignment = .centerY + spacing = 3 + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + + let glyph = NSImageView() + glyph.image = NSImage( + systemSymbolName: posture.symbolName, + accessibilityDescription: posture.inlineLabel + )?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .medium)) + glyph.contentTintColor = tint + glyph.imageScaling = .scaleProportionallyDown + glyph.setContentHuggingPriority(.required, for: .horizontal) + glyph.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + glyph.widthAnchor.constraint(equalToConstant: 13), + glyph.heightAnchor.constraint(equalToConstant: 13), + ]) + + addArrangedSubview(glyph) + addArrangedSubview(word) + // On the whole mark, so hovering the shape or the word both answer. + toolTip = explanation + glyph.toolTip = explanation + word.toolTip = explanation + } + + required init?(coder: NSCoder) { fatalError("not used") } + + func setWordVisible(_ visible: Bool) { + word.isHidden = !(visible || alwaysShowsWord) + } +} + +/// Draws the vertical rail and this hop's dot behind a chain row. +/// +/// The rail is drawn in two halves so a session can begin in the middle of a row: +/// grey above the hop the session is rooted at, tinted below it and all the way +/// down. That is what makes "inside the session" a visible span. +final class ChainRailView: NSView, PanelClickTarget { + enum Emphasis { + /// The hop that decides what runs. + case actor + /// The root of a coding-agent session. + case session + /// Everything else on the rail. + case quiet + /// Not a hop at all (a sub-line, the expander): rail, no dot. + case none + } + + private let isFirst: Bool + private let emphasis: Emphasis + private let railAbove: NSColor + private let railBelow: NSColor + + /// Whether the rail continues below this row. Set by the chain rather than + /// fixed at build time, because which row is last changes when the chain is + /// opened: the evidence lines under the bottom hop are hidden at rest, and a + /// rail drawn down to a hidden row is a stub hanging off the last thing on + /// screen. + var drawsRailBelow: Bool = true { + didSet { if drawsRailBelow != oldValue { needsDisplay = true } } + } + + /// Set when the row is something to click. + var onClick: (() -> Void)? + + init(isFirst: Bool, emphasis: Emphasis, railAbove: NSColor, railBelow: NSColor) { + self.isFirst = isFirst + self.emphasis = emphasis + self.railAbove = railAbove + self.railBelow = railBelow + super.init(frame: .zero) + wantsLayer = true + } + + required init?(coder: NSCoder) { fatalError("not used") } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let midY = bounds.midY + let railX: CGFloat = 5 + + // AppKit's origin is bottom left, so "above" is the higher y. + if !isFirst { + railAbove.setFill() + NSRect(x: railX - 1, y: midY, width: 2, height: bounds.maxY - midY).fill() + } + if drawsRailBelow { + railBelow.setFill() + NSRect(x: railX - 1, y: bounds.minY, width: 2, height: midY - bounds.minY).fill() + } + + let side: CGFloat + let color: NSColor + switch emphasis { + // Bright and neutral, never accent: a coloured marker next to a green + // posture mark reads as a verdict on the process, and this one is about + // structure. Colour on this rail means one thing at a time. + case .actor: (side, color) = (9, PanelStyle.ink) + case .session: (side, color) = (9, PanelStyle.sessionDot) + case .quiet: (side, color) = (7, PanelStyle.chainDot) + case .none: return + } + let dot = NSBezierPath(ovalIn: NSRect( + x: railX - side / 2, + y: midY - side / 2, + width: side, + height: side + )) + color.setFill() + dot.fill() + } + + override func mouseUp(with event: NSEvent) { + guard let onClick, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } + onClick() + } + + /// Only the rows that do something take the click; the rest let it fall + /// through, so a chain row is not a dead button. + override func hitTest(_ point: NSPoint) -> NSView? { + guard onClick != nil else { return nil } + return clickTargetHitTest(point) + } + + override func resetCursorRects() { + guard onClick != nil else { return } + addCursorRect(bounds, cursor: .pointingHand) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelControls.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelControls.swift new file mode 100644 index 000000000..56647356e --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelControls.swift @@ -0,0 +1,802 @@ +import AppKit +import LocalAuthentication +import LocalAuthenticationEmbeddedUI +import IdentitySessions + +/// The panel's own buttons and segmented control. +/// +/// AppKit's stock controls follow the system appearance and the system's idea of +/// hierarchy, and this panel commits to neither: it is drawn dark, its primary +/// action is a wide bar carrying a live Touch ID glyph, and its refusal is a +/// quiet ghost button. Drawing them here is what lets the approve action and the +/// scan be one object rather than two things sitting near each other. + +/// A view whose whole area is the click target. +/// +/// AppKit hands a click to the deepest view under the pointer, and an +/// `NSTextField` swallows it rather than passing it on. Every row on this panel +/// is mostly text, so without this the clickable parts were the gaps between the +/// words: the vault row looked expandable and did nothing when you clicked its +/// name. +protocol PanelClickTarget: NSView {} + +extension PanelClickTarget { + func clickTargetHitTest(_ point: NSPoint) -> NSView? { + guard let superview else { return nil } + return bounds.contains(convert(point, from: superview)) ? self : nil + } +} + +/// A flat, layer-drawn button. +final class PanelButton: NSControl, PanelClickTarget { + enum Style { + case primary + /// The refusal: red on a tinted ground, with a stop mark. + case deny + /// An underlined text link, for the secondary way through. + case link + } + + private let style: Style + private let titleField: NSTextField + private let contentRow = NSStackView() + private var pressed = false + /// Set for the primary button on a machine that can scan. + private(set) var glyphView: TouchIDGlyphView? + + var title: String { + get { titleField.stringValue } + set { + titleField.stringValue = newValue + needsLayout = true + } + } + + init(title: String, style: Style, glyph: PanelButtonGlyph = .none, target: AnyObject?, action: Selector) { + self.style = style + let size: CGFloat = style == .link ? 11.5 : 13 + let weight: NSFont.Weight = style == .primary ? .semibold : .regular + let color: NSColor + switch style { + case .primary: color = .white + case .deny: color = PanelStyle.denyInk + case .link: color = PanelStyle.inkTertiary + } + titleField = PanelStyle.label(title, size: size, color: color, weight: weight) + super.init(frame: .zero) + self.target = target + self.action = action + + wantsLayer = true + layer?.cornerRadius = style == .link ? 0 : 8 + applyBackground() + + contentRow.orientation = .horizontal + contentRow.alignment = .centerY + contentRow.spacing = 9 + contentRow.translatesAutoresizingMaskIntoConstraints = false + contentRow.setContentHuggingPriority(.required, for: .horizontal) + + switch glyph { + case .none: + break + case .touchID: + let view = TouchIDGlyphView() + view.setBaseTint(.white) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: 20), + view.heightAnchor.constraint(equalToConstant: 20), + ]) + glyphView = view + contentRow.addArrangedSubview(view) + case .lock: + let image = NSImageView() + image.image = NSImage(systemSymbolName: "lock.fill", accessibilityDescription: nil) + image.contentTintColor = .white + image.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + image.widthAnchor.constraint(equalToConstant: 15), + image.heightAnchor.constraint(equalToConstant: 17), + ]) + contentRow.addArrangedSubview(image) + case .stop: + let image = NSImageView() + image.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: nil) + image.contentTintColor = PanelStyle.denyInk + image.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + image.widthAnchor.constraint(equalToConstant: 13), + image.heightAnchor.constraint(equalToConstant: 13), + ]) + contentRow.addArrangedSubview(image) + } + + if style == .link { + titleField.attributedStringValue = NSAttributedString( + string: title, + attributes: [ + .font: NSFont.systemFont(ofSize: size), + .foregroundColor: PanelStyle.inkTertiary, + .underlineStyle: NSUnderlineStyle.single.rawValue, + ] + ) + } + contentRow.addArrangedSubview(titleField) + addSubview(contentRow) + + let vertical: CGFloat = style == .link ? 0 : 8 + let horizontal: CGFloat = style == .link ? 0 : 16 + NSLayoutConstraint.activate([ + contentRow.centerXAnchor.constraint(equalTo: centerXAnchor), + contentRow.centerYAnchor.constraint(equalTo: centerYAnchor), + contentRow.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: horizontal), + contentRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontal), + contentRow.topAnchor.constraint(equalTo: topAnchor, constant: vertical), + contentRow.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -vertical), + ]) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + enum PanelButtonGlyph { + case none + /// The breathing fingerprint, which is also the scan affordance. + case touchID + /// A static padlock, for a machine with no sensor to breathe about. + case lock + /// A circled cross, on the refusal. + case stop + } + + private func applyBackground() { + switch style { + case .primary: + layer?.backgroundColor = (pressed ? PanelStyle.primaryButtonPressed : PanelStyle.primaryButton).cgColor + layer?.borderWidth = 0 + case .deny: + layer?.backgroundColor = (pressed ? PanelStyle.denyButtonPressed : PanelStyle.denyButton).cgColor + layer?.borderColor = PanelStyle.denyButtonBorder.cgColor + layer?.borderWidth = 1 + case .link: + layer?.backgroundColor = NSColor.clear.cgColor + layer?.borderWidth = 0 + } + alphaValue = isEnabled ? 1 : 0.5 + } + + override var isEnabled: Bool { + didSet { applyBackground() } + } + + override func mouseDown(with event: NSEvent) { + guard isEnabled else { return } + pressed = true + applyBackground() + } + + override func mouseUp(with event: NSEvent) { + guard isEnabled else { return } + pressed = false + applyBackground() + if bounds.contains(convert(event.locationInWindow, from: nil)) { + sendAction(action, to: target) + } + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return isEnabled ? clickTargetHitTest(point) : nil + } + + override var intrinsicContentSize: NSSize { + let fitting = contentRow.fittingSize + switch style { + case .link: + return fitting + default: + return NSSize(width: fitting.width + 32, height: max(34, fitting.height + 16)) + } + } +} + +/// The approve action, which is the sensor and its label. +/// +/// It sits where the primary action goes, beside Deny, and it is also the scan +/// surface: the system's own `LAAuthenticationView` is the fingerprint, and +/// touching the sensor approves without anyone clicking anything. Clicking the +/// label asks again, for a scan that did not take. +/// +/// Nothing is drawn behind it, and that is not a taste decision. +/// `render-bisect.ts` measured the pixels for each arrangement: +/// +/// sensor alone in a plain holder 51 greys, drawn +/// sensor and label in a plain holder 51 greys, drawn +/// the same pair inside an outlined box 51 greys, drawn +/// the same box with a layer-backed fill 4 greys, blank +/// the same box with the fill drawn in code 4 greys, blank +/// +/// So the rule is: no paint of ours may cover the sensor's own rectangle. A fill +/// behind it blanks it, whether the fill is a layer or a `draw(_:)` call. The +/// mock's solid blue bar is therefore not available at all, and the outline that +/// stood in for it was a box around a fingerprint that read as neither. What is +/// left is the honest version: the live sensor at its own size with the words +/// beside it, and the panel's ground showing through. +/// +/// Deliberately an `NSView` and not an `NSControl`: inside an `NSButton` the auth +/// view drew nothing either, for the same reason. +final class PanelScanButton: NSView, PanelClickTarget { + /// The system's view. Kept so the panel can photograph it and prove it drew. + private(set) var scanView: NSView! + + private let onClick: () -> Void + private let label: NSTextField + + /// The sensor's own drawn size. `LAAuthenticationView` renders at a size per + /// control size (mini 16, small 32, regular 64, large 128), so the small one + /// is asked for rather than a large one squeezed: a scaled-down fingerprint + /// is a blurry fingerprint. + static let sensorSide: CGFloat = 32 + + /// `context` is nil only in a preview, which has no sensor to bind to and + /// gets our drawn glyph in the same slot: a picture of where the live one goes. + init(title: String, context: LAContext?, onClick: @escaping () -> Void) { + self.onClick = onClick + label = PanelStyle.label(title, size: 13, color: PanelStyle.ink, weight: .semibold) + super.init(frame: .zero) + + let authView: NSView + if let context { + authView = LAAuthenticationView(context: context, controlSize: .small) + } else { + let placeholder = TouchIDGlyphView() + placeholder.apply(.still) + authView = placeholder + } + authView.translatesAutoresizingMaskIntoConstraints = false + scanView = authView + + label.translatesAutoresizingMaskIntoConstraints = false + label.setContentCompressionResistancePriority(.required, for: .horizontal) + + // Both placed directly, with no stack view between the auth view and this + // one: every arrangement that has ever rendered had it as a plain subview + // with constraints of its own. + addSubview(authView) + addSubview(label) + + // A layout guide rather than a container view, so the pair can be centred + // as a unit without anything existing behind the sensor. + let pair = NSLayoutGuide() + addLayoutGuide(pair) + + NSLayoutConstraint.activate([ + authView.widthAnchor.constraint(equalToConstant: Self.sensorSide), + authView.heightAnchor.constraint(equalToConstant: Self.sensorSide), + authView.centerYAnchor.constraint(equalTo: centerYAnchor), + authView.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -9), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + pair.leadingAnchor.constraint(equalTo: authView.leadingAnchor), + pair.trailingAnchor.constraint(equalTo: label.trailingAnchor), + pair.centerXAnchor.constraint(equalTo: centerXAnchor).withPriority(.defaultHigh), + authView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + // Tall enough that the sensor is never clipped, and no taller: this + // row sits next to a 34pt Deny button. + heightAnchor.constraint(greaterThanOrEqualToConstant: Self.sensorSide + 6), + ]) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + /// Pressed state lives in the label's ink. + /// + /// Everything else a button does to say "pressed" is paint, and paint is the + /// one thing that must not happen around this view. + private func setPressed(_ pressed: Bool) { + label.textColor = pressed ? PanelStyle.inkSecondary : PanelStyle.ink + } + + override func mouseDown(with event: NSEvent) { + setPressed(true) + } + + override func mouseUp(with event: NSEvent) { + setPressed(false) + guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } + onClick() + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } +} + +private extension NSLayoutConstraint { + /// Reads better than three lines of mutation at the call site. + func withPriority(_ priority: NSLayoutConstraint.Priority) -> NSLayoutConstraint { + self.priority = priority + return self + } +} + +/// The "how long" choice, drawn as a pill of segments. +/// +/// A stock `NSSegmentedControl` would follow the system appearance, and this +/// panel commits to its own whatever the user's theme is set to. Segments size +/// to their own labels rather than to the widest one, which is what lets a +/// ladder from "Once" to "This session" sit on one row: padding "1hr" out to the +/// width of "This session" would spend most of the panel on space. +final class PanelSegmentedControl: NSView { + private var buttons: [PanelSegmentButton] = [] + private(set) var selectedIndex = 0 + private let onChange: (Int) -> Void + /// A click on the rung that is already selected. Not a change, so it must + /// never be reported as one; the custom rung uses it to put the caret back + /// in its field. + private let onReselect: ((Int) -> Void)? + + init( + labels: [String], + selectedIndex: Int, + onChange: @escaping (Int) -> Void, + onReselect: ((Int) -> Void)? = nil + ) { + self.onChange = onChange + self.onReselect = onReselect + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = PanelStyle.segmentTrack.cgColor + layer?.borderColor = PanelStyle.segmentTrackBorder.cgColor + layer?.borderWidth = 1 + layer?.cornerRadius = 8 + + let row = PanelStyle.row(spacing: 0) + row.translatesAutoresizingMaskIntoConstraints = false + for (index, title) in labels.enumerated() { + let button = PanelSegmentButton(title: title) { [weak self] view in + self?.handleClick(index: index, view: view) + } + buttons.append(button) + row.addArrangedSubview(button) + } + addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + row.topAnchor.constraint(equalTo: topAnchor, constant: 2), + row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2), + ]) + select(index: selectedIndex, notify: false) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + private func handleClick(index: Int, view: NSView) { + // Clicking what is already selected does not change the answer: a + // control that fires a change on a click that changes nothing is a + // control that can surprise somebody, on a panel where a surprise is an + // approval. It is still a click, and a rung that has something to open + // gets told about it. + guard index != selectedIndex else { + onReselect?(index) + return + } + select(index: index, notify: true) + } + + func select(index: Int, notify: Bool) { + guard index >= 0, index < buttons.count else { return } + selectedIndex = index + for (position, button) in buttons.enumerated() { + button.setSelected(position == index) + } + if notify { onChange(index) } + } +} + +/// One rung of the "how long" control. +final class PanelSegmentButton: NSView, PanelClickTarget { + private let field = PanelStyle.label("", size: 12, color: PanelStyle.ink) + private let onClick: (NSView) -> Void + private var titleText: String + private var selected = false + + init(title: String, onClick: @escaping (NSView) -> Void) { + self.onClick = onClick + self.titleText = title + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 6 + field.font = NSFont.systemFont(ofSize: 12, weight: .regular) + field.stringValue = title + field.alignment = .center + field.translatesAutoresizingMaskIntoConstraints = false + field.setContentCompressionResistancePriority(.required, for: .horizontal) + addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 11), + field.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -11), + field.topAnchor.constraint(equalTo: topAnchor, constant: 4), + field.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + ]) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + func setSelected(_ isSelected: Bool) { + selected = isSelected + layer?.backgroundColor = (isSelected ? PanelStyle.vaultLocal : NSColor.clear).cgColor + applyTitle() + } + + /// Selection is colour, never weight. + /// + /// A bolder label is a wider label, so bolding the selected segment moved the + /// control (and everything under it) by a few points every time the user + /// changed their mind. Nothing about a toggle should reflow a panel. + private func applyTitle() { + field.stringValue = titleText + field.textColor = selected ? .white : PanelStyle.ink + } + + override func mouseUp(with event: NSEvent) { + guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } + onClick(self) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return clickTargetHitTest(point) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } +} + +/// A row that toggles something open, and says so by rotating a chevron. +final class PanelDisclosureRow: NSView, PanelClickTarget { + private let onToggle: (Bool) -> Void + private(set) var isOpen = false + private let chevron: NSTextField + + init(content: NSView, chevron: NSTextField, isOpen: Bool = false, onToggle: @escaping (Bool) -> Void) { + self.onToggle = onToggle + self.chevron = chevron + self.isOpen = isOpen + super.init(frame: .zero) + content.translatesAutoresizingMaskIntoConstraints = false + addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor), + content.trailingAnchor.constraint(equalTo: trailingAnchor), + content.topAnchor.constraint(equalTo: topAnchor), + content.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + applyChevron() + } + + required init?(coder: NSCoder) { fatalError("not used") } + + private func applyChevron() { + chevron.stringValue = isOpen ? "\u{25BE}" : "\u{25B8}" + } + + override func mouseUp(with event: NSEvent) { + guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } + isOpen.toggle() + applyChevron() + onToggle(isOpen) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return clickTargetHitTest(point) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } +} + +/// The breadth control: one checkbox, ticked for the broad answer. +/// +/// Drawn here rather than taken from `NSButton(checkboxWithTitle:)` for the same +/// reason everything else on this panel is: a stock checkbox follows the system +/// appearance, and this window commits to its own. +/// +/// The whole row is the target, label included. A checkbox whose 15 points of +/// box are the only place a click lands is a checkbox people miss, and this one +/// sits directly under the control that decides how long a grant lasts, where a +/// missed click reads as the panel ignoring you. +final class PanelCheckbox: NSView, PanelClickTarget { + private(set) var isChecked: Bool + private let onChange: (Bool) -> Void + private let box = NSView() + private let tick = NSImageView() + private let field: NSTextField + + init(title: String, isChecked: Bool, onChange: @escaping (Bool) -> Void) { + self.isChecked = isChecked + self.onChange = onChange + field = PanelStyle.label(title, size: 12, color: PanelStyle.ink) + super.init(frame: .zero) + + box.wantsLayer = true + box.layer?.cornerRadius = 4 + box.layer?.borderWidth = 1 + box.translatesAutoresizingMaskIntoConstraints = false + + tick.image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) + tick.contentTintColor = .white + tick.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(tick) + + field.translatesAutoresizingMaskIntoConstraints = false + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 2 + field.setContentCompressionResistancePriority(.required, for: .horizontal) + + addSubview(box) + addSubview(field) + + NSLayoutConstraint.activate([ + box.widthAnchor.constraint(equalToConstant: 15), + box.heightAnchor.constraint(equalToConstant: 15), + box.leadingAnchor.constraint(equalTo: leadingAnchor), + box.centerYAnchor.constraint(equalTo: centerYAnchor), + tick.centerXAnchor.constraint(equalTo: box.centerXAnchor), + tick.centerYAnchor.constraint(equalTo: box.centerYAnchor), + tick.widthAnchor.constraint(equalToConstant: 10), + tick.heightAnchor.constraint(equalToConstant: 10), + field.leadingAnchor.constraint(equalTo: box.trailingAnchor, constant: 8), + field.trailingAnchor.constraint(equalTo: trailingAnchor), + field.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(greaterThanOrEqualTo: field.heightAnchor), + ]) + applyState() + } + + required init?(coder: NSCoder) { fatalError("not used") } + + /// Set the state without telling anyone, for building the panel. + func setChecked(_ checked: Bool) { + isChecked = checked + applyState() + } + + private func applyState() { + box.layer?.backgroundColor = (isChecked ? PanelStyle.vaultLocal : NSColor.clear).cgColor + box.layer?.borderColor = (isChecked ? PanelStyle.vaultLocal : PanelStyle.segmentTrackBorder).cgColor + tick.isHidden = !isChecked + } + + override func mouseUp(with event: NSEvent) { + guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } + isChecked.toggle() + applyState() + onChange(isChecked) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return clickTargetHitTest(point) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } +} + +/// The custom rung's value: a number, and the unit it is in. +/// +/// Typed rather than stepped, which is a decision with a cost this control has +/// to pay for. The sensor is live the whole time this field has focus, and a +/// scan approves with no click, so a half-typed number must never become an +/// approval for something other than what the panel is showing. Three rules +/// carry that: +/// +/// 1. There is no committed value hiding behind the field. `liveValue` is a +/// clamped reading of the text as it stands at this instant, and it is what +/// the panel grants; the summary sentence is rewritten from it on every +/// keystroke. A finger landing mid-word approves what is on screen. Partial +/// numbers are prefixes, so a scan during typing can only ever land shorter +/// than what was being aimed at, never longer. +/// 2. Return commits and gets out of the way. It does not reach the panel's +/// confirm handler, because a key that means "done editing" must not also +/// mean "unlock". Escape is left alone to refuse the whole panel, which is +/// what it does everywhere else here. +/// 3. Nothing is ever invalid. See `CustomDuration`: empty, zero, nonsense and +/// over the cap all read as the nearest legal window, and the field is +/// rewritten to the clamped value when editing ends. There is no error +/// state, so there is no way to leave the panel holding an answer it cannot +/// act on. +/// +/// Notably NOT solved by disarming the sensor while the field has focus. Arming +/// is the part of this panel with the longest bug history, and a rule that says +/// "grant what is displayed" needs no state machine to be right. +final class PanelDurationField: NSView, NSTextFieldDelegate { + private let amountField = NSTextField() + private let amountBox = NSView() + private let capLabel: NSTextField + private var unitControl: PanelSegmentedControl! + /// Any change to the value, typed or through the unit toggle. + private let onChange: () -> Void + /// Escape, which is a refusal of the panel and not of the field. + private let onCancel: () -> Void + private(set) var unit: DurationUnit + + /// What the panel would grant if a finger landed right now. + var liveValue: CustomDuration { + return CustomDuration.parse(amountField.stringValue, unit: unit) + } + + init( + value: CustomDuration, + onChange: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self.onChange = onChange + self.onCancel = onCancel + self.unit = value.unit + capLabel = PanelStyle.label("", size: 11, color: PanelStyle.inkTertiary) + super.init(frame: .zero) + + amountBox.wantsLayer = true + amountBox.layer?.backgroundColor = PanelStyle.segmentTrack.cgColor + amountBox.layer?.cornerRadius = 8 + amountBox.layer?.borderWidth = 1 + amountBox.translatesAutoresizingMaskIntoConstraints = false + + // Drawn in the panel's chrome like everything else here: no bezel, no + // background of its own, no focus ring. A stock field would be the one + // control on this window that follows the system's appearance. + amountField.isBordered = false + amountField.drawsBackground = false + amountField.focusRingType = .none + amountField.isEditable = true + amountField.isSelectable = true + amountField.usesSingleLineMode = true + amountField.cell?.wraps = false + amountField.cell?.isScrollable = true + // Tabular figures, so the number does not jitter sideways as digits are + // typed and deleted under a live sensor. + amountField.font = NSFont.monospacedDigitSystemFont(ofSize: 12.5, weight: .regular) + amountField.textColor = PanelStyle.ink + amountField.alignment = .center + amountField.delegate = self + amountField.stringValue = String(value.amount) + amountField.translatesAutoresizingMaskIntoConstraints = false + amountBox.addSubview(amountField) + + addSubview(amountBox) + + let unitControl = PanelSegmentedControl( + labels: DurationUnit.allCases.map { $0.suffix }, + selectedIndex: DurationUnit.allCases.firstIndex(of: value.unit) ?? 0 + ) { [weak self] index in + self?.unitChanged(to: DurationUnit.allCases[index]) + } + unitControl.translatesAutoresizingMaskIntoConstraints = false + self.unitControl = unitControl + addSubview(unitControl) + + capLabel.alignment = .left + capLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(capLabel) + + NSLayoutConstraint.activate([ + amountBox.leadingAnchor.constraint(equalTo: leadingAnchor), + amountBox.centerYAnchor.constraint(equalTo: centerYAnchor), + // Wide enough for three digits, which is every number this field can + // hold, so the box never resizes around its own contents. + amountBox.widthAnchor.constraint(equalToConstant: 54), + amountBox.heightAnchor.constraint(equalToConstant: 26), + amountField.leadingAnchor.constraint(equalTo: amountBox.leadingAnchor, constant: 6), + amountField.trailingAnchor.constraint(equalTo: amountBox.trailingAnchor, constant: -6), + amountField.centerYAnchor.constraint(equalTo: amountBox.centerYAnchor), + unitControl.leadingAnchor.constraint(equalTo: amountBox.trailingAnchor, constant: 8), + unitControl.centerYAnchor.constraint(equalTo: centerYAnchor), + capLabel.leadingAnchor.constraint(equalTo: unitControl.trailingAnchor, constant: 12), + capLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + capLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + // The cap is said in whichever unit is showing, so its text changes + // with the toggle. Sized for the longer of the two once, or the row + // would re-centre itself every time somebody switched units. + capLabel.widthAnchor.constraint(equalToConstant: Self.capLabelWidth), + heightAnchor.constraint(greaterThanOrEqualTo: amountBox.heightAnchor), + ]) + applyCapLabel() + setFocusedLook(false) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + /// Room for `max 720min`, which is the wider of the two things it says. + private static let capLabelWidth: CGFloat = { + let font = NSFont.systemFont(ofSize: 11) + let widest = DurationUnit.allCases + .map { PanelContent.customDurationCapLabel(unit: $0) } + .map { ($0 as NSString).size(withAttributes: [.font: font]).width } + .max() ?? 0 + return ceil(widest) + 1 + }() + + /// Show the value the panel would actually act on. + /// + /// Called when editing ends rather than on every keystroke: rewriting `80` + /// to `72` under somebody's fingers as they reach for the `0` of `800` is + /// the kind of help nobody asked for. Until then the clamp is applied to the + /// VALUE and said out loud in the summary sentence, so the panel is honest + /// about what it would grant even while the text is not yet legal. + func commit() { + amountField.stringValue = String(liveValue.amount) + } + + /// Put the caret in the field, for a click on the rung that opens it. + func focusField() { + window?.makeFirstResponder(amountField) + } + + /// The focused look, as a border rather than a system focus ring. + /// + /// Exposed so a preview can render the focused state: the panel is checked + /// by looking at pictures of it, and a state only reachable by clicking is a + /// state nobody checks. + func setFocusedLook(_ focused: Bool) { + amountBox.layer?.borderColor = (focused ? PanelStyle.vaultLocal : PanelStyle.segmentTrackBorder).cgColor + } + + private func applyCapLabel() { + capLabel.stringValue = PanelContent.customDurationCapLabel(unit: unit) + } + + /// Switching units converts the window, never rereads the number. + /// + /// 90 minutes becomes one hour, not ninety hours. `CustomDuration.converted` + /// owns the rounding and the re-clamp; this only has to show the answer. + private func unitChanged(to newUnit: DurationUnit) { + let converted = liveValue.converted(to: newUnit) + unit = newUnit + amountField.stringValue = String(converted.amount) + applyCapLabel() + onChange() + } + + // MARK: - NSTextFieldDelegate + + func controlTextDidChange(_ obj: Notification) { + onChange() + } + + func controlTextDidBeginEditing(_ obj: Notification) { + setFocusedLook(true) + } + + func controlTextDidEndEditing(_ obj: Notification) { + setFocusedLook(false) + commit() + onChange() + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.insertNewline(_:)): + // Return means "I am done with this number". It must not fall + // through to the window, where Return approves. + commit() + onChange() + window?.makeFirstResponder(window) + return true + case #selector(NSResponder.cancelOperation(_:)): + // Escape refuses the panel, exactly as it does with the field + // unfocused. A field that swallowed it would leave somebody pressing + // Escape at an approval prompt and watching nothing happen. + onCancel() + return true + default: + return false + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelDebug.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelDebug.swift new file mode 100644 index 000000000..72738ed19 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelDebug.swift @@ -0,0 +1,77 @@ +import Foundation +import AppKit + +/// Timestamped lifecycle logging for the approval panel, and the scheduling +/// primitive it depends on. +/// +/// The panel runs in a place with an awkward shape: the IPC handler is on a +/// background queue, so it reaches the main thread with `DispatchQueue.main.sync`, +/// and the panel then spins a nested modal run loop inside that work item. Work +/// posted with `DispatchQueue.main.async` from there never runs, because the main +/// queue is serial and its current item has not returned. The nested loop keeps +/// drawing, so the panel looks fine while everything scheduled behind it starves. +/// +/// That is not theoretical: it is what stopped the Touch ID evaluation from ever +/// being armed in the daemon, while the same code worked in the probe, which owns +/// its run loop through `NSApplication.run()` and so has no outer work item. +enum PanelDebug { + /// Streams the panel's lifecycle to the daemon's stderr. + static let envVar = "_VARLOCK_PANEL_DEBUG" + + static var isEnabled: Bool { + let value = ProcessInfo.processInfo.environment[envVar] + return value == "1" || value == "true" + } + + private static let start = Date() + + static func note(_ event: String, _ detail: [String: Any] = [:]) { + guard isEnabled else { return } + let atMs = Int(Date().timeIntervalSince(start) * 1000) + let rendered = detail.isEmpty + ? "" + : " " + detail.keys.sorted().map { "\($0)=\(detail[$0] ?? "")" }.joined(separator: " ") + let thread = Thread.isMainThread ? "main" : "background" + FileHandle.standardError.write(Data("varlock-panel [\(atMs)ms] \(event) thread=\(thread)\(rendered)\n".utf8)) + } +} + +/// Scheduling that survives a nested modal loop. +/// +/// `RunLoop.perform` posts a run-loop source rather than a main-queue work item, +/// so it is not held behind whatever item is currently occupying the main queue. +/// The modal panel mode is named explicitly alongside the common modes, so a block +/// posted while an `NSAlert` is up still runs. This is the same mechanism +/// `SecureInputDialog` already relies on to focus its text field. +enum MainLoop { + private static let modes: [RunLoop.Mode] = [.common, .modalPanel, .default] + + /// Run `block` on the main thread, even from inside a nested modal loop. + static func perform(_ block: @escaping () -> Void) { + RunLoop.main.perform(inModes: modes, block: block) + // A loop parked waiting for input has to be told there is new work. + CFRunLoopWakeUp(CFRunLoopGetMain()) + } + + /// Run `block` on the main thread after `delay` seconds, with the same + /// guarantee. Returns a handle that cancels it. + static func after(_ delay: TimeInterval, _ block: @escaping () -> Void) -> Cancellable { + let timer = Timer(timeInterval: delay, repeats: false) { _ in block() } + for mode in modes { RunLoop.main.add(timer, forMode: mode) } + CFRunLoopWakeUp(CFRunLoopGetMain()) + return Cancellable(timer: timer) + } + + /// Repeat `block` on the main thread every `interval` seconds. + static func every(_ interval: TimeInterval, _ block: @escaping () -> Void) -> Cancellable { + let timer = Timer(timeInterval: interval, repeats: true) { _ in block() } + for mode in modes { RunLoop.main.add(timer, forMode: mode) } + CFRunLoopWakeUp(CFRunLoopGetMain()) + return Cancellable(timer: timer) + } + + struct Cancellable { + let timer: Timer + func cancel() { timer.invalidate() } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelIcons.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelIcons.swift new file mode 100644 index 000000000..618f73e71 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelIcons.swift @@ -0,0 +1,369 @@ +import AppKit +import SessionScoping + +/// The small pictures on the panel: varlock's own mark, and one per hop. +/// +/// An icon is the fastest way a person recognises their own terminal in the +/// chain, so where a real one exists we use the real one: the app's icon, +/// straight from its bundle. Where there is no bundle to ask (a CLI on the path, +/// an interpreter in a version manager's directory) we draw a small tile with the +/// tool's initials rather than shipping a logo we would have to keep in step with +/// somebody else's branding. +/// +/// A drop-in beats both: anything in `Resources/tool-icons/.png` wins, so a +/// proper icon set can be added later without touching this code. +/// +/// Every icon in the chain is drawn in a square box of one size. App icons are +/// square already; a document icon is page-shaped, and a monogram is whatever we +/// draw. Fitting each one inside the same square, rather than resizing it TO a +/// square, is what keeps a page from being stretched into a stamp and keeps the +/// rows on the rail lined up whatever mix of art a chain happens to carry. +enum PanelIcons { + /// The side of that box, everywhere in the chain. + static let side: CGFloat = 16 + + /// Icons are cheap but not free: LaunchServices can hit disk on a cold cache. + /// Nothing here is ever on the path that draws the panel. + private static var cache: [String: NSImage] = [:] + + // MARK: - varlock's own mark + + /// The app's icon, which is the same mark the menu bar and the Dock use. + /// + /// Drawn from the bundle when the daemon is running as the shipped app, and + /// from the source tree when it is a development build, so the panel looks + /// the same in the demo as it does in the field. + static func varlockMark() -> NSImage? { + if let cached = cache["varlock-mark"] { return cached } + let image = bundledResource(named: "AppIcon", extension: "icns") + ?? NSImage(systemSymbolName: "lock.fill", accessibilityDescription: "varlock") + if let image { + image.size = NSSize(width: 20, height: 20) + cache["varlock-mark"] = image + } + return image + } + + // MARK: - Hops + + /// The icon for one hop, or nil when there is nothing worth drawing. + /// + /// In order: the app's own icon, the agent's, varlock's own mark, the script + /// file's icon as the system draws it, a drop-in for a known tool, a tile + /// with the tool's initials, and for a shell the terminal symbol. Resolution + /// can touch LaunchServices and the disk, so callers do it after the panel is + /// up: see `PanelIconView`. + static func icon(for hop: ExecutionHop) -> NSImage? { + if let bundlePath = hop.bundlePath, let icon = appIcon(bundlePath: bundlePath) { + return icon + } + if let session = hop.agentSession { + return agentIcon(productName: session.productName) + } + // varlock is in every chain and is the one program in it the panel can + // speak for, so it wears its own mark rather than a generic binary. + if hop.isVarlock { return varlockHopMark() } + // A script is what the values are actually for, and its file's own icon + // is the fastest way to recognise it. Read from the resolved path so the + // registered handler answers for the real file. + if hop.via != nil { + guard let scriptPath = hop.scriptPath else { return genericDocument() } + return fileIcon(path: scriptPath) ?? genericDocument() + } + let name = executableName(for: hop) + if let dropIn = toolDropIn(named: name) { return dropIn } + if let monogram = monogramIcon(forTool: name) { return monogram } + // A tile rather than the "terminal" symbol, which draws a landscape + // screen: square canvas or not, the artwork still read as a wide + // rectangle beside the square tiles and app icons around it. + if isShell(name) { + return monogram( + ">_", + background: PanelStyle.chipBackground, + ink: PanelStyle.inkTertiary + ) + } + return nil + } + + /// A real app icon, when the path is a bundle that exists. + static func appIcon(bundlePath: String) -> NSImage? { + return fileIcon(path: bundlePath) + } + + /// Whatever the system draws for a file: the type's icon plus whatever the + /// registered handler contributes. + /// + /// Always asked about a PATH, never about an extension or a type derived from + /// one. `UTType(filenameExtension: "ts")` answers `public.mpeg-2-transport-stream` + /// on a stock machine, so an extension lookup can hand back a video icon for + /// a TypeScript file. + static func fileIcon(path: String) -> NSImage? { + if let cached = cache[path] { return cached } + guard FileManager.default.fileExists(atPath: path) else { return nil } + let icon = fitted(NSWorkspace.shared.icon(forFile: path)) + cache[path] = icon + return icon + } + + /// varlock's own mark at chain-row size. + /// + /// The app icon rather than the menu bar art: the menu bar marks are template + /// images meant to be tinted by the menu bar, and dropped onto the panel's own + /// background they read as a grey smudge. The app icon is the same mark the + /// Dock and the panel's header already use, in colour, and it is legible at + /// 16pt. + private static func varlockHopMark() -> NSImage? { + if let cached = cache["varlock-hop"] { return cached } + guard let image = bundledResource(named: "AppIcon", extension: "icns") + ?? NSImage(systemSymbolName: "lock.fill", accessibilityDescription: "varlock") else { + return nil + } + let mark = fitted(image) + cache["varlock-hop"] = mark + return mark + } + + /// The plain page a script gets when its file cannot be found: honest about + /// there being a file, silent about what kind. Never a guess from the name. + private static func genericDocument() -> NSImage? { + return symbol("doc", tint: PanelStyle.inkTertiary) + } + + /// The artwork centred on a square canvas, scaled to fit and never squashed. + /// + /// Every icon this returns is the same square, whatever shape it arrived as. + /// That matters because the shapes genuinely differ: an app icon is square, a + /// document icon is page shaped, and an SF Symbol is usually wider than it is + /// tall. Returning their natural rectangles left glyph rows visibly smaller + /// than the app rows beside them, so the canvas is the constant and the + /// artwork is what varies inside it. + private static func fitted(_ image: NSImage, box: CGFloat = side) -> NSImage { + let natural = image.size + guard natural.width > 0, natural.height > 0 else { + // Nothing to scale by: hand back an empty square so the row still + // lines up with its neighbours. + return NSImage(size: NSSize(width: box, height: box)) + } + let scale = box / max(natural.width, natural.height) + let drawn = NSSize(width: natural.width * scale, height: natural.height * scale) + let canvas = NSImage(size: NSSize(width: box, height: box)) + canvas.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + // Drawn from the source rather than mutated in place: NSWorkspace hands + // back images it also holds, and resizing one of those resizes it for + // everybody who asked. + image.draw( + in: NSRect( + x: (box - drawn.width) / 2, + y: (box - drawn.height) / 2, + width: drawn.width, + height: drawn.height + ), + from: .zero, + operation: .sourceOver, + fraction: 1 + ) + canvas.unlockFocus() + return canvas + } + + /// The agent's own app icon when it is installed, and its initial otherwise. + /// + /// The CLI a session runs under has no bundle of its own, so the icon has to + /// come from the app that ships alongside it. + private static func agentIcon(productName: String) -> NSImage? { + for path in agentAppPaths[productName] ?? [] { + if let icon = appIcon(bundlePath: path) { return icon } + } + return monogram( + String(productName.prefix(1)), + background: PanelStyle.sessionRail, + ink: PanelStyle.sessionInk + ) + } + + /// Where each agent's own app lives, when it has one. + private static let agentAppPaths: [String: [String]] = [ + "Claude Code": ["/Applications/Claude.app", NSHomeDirectory() + "/Applications/Claude.app"], + ] + + /// The tool this hop is, whatever it is running: `bun` for a script under bun. + private static func executableName(for hop: ExecutionHop) -> String { + if let via = hop.via, via.hasPrefix("via ") { return String(via.dropFirst(4)) } + if let path = hop.path, !path.isEmpty { return (path as NSString).lastPathComponent } + return hop.name + } + + // MARK: - Tools + + /// A drop-in icon for a tool, if one has been added to the bundle. + /// + /// Nothing ships here today: a logo is somebody else's asset to keep current, + /// and a stale one is worse than a tile. The lookup exists so a proper set can + /// be dropped in without a code change. + private static func toolDropIn(named name: String) -> NSImage? { + if let cached = cache["tool:\(name)"] { return cached } + guard let image = bundledResource(named: "tool-icons/\(name)", extension: "png") else { return nil } + let sized = fitted(image) + cache["tool:\(name)"] = sized + return sized + } + + /// The tools worth drawing a tile for, with the colour each is known by. + /// + /// One or two characters: a tile this small is read as a mark, not as a word, + /// and four letters at 16pt is a smudge. + private static let toolTiles: [String: (label: String, tint: UInt32)] = [ + "bun": ("b", 0xF2_E4_D2), + "node": ("n", 0x6C_C2_4A), + "deno": ("d", 0xE5_E5_EA), + "python": ("py", 0x4B_8B_BE), + "python3": ("py", 0x4B_8B_BE), + "ruby": ("rb", 0xCC_34_2D), + "perl": ("pl", 0x9B_8A_C4), + "tsx": ("ts", 0x31_78_C6), + "ts-node": ("ts", 0x31_78_C6), + ] + + private static func monogramIcon(forTool name: String) -> NSImage? { + guard let tile = toolTiles[name] else { return nil } + return monogram( + tile.label, + background: PanelStyle.chipBackground, + ink: PanelStyle.color(tile.tint) + ) + } + + private static func isShell(_ name: String) -> Bool { + return ["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login"].contains(name) + } + + /// The last-resort mark: a terminal, which is where a request without any + /// other identity came from. Drawn as the same square tile the shells get, + /// so a row waiting on its icon does not change shape when the real one + /// arrives. + static func genericTerminal() -> NSImage? { + return monogram(">_", background: PanelStyle.chipBackground, ink: PanelStyle.inkQuiet) + } + + // MARK: - Drawing + + private static func symbol(_ name: String, tint: NSColor) -> NSImage? { + if let cached = cache["symbol:\(name)"] { return cached } + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil } + let configured = image.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + ) ?? image + // Squared like everything else: symbols are typically wider than they are + // tall, and a bare one sat visibly short next to the app icons above it. + let tinted = fitted(tintedCopy(configured, tint: tint)) + cache["symbol:\(name)"] = tinted + return tinted + } + + private static func tintedCopy(_ image: NSImage, tint: NSColor) -> NSImage { + let copy = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect) + tint.set() + rect.fill(using: .sourceAtop) + return true + } + return copy + } + + /// A small rounded tile carrying a tool's initials. Reads as an icon at 16pt + /// while being ours to draw, which a borrowed logo would not be. + private static func monogram(_ text: String, background: NSColor, ink: NSColor) -> NSImage { + let key = "monogram:\(text):\(background.description):\(ink.description)" + if let cached = cache[key] { return cached } + let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in + let tile = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4) + background.setFill() + tile.fill() + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: text.count > 1 ? 8 : 10, weight: .bold), + .foregroundColor: ink, + ] + let string = NSAttributedString(string: text.lowercased(), attributes: attributes) + let size = string.size() + string.draw(at: NSPoint( + x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2 + )) + return true + } + cache[key] = image + return image + } + + // MARK: - Finding resources + + /// A resource from the app bundle, falling back to the source tree. + /// + /// A development build is a bare executable with no bundle around it, so + /// without the fallback every icon would be missing in exactly the situation + /// where the panel is being looked at on purpose: the demo. + /// + /// Shared with the menu bar, which has the same problem and used to solve it + /// by not using our artwork at all. + static func bundledResource(named name: String, extension ext: String) -> NSImage? { + if let url = Bundle.main.url(forResource: name, withExtension: ext), + let image = NSImage(contentsOf: url) { + return image + } + var directory = URL(fileURLWithPath: Bundle.main.bundlePath) + for _ in 0..<6 { + let candidate = directory + .appendingPathComponent("resources") + .appendingPathComponent(name) + .appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: candidate.path) { + return NSImage(contentsOf: candidate) + } + directory = directory.deletingLastPathComponent() + } + return nil + } +} + +/// An image view that fills itself in once the panel is already up. +/// +/// Resolving an icon can go to LaunchServices, and nothing about a picture is +/// worth delaying an approval for. This draws a placeholder immediately and +/// replaces it from the run loop, which for a cached icon is the same frame and +/// for a cold one is a moment later. +/// +/// The box is always square and always the same size, whatever turns up in it. +/// The view owns that, not the image: art arrives at every shape and size (app +/// icons square, document icons page-shaped, SF Symbols whatever they please), +/// and a row whose icon is a different width from its neighbour's throws the +/// whole rail out. So the frame is pinned, the artwork is scaled to fit inside +/// it, and it is centred; nothing is ever stretched or cropped to fill. +final class PanelIconView: NSImageView { + init(side: CGFloat, placeholder: NSImage?, resolve: @escaping () -> NSImage?) { + super.init(frame: .zero) + image = placeholder + imageScaling = .scaleProportionallyUpOrDown + imageAlignment = .alignCenter + imageFrameStyle = .none + translatesAutoresizingMaskIntoConstraints = false + // The stack view around this one distributes slack, and an icon is not + // where slack should go. + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: side), + heightAnchor.constraint(equalToConstant: side), + ]) + MainLoop.perform { [weak self] in + guard let self, let resolved = resolve() else { return } + self.image = resolved + } + } + + required init?(coder: NSCoder) { fatalError("not used") } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelKeyBoxView.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelKeyBoxView.swift new file mode 100644 index 000000000..bb0f20180 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelKeyBoxView.swift @@ -0,0 +1,234 @@ +import AppKit +import IdentitySessions +import SessionScoping + +/// "What do they get": one card, one row per key. +/// +/// The row answers the question at a glance (which key, which vault, how many +/// values) and opens to every source those values sit in: the env files, and +/// varlock's value cache, listed the same way as siblings. That is the point of +/// grouping by key rather than by kind. One key is one grant, so one row holds +/// everything the grant opens, and nothing the user is approving is off to the +/// side where it can be missed. +/// +/// That detail is client-reported, and the footnote inside the open row says so: +/// the daemon has no way to know what an env value is called, and pretending +/// otherwise would put the panel's own credibility behind a caller's strings. +final class PanelKeyBoxView: NSView { + private let onLayoutChanged: () -> Void + /// Whether every row starts open. Only `panel-preview` sets this: a picture + /// of the panel is worth taking precisely for the list behind the rows, and + /// a still cannot click. + private let startExpanded: Bool + + init(rows: [PanelKeyRow], startExpanded: Bool = false, onLayoutChanged: @escaping () -> Void) { + self.onLayoutChanged = onLayoutChanged + self.startExpanded = startExpanded + super.init(frame: .zero) + + let card = PanelStyle.card() + card.translatesAutoresizingMaskIntoConstraints = false + addSubview(card) + NSLayoutConstraint.activate([ + card.leadingAnchor.constraint(equalTo: leadingAnchor), + card.trailingAnchor.constraint(equalTo: trailingAnchor), + card.topAnchor.constraint(equalTo: topAnchor), + card.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + let column = PanelStyle.column(spacing: 0) + column.alignment = .leading + column.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(column) + NSLayoutConstraint.activate([ + column.leadingAnchor.constraint(equalTo: card.leadingAnchor), + column.trailingAnchor.constraint(equalTo: card.trailingAnchor), + column.topAnchor.constraint(equalTo: card.topAnchor), + column.bottomAnchor.constraint(equalTo: card.bottomAnchor), + ]) + + for (index, row) in rows.enumerated() { + if index > 0 { column.addArrangedSubview(divider()) } + column.addArrangedSubview(keyRow(row)) + } + } + + required init?(coder: NSCoder) { fatalError("not used") } + + private func divider() -> NSView { + let line = NSView() + line.wantsLayer = true + line.layer?.backgroundColor = PanelStyle.cardDivider.cgColor + line.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + line.heightAnchor.constraint(equalToConstant: 1), + line.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + return line + } + + private func keyRow(_ row: PanelKeyRow) -> NSView { + let column = PanelStyle.column(spacing: 0) + column.translatesAutoresizingMaskIntoConstraints = false + + let head = PanelStyle.row(spacing: 8) + head.addArrangedSubview(PanelStyle.label( + row.displayName, + size: 12.5, + color: PanelStyle.ink, + weight: .semibold, + mono: true + )) + if let vaultLabel = row.vaultLabel { + let tag = PanelStyle.row(spacing: 5) + tag.addArrangedSubview(PanelStyle.swatch( + PanelStyle.color(hex: row.vaultColor) ?? PanelStyle.vaultLocal + )) + tag.addArrangedSubview(PanelStyle.label(vaultLabel, size: 10.5, color: PanelStyle.inkTertiary)) + head.addArrangedSubview(tag) + } + if let note = row.note { + head.addArrangedSubview(PanelStyle.label(note, size: 10.5, color: PanelStyle.warn)) + } + head.addArrangedSubview(PanelStyle.spacer()) + // Always says something. A row whose client reported nothing says so, in + // the quiet colour, rather than leaving a gap that would read as "there + // is not much in here". + head.addArrangedSubview(PanelStyle.label( + row.contentsLabel, + size: 11.5, + color: row.reportsContents ? PanelStyle.inkTertiary : PanelStyle.inkQuiet + )) + + let headBox = NSView() + head.translatesAutoresizingMaskIntoConstraints = false + headBox.addSubview(head) + NSLayoutConstraint.activate([ + head.leadingAnchor.constraint(equalTo: headBox.leadingAnchor, constant: 12), + head.trailingAnchor.constraint(equalTo: headBox.trailingAnchor, constant: -12), + head.topAnchor.constraint(equalTo: headBox.topAnchor, constant: 8), + head.bottomAnchor.constraint(equalTo: headBox.bottomAnchor, constant: -8), + headBox.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + + guard row.isExpandable else { + column.addArrangedSubview(headBox) + return column + } + + // A row with something behind it says so, and opening it is what shows + // the value names. + let chevron = PanelStyle.label("\u{25B8}", size: 10, color: PanelStyle.inkQuiet) + head.addArrangedSubview(chevron) + + let body = valueList(row) + body.isHidden = !startExpanded + + let disclosure = PanelDisclosureRow( + content: headBox, + chevron: chevron, + isOpen: startExpanded + ) { [weak self] isOpen in + body.isHidden = !isOpen + self?.onLayoutChanged() + } + disclosure.translatesAutoresizingMaskIntoConstraints = false + column.addArrangedSubview(disclosure) + column.addArrangedSubview(body) + NSLayoutConstraint.activate([ + disclosure.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + return column + } + + /// The open row: one heading and chip list per source, in the order the + /// client sent them. + /// + /// Every source is drawn the same way, on purpose. An env file and the value + /// cache sit under the same key because the same grant opens both, so the + /// panel puts them in one list and lets the headings say which is which, + /// rather than giving one of them a shape that implies it is the real answer + /// and the other an aside. + private func valueList(_ row: PanelKeyRow) -> NSView { + let column = PanelStyle.column(spacing: 5) + column.translatesAutoresizingMaskIntoConstraints = false + + for source in row.sources where source.isDrawable { + if let heading = source.heading { + let line = PanelStyle.row(spacing: 6) + line.addArrangedSubview(PanelStyle.label( + heading, + size: 10.5, + color: PanelStyle.inkTertiary, + mono: true + )) + // Only when there is a number to put in it: a source whose size + // the client did not report draws no badge, rather than an empty + // one that would read as a count of nothing. + if let count = source.headingCount { + line.addArrangedSubview(PanelStyle.countBadge(count)) + } + column.addArrangedSubview(line) + } + if !source.entries.isEmpty { + column.addArrangedSubview(WrappingChipView( + names: source.entries.map { $0.label }, + maxWidth: PanelStyle.contentWidth - 24 + )) + } + } + column.addArrangedSubview(PanelStyle.label( + row.sourceFootnote, + size: 10, + color: PanelStyle.inkQuiet + )) + + let box = NSView() + box.addSubview(column) + NSLayoutConstraint.activate([ + column.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 12), + column.trailingAnchor.constraint(lessThanOrEqualTo: box.trailingAnchor, constant: -12), + column.topAnchor.constraint(equalTo: box.topAnchor, constant: 2), + column.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -11), + box.widthAnchor.constraint(equalToConstant: PanelStyle.contentWidth), + ]) + return box + } +} + +/// Value-name chips, wrapped onto as many lines as they need. +/// +/// Auto layout has no flow container, and a horizontal stack would push a long +/// list off the panel, so the wrapping is worked out here from each chip's +/// fitting size. +final class WrappingChipView: NSView { + init(names: [String], maxWidth: CGFloat) { + super.init(frame: .zero) + let column = PanelStyle.column(spacing: 4) + column.translatesAutoresizingMaskIntoConstraints = false + addSubview(column) + NSLayoutConstraint.activate([ + column.leadingAnchor.constraint(equalTo: leadingAnchor), + column.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + column.topAnchor.constraint(equalTo: topAnchor), + column.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + var line = PanelStyle.row(spacing: 6) + var lineWidth: CGFloat = 0 + for name in names { + let chip = PanelStyle.chip(name) + let width = chip.fittingSize.width + if lineWidth > 0, lineWidth + width + 6 > maxWidth { + column.addArrangedSubview(line) + line = PanelStyle.row(spacing: 6) + lineWidth = 0 + } + line.addArrangedSubview(chip) + lineWidth += width + 6 + } + if !line.arrangedSubviews.isEmpty { column.addArrangedSubview(line) } + } + + required init?(coder: NSCoder) { fatalError("not used") } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelPreviewChain.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelPreviewChain.swift new file mode 100644 index 000000000..604148274 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelPreviewChain.swift @@ -0,0 +1,136 @@ +import Foundation +import Darwin +import SessionScoping + +/// A process tree written down in the preview payload instead of read off the +/// machine. +/// +/// The panel's job is to describe situations that are awkward to be in on +/// purpose: varlock running as JavaScript under bun, an agent with nobody +/// watching it, an agent working in a different project from the one being +/// unlocked. Checking how those look should not require arranging one, so +/// `panel-preview` accepts the tree as data and runs the SAME +/// `ExecutionChainBuilder` over it. Nothing here is a second rendering path; +/// only the source of the facts changes. +/// +/// Preview only. Neither the daemon nor any unlock ever constructs one of these. +enum PanelPreviewChain { + /// Build a chain from a `processes` array, or nil when the payload has none. + /// + /// ```json + /// "processes": [ + /// { "pid": 100, "ppid": 1, "path": "/Applications/iTerm.app/Contents/MacOS/iTerm2" }, + /// { "pid": 300, "ppid": 200, "path": "/Users/me/.bun/bin/bun", + /// "args": ["bun", "/p/node_modules/.bin/varlock", "load"], + /// "signed": true, "hardened": true } + /// ], + /// "peerPid": 300 + /// ``` + static func chain(from payload: [String: Any]) -> ExecutionChain? { + guard let raw = payload["processes"] as? [[String: Any]], !raw.isEmpty else { return nil } + let processes = raw.compactMap(ScriptedProcess.init) + guard !processes.isEmpty else { return nil } + let peer = (payload["peerPid"] as? NSNumber)?.int32Value ?? processes.last?.pid ?? 0 + let session = payload["agentSession"] as? [String: Any] + return ExecutionChainBuilder( + provider: ScriptedProcessProvider(processes: processes), + posture: ScriptedPostureProbe(processes: processes), + sessionMetadata: ScriptedMetadataReader(record: session) + ).build(forPid: peer) + } +} + +/// One process as the payload described it. +private struct ScriptedProcess { + let pid: pid_t + let ppid: pid_t + let tty: dev_t + let startTime: Int + let path: String? + let arguments: [String] + let environment: [String: String] + let workingDirectory: String? + let facts: PeerPostureFacts + + init?(_ raw: [String: Any]) { + guard let pid = (raw["pid"] as? NSNumber)?.int32Value else { return nil } + self.pid = pid + ppid = (raw["ppid"] as? NSNumber)?.int32Value ?? 1 + tty = dev_t((raw["tty"] as? NSNumber)?.int32Value ?? 0) + startTime = (raw["startTime"] as? NSNumber)?.intValue ?? 0 + path = raw["path"] as? String + arguments = (raw["args"] as? [String]) ?? [] + environment = (raw["env"] as? [String: String]) ?? [:] + workingDirectory = raw["cwd"] as? String + // Absent means "the kernel would not say", which is a distinct answer the + // panel has to be able to draw, so it is the default rather than a + // convenient stand-in for "fine". + let readable = (raw["signed"] ?? raw["hardened"]) != nil + facts = PeerPostureFacts( + isTraced: (raw["traced"] as? NSNumber)?.boolValue ?? false, + hasHardenedRuntime: (raw["hardened"] as? NSNumber)?.boolValue ?? false, + signatureValid: (raw["signed"] as? NSNumber)?.boolValue ?? false, + isReadable: readable + ) + } +} + +private struct ScriptedProcessProvider: ProcessProvider { + let processes: [ScriptedProcess] + + private func process(_ pid: pid_t) -> ScriptedProcess? { + return processes.first { $0.pid == pid } + } + + func info(for pid: pid_t) -> ProcSnapshot? { + guard let match = process(pid) else { return nil } + return ProcSnapshot(pid: match.pid, ppid: match.ppid, tty: match.tty, startTime: match.startTime) + } + + func environment(for pid: pid_t) -> [String: String]? { return process(pid)?.environment } + func arguments(for pid: pid_t) -> [String]? { return process(pid)?.arguments } + func path(for pid: pid_t) -> String? { return process(pid)?.path } + func workingDirectory(for pid: pid_t) -> String? { return process(pid)?.workingDirectory } + + func ttyName(forDevice dev: dev_t) -> String? { + return dev > 0 ? "ttys\(String(format: "%03d", Int(dev)))" : nil + } + + func sessionLeader(for pid: pid_t) -> pid_t { + // The outermost process the payload described, which is what a session + // leader is for a tree that was written down top first. + return processes.first?.pid ?? pid + } +} + +private struct ScriptedPostureProbe: PostureProbe { + let processes: [ScriptedProcess] + + func posture(forPid pid: pid_t) -> PeerPostureFacts { + return processes.first { $0.pid == pid }?.facts ?? .unreadable + } +} + +/// The agent's own session record, taken from the payload instead of from +/// `~/.claude/sessions`. Parsed by the same code that reads the real file, so a +/// preview cannot drift from what a live session would produce. +private struct ScriptedMetadataReader: AgentSessionMetadataReader { + let record: [String: Any]? + + func metadata(for product: AgentProduct, pid: pid_t, processStartTime: Int) -> AgentSessionMetadata? { + guard let record else { return nil } + let startedAt = (record["startedAt"] as? NSNumber)?.doubleValue + return AgentSessionMetadata( + title: LiveAgentSessionMetadataReader.humanTitle(record["name"]), + isTitleDerived: (record["nameSource"] as? String) == "derived", + startTime: startedAt.map { Int($0 / 1000) }, + kind: LiveAgentSessionMetadataReader.shortField(record["kind"]), + workingDirectory: LiveAgentSessionMetadataReader.shortField( + record["cwd"], + limit: LiveAgentSessionMetadataReader.maxPathLength + ), + entrypoint: LiveAgentSessionMetadataReader.shortField(record["entrypoint"]), + version: LiveAgentSessionMetadataReader.shortField(record["version"]) + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelStyle.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelStyle.swift new file mode 100644 index 000000000..d85102b9e --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PanelStyle.swift @@ -0,0 +1,244 @@ +import AppKit +import IdentitySessions + +/// The approval panel's look, in one place. +/// +/// The panel commits to a dark chrome rather than following the system +/// appearance. It is not a document window: it is an interruption that has to be +/// recognisable at a glance as varlock asking, whatever is behind it, and a +/// panel that changes colour with the user's theme is one more thing an +/// impostor could get right by accident. +enum PanelStyle { + /// Inner width of the panel's content. Wide enough that a typical binary + /// path fits in the chain's quiet column: a truncated path is evidence you + /// cannot check, which is worse than a slightly wider window. + static let contentWidth: CGFloat = 430 + static let contentInset: CGFloat = 20 + + static let panelBackground = color(0x21_21_25) + static let panelBorder = color(0x38_38_3E) + + static let ink = color(0xE9_E9_EC) + static let inkSecondary = color(0x9A_9A_A1) + static let inkTertiary = color(0x8F_8F_97) + static let inkQuiet = color(0x6F_6F_76) + static let wordmark = color(0xC6_C6_CC) + + static let cardBackground = color(0x24_24_28) + static let cardBorder = color(0x32_32_38) + static let cardDivider = color(0x2E_2E_34) + static let chipBackground = color(0x2D_2D_33) + static let chipInk = color(0xC2_C2_C8) + /// The ground under a count badge. Quieter than a value chip, because the + /// number is a measure of the line it sits on and not another thing in it. + static let countBadgeBackground = color(0x2B_2B_31) + static let countBadgeInk = color(0x9C_9C_A4) + + static let chainBackground = color(0x28_28_2C) + static let chainBorder = color(0x35_35_3B) + static let chainRail = color(0x38_38_3E) + static let chainDot = color(0x4A_4A_52) + + /// The strip a command line is drawn on, so `$ varlock load` reads as + /// something typed at a prompt rather than as one more grey note. + static let commandStrip = color(0x1B_1B_1F) + static let commandStripBorder = color(0x35_35_3B) + /// The command itself: brighter than the prose around it, because it is the + /// one line on the panel a person can match against what they typed. + static let commandInk = color(0xC8_C8_D0) + /// The `$`. Present, and quiet enough not to be read as part of the command. + static let commandSigil = color(0x5F_5F_68) + + static let accent = color(0xFF_5D_73) + /// The agent session's colour, used for its hop and for the rail beneath it. + static let sessionDot = color(0xB4_8C_E8) + static let sessionInk = color(0xCD_B6_F0) + static let sessionTitleInk = color(0x9A_87_B8) + static let sessionRail = color(0x5B_47_79) + static let sessionRowBackground = color(0x2C_23_37) + static let sessionTagBackground = color(0x47_36_5C) + static let vaultLocal = color(0x4A_72_D8) + static let warn = color(0xD9_A2_4A) + static let ok = color(0x57_B0_6A) + + static let primaryButton = color(0x2F_6F_ED) + static let primaryButtonPressed = color(0x27_5C_C8) + /// Deny reads as a refusal, not as a second choice: red on a tinted ground + /// rather than a solid red button, which would compete with the approve + /// action it is meant to sit quietly beside. + static let denyInk = color(0xF0_8A_8A) + static let denyButton = color(0x33_23_26) + static let denyButtonPressed = color(0x40_2B_2F) + static let denyButtonBorder = color(0x5A_31_36) + static let segmentTrack = color(0x2B_2B_30) + static let segmentTrackBorder = color(0x3A_3A_41) + + static func color(_ rgb: UInt32) -> NSColor { + return NSColor( + srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } + + /// A client-supplied `#rrggbb`, or nil. Only the exact form is accepted; the + /// parse already happened once when the payload was read, and this is the + /// second half of the same refusal to trust a colour string. + static func color(hex: String?) -> NSColor? { + guard let hex, hex.count == 7, hex.hasPrefix("#") else { return nil } + guard let value = UInt32(hex.dropFirst(), radix: 16) else { return nil } + return color(value) + } + + // MARK: - Text + + static func label( + _ text: String, + size: CGFloat, + color: NSColor = ink, + weight: NSFont.Weight = .regular, + mono: Bool = false + ) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.font = mono + ? NSFont.monospacedSystemFont(ofSize: size, weight: weight) + : NSFont.systemFont(ofSize: size, weight: weight) + field.textColor = color + field.lineBreakMode = .byTruncatingTail + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + /// A heading whose key names are drawn as identifiers. + static func heading(_ segments: [PanelTextSegment], size: CGFloat) -> NSTextField { + let string = NSMutableAttributedString() + for segment in segments { + let font: NSFont + switch segment { + case .plain: + font = NSFont.systemFont(ofSize: size, weight: .semibold) + case .code: + font = NSFont.monospacedSystemFont(ofSize: size - 0.5, weight: .semibold) + } + string.append(NSAttributedString( + string: segment.text, + attributes: [.font: font, .foregroundColor: ink] + )) + } + let field = NSTextField(labelWithAttributedString: string) + field.alignment = .center + field.lineBreakMode = .byTruncatingTail + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + // MARK: - Boxes + + /// A rounded card, the shape the panel groups things in. + static func card(background: NSColor = cardBackground, border: NSColor = cardBorder) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = background.cgColor + view.layer?.borderColor = border.cgColor + view.layer?.borderWidth = 1 + view.layer?.cornerRadius = 10 + view.layer?.masksToBounds = true + return view + } + + /// A small filled square: the vault's identity mark, and the only place a + /// vault colour is ever used. + static func swatch(_ color: NSColor, side: CGFloat = 6) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = color.cgColor + view.layer?.cornerRadius = 2 + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: side), + view.heightAnchor.constraint(equalToConstant: side), + ]) + return view + } + + /// A pill around a value name. + static func chip(_ text: String) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = chipBackground.cgColor + view.layer?.cornerRadius = 4 + let field = label(text, size: 10.5, color: chipInk, mono: true) + field.translatesAutoresizingMaskIntoConstraints = false + field.setContentCompressionResistancePriority(.required, for: .horizontal) + view.addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6), + field.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), + field.topAnchor.constraint(equalTo: view.topAnchor, constant: 1), + field.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -1), + ]) + return view + } + + /// How much is in a source, as a mark beside its name. + /// + /// A count is the one thing on these lines a reader compares rather than + /// reads, so it gets a shape of its own: numerals in a pill at a fixed + /// height, so a column of them lines up whatever the names beside them are. + /// One and two digits come out round; three widen the pill rather than + /// shrinking the number, since a count too small to read is worse than one + /// that takes a few more points. + /// + /// Tabular figures, so the digits line up as well as the pills do. + static func countBadge(_ count: Int) -> NSView { + let height: CGFloat = 15 + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = countBadgeBackground.cgColor + view.layer?.cornerRadius = height / 2 + view.translatesAutoresizingMaskIntoConstraints = false + + let field = NSTextField(labelWithString: String(count)) + field.font = NSFont.monospacedDigitSystemFont(ofSize: 9.5, weight: .medium) + field.textColor = countBadgeInk + field.alignment = .center + field.translatesAutoresizingMaskIntoConstraints = false + field.setContentCompressionResistancePriority(.required, for: .horizontal) + view.addSubview(field) + + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: height), + view.widthAnchor.constraint(greaterThanOrEqualToConstant: height), + field.centerXAnchor.constraint(equalTo: view.centerXAnchor), + field.centerYAnchor.constraint(equalTo: view.centerYAnchor), + field.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 5), + field.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -5), + ]) + return view + } + + /// A flexible gap in a horizontal stack. + static func spacer() -> NSView { + let view = NSView() + view.setContentHuggingPriority(.init(1), for: .horizontal) + view.setContentCompressionResistancePriority(.init(1), for: .horizontal) + return view + } + + static func row(spacing: CGFloat, alignment: NSLayoutConstraint.Attribute = .centerY) -> NSStackView { + let stack = NSStackView() + stack.orientation = .horizontal + stack.alignment = alignment + stack.spacing = spacing + return stack + } + + static func column(spacing: CGFloat, alignment: NSLayoutConstraint.Attribute = .leading) -> NSStackView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = alignment + stack.spacing = spacing + return stack + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift index 6ffe090d9..ff62334b0 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import IdentitySessions import SessionScoping // The session-scoping logic (TTY / process-tree identity) lives in the @@ -42,3 +43,48 @@ func verifyPeerProcess(pid: pid_t) -> String? { guard allowedBinaryNames.contains(binaryName) else { return nil } return binaryName } + +// MARK: - Peer Posture + +/// The posture checks this daemon applies to its callers. +/// +/// Resolved once at startup rather than per connection: it depends on this +/// process's own code signature, which cannot change while it runs, and on the +/// config file, which is not worth re-reading on every accept. The daemon says +/// on stderr what it settled on, so a build that is quietly only warning is +/// visible in the log rather than being a surprise later. +enum PeerPosture { + private static let reader = PeerPostureReader() + + private static let requirements: PeerPostureRequirements = { + let selfFacts = reader.selfFacts() + let resolved = PeerPostureEvaluator.resolve( + selfFacts: selfFacts, + machineConfigData: IdentityStore.readMachineConfigData() + ) + if !selfFacts.hasHardenedRuntime { + fputs( + "varlock: this daemon is not running with the Hardened Runtime (a development build), " + + "so peer posture problems are reported and not refused\n", + stderr + ) + } + return resolved + }() + + /// Check one peer. Returns the violation that refuses it, or nil to serve it, + /// having already reported anything that failed but was set to warn. + static func check(pid: pid_t, path: String) -> PeerPostureViolation? { + let outcome = PeerPostureEvaluator.evaluate( + facts: reader.facts(forPid: pid), + requirements: requirements + ) + for warning in outcome.warnings { + fputs(warning.stderrLine(pid: pid, path: path, severity: .warn), stderr) + } + if let rejection = outcome.rejection { + fputs(rejection.stderrLine(pid: pid, path: path, severity: .reject), stderr) + } + return outcome.rejection + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift index f79db9509..03e8df732 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift @@ -90,6 +90,35 @@ final class SecureEnclaveManager { return Data(privateKey.publicKey.x963Representation) } + /// Create an ephemeral Secure Enclave key for one unlock session. + /// + /// Access control is `.privateKeyUsage` only: no user presence, so the daemon can + /// unwrap under it silently for the life of the session. What keeps that safe is + /// that the key exists nowhere but this process's memory. Unlike `generateKey`, + /// no `.keydata` file is written, so there is nothing on disk for a later process + /// (or a reboot) to pick up and open silently. Ending the session scrubs the data + /// representation, and every blob wrapped under it becomes unreadable. + static func createEphemeralSessionKey() throws -> SecureEnclave.P256.KeyAgreement.PrivateKey { + var accessError: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + [.privateKeyUsage], + &accessError + ) else { + let err = accessError?.takeRetainedValue() + throw EnclaveError.keyGenerationFailed( + err?.localizedDescription ?? "Failed to create session key access control" + ) + } + + do { + return try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: accessControl) + } catch { + throw EnclaveError.keyGenerationFailed(error.localizedDescription) + } + } + /// Delete a key by removing its data representation file. static func deleteKey(keyId: String) -> Bool { let filePath = keyFilePath(for: keyId) @@ -117,10 +146,26 @@ final class SecureEnclaveManager { return FileManager.default.fileExists(atPath: keyFilePath(for: keyId)) } + /// When a key was created, as an ISO 8601 string, for `status` to report. + /// + /// Taken from the stored key file's creation date, since nothing records it + /// separately. Second precision, matching the Rust helper's `createdAt`. Nil + /// when the filesystem does not report one, in which case the field is left + /// out rather than guessed. + static func keyCreatedAt(keyId: String) -> String? { + let attributes = try? FileManager.default.attributesOfItem(atPath: keyFilePath(for: keyId)) + guard let created = attributes?[.creationDate] as? Date else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: created) + } + // MARK: - Key Loading /// Load a Secure Enclave private key from its stored data representation. - private static func loadPrivateKey(keyId: String, context: LAContext?) throws -> SecureEnclave.P256.KeyAgreement.PrivateKey { + /// + /// Not private: the identity-session code loads custody keys through this too. + static func loadPrivateKey(keyId: String, context: LAContext?) throws -> SecureEnclave.P256.KeyAgreement.PrivateKey { let filePath = keyFilePath(for: keyId) guard let data = FileManager.default.contents(atPath: filePath) else { throw EnclaveError.keyNotFound(keyId) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionManager.swift index 5049a9c03..06e6caf2d 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionManager.swift @@ -1,6 +1,7 @@ import Foundation import LocalAuthentication import AppKit +import IdentitySessions /// Manages biometric authentication sessions for the daemon, scoped per-session. /// @@ -32,6 +33,34 @@ final class SessionManager { /// Called when the daemon should shut down due to inactivity var onDaemonTimeout: (() -> Void)? + /// Whether something is still being held that must outlive an idle stretch. + /// + /// Identity unlock sessions live only in daemon memory, so quitting while one is + /// open would silently throw away an unlock the user paid a fingerprint for. When + /// this returns true the idle timer re-arms instead of firing. + var hasLiveWork: (() -> Bool)? + + /// Called on an explicit lock, alongside dropping cached biometric contexts. + /// An explicit lock always erases everything, whatever any lock policy says. + var onSystemLock: (() -> Void)? + + /// Called on a system lock event (sleep, screen lock), which erases identity + /// sessions selectively according to each session's own policy. + var onLockEvent: ((SessionLockEvent) -> Void)? + + /// How to get the user's approval before a device-key read. + /// + /// Injected, and set by the daemon to the approval panel. Without it this + /// path evaluated a policy directly, which on macOS means the system's own + /// sheet appearing with nothing behind it: no statement of who was asking or + /// what they wanted, and after a lock it looked like the machine demanding a + /// fingerprint out of nowhere. Every presence check now happens with our + /// panel already on screen; the only exception is the one-time setup step, + /// which says what it is. + /// The wording is not passed in: the panel owns what the user is told, and + /// what the system's own sheet says is derived from the same content. + var authorize: ((_ peerPid: pid_t?) throws -> LAContext)? + private var daemonTimer: DispatchSourceTimer? init() { @@ -50,15 +79,32 @@ final class SessionManager { /// reuse duration return the cached context without re-prompting. /// /// Processes with no identifiable session always require fresh authentication. - func getAuthenticatedContext(sessionId: String?) throws -> LAContext { - return try queue.sync { - // Check for cached context from a previous auth in this session - if let key = sessionId, let context = contexts[key] { + func getAuthenticatedContext(sessionId: String?, peerPid: pid_t? = nil) throws -> LAContext { + // Check for cached context from a previous auth in this session + if let cached = queue.sync(execute: { sessionId.flatMap { contexts[$0] } }) { + queue.sync { resetDaemonTimer() } + return cached + } + + // Need fresh auth (first time for this session, or always for + // unidentifiable callers). Through the panel where one is wired up, so + // the user is asked rather than merely prompted. + // + // Deliberately NOT inside `queue.sync`: the panel is a modal loop on the + // main thread, and a lock arriving while it is up calls + // `invalidateAllSessions`, which wants this queue. Holding it here would + // mean the lock waiting on the panel and the panel waiting on the main + // thread the lock is blocking. + if let authorize { + let approved = try authorize(peerPid) + queue.sync { + if let key = sessionId { contexts[key] = approved } resetDaemonTimer() - return context } + return approved + } - // Need fresh auth (first time for this session, or always for unidentifiable callers) + return try queue.sync { let context = LAContext() context.touchIDAuthenticationAllowableReuseDuration = SessionManager.sessionTimeout @@ -116,6 +162,24 @@ final class SessionManager { } } + /// An explicit lock (menu bar, `varlock lock`): drop cached biometric contexts, + /// and erase every identity session regardless of its lock policy. + func handleSystemLock() { + invalidateAllSessions() + onSystemLock?() + } + + /// A system lock event. Cached biometric contexts always go, as they always + /// have. Identity sessions are judged one at a time against their own policy, + /// so a session set to survive screen lock does. + /// + /// The notification observers do nothing but call this, so the policy behavior + /// is testable without real sleep events. + func handleLockEvent(_ event: SessionLockEvent) { + invalidateAllSessions() + onLockEvent?(event) + } + /// Resets the daemon shutdown timer (no Touch ID). Call for any IPC so the /// process stays up while clients use ping, encrypt, etc., not only decrypt. func noteIpcActivity() { @@ -148,7 +212,13 @@ final class SessionManager { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + SessionManager.daemonInactivityTimeout) timer.setEventHandler { [weak self] in - self?.onDaemonTimeout?() + guard let self else { return } + // An open unlock session outranks idleness: re-arm rather than quit. + if self.hasLiveWork?() == true { + self.resetDaemonTimer() + return + } + self.onDaemonTimeout?() } timer.resume() daemonTimer = timer @@ -160,13 +230,15 @@ final class SessionManager { let workspace = NSWorkspace.shared let notificationCenter = workspace.notificationCenter - // Screen lock / sleep → invalidate ALL sessions + // The machine going to sleep is the one event treated as "sleep". Display + // sleep and fast user switching are screen-lock events: a display that + // sleeps after a couple of idle minutes must not read as the lid closing. notificationCenter.addObserver( forName: NSWorkspace.willSleepNotification, object: nil, queue: .main ) { [weak self] _ in - self?.invalidateAllSessions() + self?.handleLockEvent(.sleep) } notificationCenter.addObserver( @@ -174,7 +246,7 @@ final class SessionManager { object: nil, queue: .main ) { [weak self] _ in - self?.invalidateAllSessions() + self?.handleLockEvent(.screenLock) } notificationCenter.addObserver( @@ -182,16 +254,16 @@ final class SessionManager { object: nil, queue: .main ) { [weak self] _ in - self?.invalidateAllSessions() + self?.handleLockEvent(.screenLock) } - // Also invalidate when screens lock (available on macOS 13+) + // Also fire when screens lock (available on macOS 13+) DistributedNotificationCenter.default().addObserver( forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main ) { [weak self] _ in - self?.invalidateAllSessions() + self?.handleLockEvent(.screenLock) } } } diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionUnlockProbe.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionUnlockProbe.swift new file mode 100644 index 000000000..4d01d398b --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SessionUnlockProbe.swift @@ -0,0 +1,251 @@ +import Foundation +import CryptoKit +import LocalAuthentication +import IdentitySessions + +/// Proves, on real hardware, that one biometric scan covers a whole unlock. +/// +/// The two-key model only pays for itself if the daemon can drive the biometric +/// itself (`LAContext.evaluatePolicy`) and then hand that authenticated context to +/// the enclave operation without macOS raising a second sheet. That is a claim +/// about this machine and this OS version, not something a unit test can settle, +/// so it gets a probe. +/// +/// The probe does not ask a human to count sheets. `LAContext.interactionNotAllowed` +/// makes any operation that still wants UI fail instead of showing it, so a second +/// prompt turns into an error we can report. Exactly one scan is requested, in phase B. +/// +/// phase A (control): unauthenticated context, no interaction allowed +/// -> must FAIL, which is what proves the key is presence gated +/// phase B (handoff): authenticate once, then unwrap twice under that context +/// with no interaction allowed -> must SUCCEED +/// phase C (session): ephemeral no-presence session key -> must SUCCEED silently +/// +/// Run it with: `varlock-enclave probe-session-unlock [--key-id ]` +/// See the "Checking the single-scan unlock" section of the package README. +enum SessionUnlockProbe { + + struct PhaseResult { + let name: String + let expectation: String + let succeeded: Bool + let passed: Bool + let durationMs: Int + let error: String? + + var dictionary: [String: Any] { + var dict: [String: Any] = [ + "phase": name, + "expected": expectation, + "operationSucceeded": succeeded, + "passed": passed, + "durationMs": durationMs, + ] + if let error { dict["error"] = error } + return dict + } + } + + /// Sample plaintext standing in for the identity private key, so the probe needs + /// no identity file and touches no real key material. + private static let probePlaintext = Data("varlock-session-unlock-probe".utf8) + + static func run(keyId: String) -> [String: Any] { + guard SecureEnclaveManager.keyExists(keyId: keyId) else { + return [ + "verdict": "inconclusive", + "reason": "no Secure Enclave key \"\(keyId)\" on this machine; create one with generate-key first", + ] + } + + let wrapped: Data + do { + wrapped = try SecureEnclaveManager.encrypt(plaintext: probePlaintext, keyId: keyId) + } catch { + return ["verdict": "inconclusive", "reason": "could not encrypt probe payload: \(error.localizedDescription)"] + } + + var phases: [PhaseResult] = [] + + // Phase A: control. A presence-gated key must refuse an unauthenticated, + // non-interactive context. If this SUCCEEDS the key has no presence + // requirement (e.g. it was made with --no-auth) and the probe proves nothing. + let unauthenticated = LAContext() + unauthenticated.interactionNotAllowed = true + let phaseA = measure(name: "control-unauthenticated", expectation: "fail") { + _ = try SecureEnclaveManager.decrypt(payload: wrapped, keyId: keyId, context: unauthenticated) + } + phases.append(PhaseResult( + name: phaseA.name, expectation: phaseA.expectation, + succeeded: phaseA.succeeded, passed: !phaseA.succeeded, + durationMs: phaseA.durationMs, error: phaseA.error + )) + unauthenticated.invalidate() + + if phaseA.succeeded { + return [ + "verdict": "inconclusive", + "reason": "key \"\(keyId)\" does not require user presence, so a handoff cannot be observed; " + + "re-run against a key created without --no-auth", + "phases": phases.map(\.dictionary), + ] + } + + // Phase B: the handoff itself. One scan, then two enclave operations under + // the same authenticated context with UI refused. + let context = LAContext() + var policyName = "deviceOwnerAuthenticationWithBiometrics" + var policy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics + var canEvaluateError: NSError? + if !context.canEvaluatePolicy(policy, error: &canEvaluateError) { + policy = .deviceOwnerAuthentication + policyName = "deviceOwnerAuthentication" + var fallbackError: NSError? + guard context.canEvaluatePolicy(policy, error: &fallbackError) else { + return [ + "verdict": "inconclusive", + "reason": "no usable authentication policy: " + + (fallbackError?.localizedDescription ?? "unknown"), + "phases": phases.map(\.dictionary), + ] + } + } + + let authStart = Date() + let semaphore = DispatchSemaphore(value: 0) + var evalError: Error? + context.evaluatePolicy( + policy, + localizedReason: "unlock \(UnlockPanelContent.displayName(forKeyId: keyId))" + ) { success, error in + if !success { evalError = error } + semaphore.signal() + } + if semaphore.wait(timeout: .now() + IdentitySessionManager.biometricTimeoutSeconds) == .timedOut { + context.invalidate() + return [ + "verdict": "inconclusive", + "reason": "the biometric prompt timed out; nobody answered it", + "phases": phases.map(\.dictionary), + ] + } + if let evalError { + context.invalidate() + let nsError = evalError as NSError + return [ + "verdict": "inconclusive", + "reason": "authentication did not complete: \(evalError.localizedDescription)", + "authErrorCode": nsError.code, + "authErrorDomain": nsError.domain, + "hint": laErrorHint(code: nsError.code), + "phases": phases.map(\.dictionary), + ] + } + let authMs = Int(Date().timeIntervalSince(authStart) * 1000) + + // No further UI from here: a second sheet becomes an error instead. + context.interactionNotAllowed = true + + for attempt in 1...2 { + let result = measure(name: "handoff-unwrap-\(attempt)", expectation: "succeed") { + _ = try SecureEnclaveManager.decrypt(payload: wrapped, keyId: keyId, context: context) + } + phases.append(PhaseResult( + name: result.name, expectation: result.expectation, + succeeded: result.succeeded, passed: result.succeeded, + durationMs: result.durationMs, error: result.error + )) + } + context.invalidate() + + // Phase C: the session key. No presence flag, no context, no prompt. + let phaseC = measure(name: "session-key-silent-unwrap", expectation: "succeed") { + let sessionKey = try SecureEnclaveManager.createEphemeralSessionKey() + let sessionWrapped = try Ecies.encrypt( + plaintext: probePlaintext, + to: sessionKey.publicKey, + version: Ecies.devicePayloadVersion + ) + let reloaded = try SecureEnclave.P256.KeyAgreement.PrivateKey( + dataRepresentation: sessionKey.dataRepresentation + ) + let opened = try Ecies.decrypt( + payload: sessionWrapped, + using: reloaded, + acceptedVersions: [Ecies.devicePayloadVersion] + ) + guard opened == probePlaintext else { + throw EnclaveError.decryptionFailed("session key round trip returned different bytes") + } + } + phases.append(PhaseResult( + name: phaseC.name, expectation: phaseC.expectation, + succeeded: phaseC.succeeded, passed: phaseC.succeeded, + durationMs: phaseC.durationMs, error: phaseC.error + )) + + let allPassed = phases.allSatisfy(\.passed) + let handoffPassed = phases.filter { $0.name.hasPrefix("handoff-") }.allSatisfy(\.passed) + + return [ + "verdict": allPassed ? "single-scan" : (handoffPassed ? "partial" : "double-prompt"), + "scansRequested": 1, + "policy": policyName, + "authenticationMs": authMs, + "phases": phases.map(\.dictionary), + "interpretation": allPassed + ? "one scan covered the authentication and every enclave operation that followed" + : "an enclave operation still wanted its own prompt; see the failing phase", + ] + } + + // MARK: - Helpers + + /// Tell "the machine said no" apart from "nothing could show the prompt here". + /// A probe run over ssh, or from a process with no window server session, gets + /// cancelled without any human involved, and that is not evidence about the + /// handoff either way. + private static func laErrorHint(code: Int) -> String { + switch code { + case LAError.userCancel.rawValue: + return "the person dismissed the prompt; run it again and authenticate" + case LAError.systemCancel.rawValue, LAError.appCancel.rawValue: + return "the system withdrew the prompt, which usually means this process has no " + + "window server session (ssh, a headless agent shell). Re-run from a normal " + + "Terminal window logged into the desktop." + case LAError.notInteractive.rawValue: + return "this context refuses interaction entirely" + case LAError.biometryNotEnrolled.rawValue: + return "no biometrics enrolled on this Mac" + case LAError.biometryLockout.rawValue: + return "biometrics are locked out; unlock with the device password first" + default: + return "see LAError code \(code)" + } + } + + private struct Measured { + let name: String + let expectation: String + let succeeded: Bool + let durationMs: Int + let error: String? + } + + private static func measure(name: String, expectation: String, _ body: () throws -> Void) -> Measured { + let start = Date() + do { + try body() + return Measured( + name: name, expectation: expectation, succeeded: true, + durationMs: Int(Date().timeIntervalSince(start) * 1000), error: nil + ) + } catch { + return Measured( + name: name, expectation: expectation, succeeded: false, + durationMs: Int(Date().timeIntervalSince(start) * 1000), + error: error.localizedDescription + ) + } + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/StatusBarMenu.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/StatusBarMenu.swift index e40374f41..074d17faf 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/StatusBarMenu.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/StatusBarMenu.swift @@ -1,56 +1,119 @@ import AppKit +import IdentitySessions /// Manages the macOS menu bar status item for the Varlock Enclave daemon. +/// +/// The icon is the passive part: closed lock when the daemon is holding nothing, +/// open lock while any session is unlocked. Everything else is built when the +/// menu opens, from `SessionMenuModel`, which is where the wording lives and +/// where it is tested. Nothing here ticks: an open menu is a snapshot, and the +/// next one is a fresh read. final class StatusBarMenu: NSObject, NSMenuDelegate { + + /// What the menu needs from the rest of the daemon. Closures rather than the + /// managers themselves, so this file never reaches into session state. + struct Actions { + /// Live grants, re-read every time the menu opens. + var liveGrants: () -> [SessionGrantInfo] + /// Erase everything, whatever each session's policy says. + var lockAll: () -> Void + /// Erase one session. + var lockSession: (String) -> Void + /// The machine-wide default, as the config file currently has it. + var currentLockPolicy: () -> SessionLockPolicy + /// Write the machine-wide default back. Throws so a config file that + /// cannot be edited is reported rather than silently ignored. + var setLockPolicy: (SessionLockPolicy) throws -> Void + var quit: () -> Void + } + private var statusItem: NSStatusItem? private let menu = NSMenu() private let sessionManager: SessionManager - private let onLock: () -> Void - private let onQuit: () -> Void - - init( - sessionManager: SessionManager, - onLock: @escaping () -> Void, - onQuit: @escaping () -> Void - ) { + private let actions: Actions + + init(sessionManager: SessionManager, actions: Actions) { self.sessionManager = sessionManager - self.onLock = onLock - self.onQuit = onQuit + self.actions = actions super.init() setupStatusItem() } private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) guard let button = statusItem?.button else { return } updateIcon() - let menuTitle = Bundle.main.object(forInfoDictionaryKey: "VarlockMenuTitle") as? String ?? "Varlock Secure Enclave" - button.toolTip = menuTitle + button.toolTip = Self.menuTitle menu.delegate = self statusItem?.menu = menu rebuildMenuItems() } + private static var menuTitle: String { + return Bundle.main.object(forInfoDictionaryKey: "VarlockMenuTitle") as? String ?? "Varlock Secure Enclave" + } + + // MARK: - Icon + + /// Sessions the icon reflects: identity unlock sessions, plus the older + /// cached-biometric sessions, since both mean the daemon is holding something. + private func liveSessionCount() -> Int { + let identitySessions = Set(actions.liveGrants().map(\.sessionId)).count + if identitySessions > 0 { return identitySessions } + return sessionManager.hasAnySessions() ? 1 : 0 + } + private func updateIcon() { guard let button = statusItem?.button else { return } - let hasActiveSessions = sessionManager.hasAnySessions() - let iconName = hasActiveSessions ? "varlock-menu-unlocked" : "varlock-menu-locked" - if let iconURL = Bundle.main.url(forResource: iconName, withExtension: "pdf"), - let image = NSImage(contentsOf: iconURL) { - image.isTemplate = true - image.size = NSSize(width: 18, height: 18) + let count = liveSessionCount() + + if let image = Self.icon(unlocked: count > 0) { button.image = image - button.title = "" } else { - // Fallback to emoji if PDF icons are missing button.image = nil - button.title = hasActiveSessions ? "🔓" : "🔒" } + // A count only when there is more than one thing to count. One open + // session is what the open lock already says. + button.title = count > 1 ? " \(count)" : "" + button.imagePosition = button.title.isEmpty ? .imageOnly : .imageLeading } - // NSMenuDelegate — update items and icon each time the menu opens + /// Our own mark first, then a plain lock symbol as the fallback. + /// + /// The released daemon on main loads this art PDF-first and shows it fine; + /// this branch's menu bar rewrite briefly inverted the order (symbol first, + /// and a system symbol always exists), which turned the mark into a generic + /// padlock until it was put back. The artwork is a template image, so it + /// still follows the menu bar's own light and dark treatment rather than + /// fighting it. + /// + /// Resolved through `PanelIcons`, which also finds resources in the source + /// tree, so a development build shows the real icon instead of quietly + /// falling back. + private static func icon(unlocked: Bool) -> NSImage? { + let resourceName = unlocked ? "varlock-menu-unlocked" : "varlock-menu-locked" + if let image = PanelIcons.bundledResource(named: resourceName, extension: "pdf") { + image.isTemplate = true + image.size = NSSize(width: 18, height: 18) + return image + } + // Worth saying out loud: a silent fallback is how the mark went missing + // on this branch without anyone noticing. + PanelDebug.note("menu-icon-fallback", ["missing": resourceName]) + let symbolName = unlocked ? "lock.open.fill" : "lock.fill" + guard let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: menuTitle) else { + return nil + } + symbol.isTemplate = true + return symbol + } + + // MARK: - Menu + + // NSMenuDelegate: everything is recomputed each time the menu opens, which is + // also why no timer is needed to keep the times honest. func menuWillOpen(_ menu: NSMenu) { updateIcon() rebuildMenuItems() @@ -59,47 +122,129 @@ final class StatusBarMenu: NSObject, NSMenuDelegate { private func rebuildMenuItems() { menu.removeAllItems() - // Header - let menuTitle = Bundle.main.object(forInfoDictionaryKey: "VarlockMenuTitle") as? String ?? "Varlock Secure Enclave" - let headerItem = NSMenuItem(title: menuTitle, action: nil, keyEquivalent: "") - headerItem.isEnabled = false - menu.addItem(headerItem) - + addDisabledItem(Self.menuTitle, to: menu) menu.addItem(NSMenuItem.separator()) - // Lock action — disabled with status text when already locked - let hasActiveSessions = sessionManager.hasAnySessions() - if hasActiveSessions { - let lockItem = NSMenuItem(title: "Lock all sessions", action: #selector(lockClicked), keyEquivalent: "") - lockItem.target = self - menu.addItem(lockItem) + let model = SessionMenuModel.build(from: actions.liveGrants()) + if model.isEmpty { + addDisabledItem("No unlocked sessions", to: menu) } else { - let lockedItem = NSMenuItem(title: "Locked", action: nil, keyEquivalent: "") - lockedItem.isEnabled = false - menu.addItem(lockedItem) + for row in model.rows { + menu.addItem(sessionItem(for: row)) + } } menu.addItem(NSMenuItem.separator()) - // Quit + // Lock All stays enabled while the older cached-biometric sessions exist + // even with no identity grants, since it drops those too. + let lockAll = NSMenuItem(title: "Lock All", action: #selector(lockAllClicked), keyEquivalent: "") + lockAll.target = self + lockAll.isEnabled = !model.isEmpty || sessionManager.hasAnySessions() + menu.addItem(lockAll) + + menu.addItem(lockPolicyItem()) + + menu.addItem(NSMenuItem.separator()) + let quitItem = NSMenuItem(title: "Quit Daemon", action: #selector(quitClicked), keyEquivalent: "") quitItem.target = self menu.addItem(quitItem) } - @objc private func lockClicked() { - onLock() + /// One session, as a submenu: what it holds, how long it has, what ends it, + /// and a way to end it now. + private func sessionItem(for row: SessionMenuModel.SessionRow) -> NSMenuItem { + let item = NSMenuItem(title: row.title, action: nil, keyEquivalent: "") + let submenu = NSMenu() + + for key in row.keys { + addDisabledItem(key.title, to: submenu) + } + submenu.addItem(NSMenuItem.separator()) + addDisabledItem(row.capLine, to: submenu) + addDisabledItem(row.lockLine, to: submenu) + submenu.addItem(NSMenuItem.separator()) + + let lockItem = NSMenuItem(title: "Lock This Session", action: #selector(lockSessionClicked(_:)), keyEquivalent: "") + lockItem.target = self + lockItem.representedObject = row.sessionId + submenu.addItem(lockItem) + + item.submenu = submenu + return item + } + + /// The machine-wide default. Per-session overrides are shown on the rows + /// above and are not editable here: a session's policy was fixed when it was + /// unlocked, and changing it from a menu would rewrite a decision the user + /// already approved. + private func lockPolicyItem() -> NSMenuItem { + let item = NSMenuItem(title: "Lock Sessions On", action: nil, keyEquivalent: "") + let submenu = NSMenu() + let current = actions.currentLockPolicy() + + for policy in [SessionLockPolicy.screenLock, .sleep, .never] { + let choice = NSMenuItem( + title: SessionMenuModel.lockPolicyMenuLabel(policy), + action: #selector(lockPolicyChosen(_:)), + keyEquivalent: "" + ) + choice.target = self + choice.representedObject = policy.rawValue + choice.state = policy == current ? .on : .off + submenu.addItem(choice) + } + + submenu.addItem(NSMenuItem.separator()) + addDisabledItem("Applies to new sessions", to: submenu) + + item.submenu = submenu + return item + } + + private func addDisabledItem(_ title: String, to menu: NSMenu) { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } + + // MARK: - Actions + + @objc private func lockAllClicked() { + actions.lockAll() updateIcon() } + @objc private func lockSessionClicked(_ sender: NSMenuItem) { + guard let sessionId = sender.representedObject as? String else { return } + actions.lockSession(sessionId) + updateIcon() + } + + @objc private func lockPolicyChosen(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let policy = SessionLockPolicy(wireValue: raw) else { return } + do { + try actions.setLockPolicy(policy) + } catch { + let alert = NSAlert() + alert.messageText = "Could not save the lock setting" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } + } + @objc private func quitClicked() { - onQuit() + actions.quit() } /// Call from any thread after a session state change to update the icon func refresh() { - // Use performSelector to ensure the update runs in the next run loop iteration - // on the main thread — more reliable than DispatchQueue.main.async with NSApplication + // Use performSelector to ensure the update runs in the next run loop + // iteration on the main thread: more reliable than DispatchQueue.main.async + // with NSApplication. performSelector(onMainThread: #selector(doRefresh), with: nil, waitUntilDone: false) } diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/TouchIDGlyphView.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/TouchIDGlyphView.swift new file mode 100644 index 000000000..05fc58eab --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/TouchIDGlyphView.swift @@ -0,0 +1,173 @@ +import AppKit +import IdentitySessions + +/// The panel's own Touch ID glyph, and the only thing on the panel that moves. +/// +/// It exists because the system's `LAAuthenticationView` draws nothing here, so +/// without this the panel would show an empty square while asking for a +/// fingerprint. The system view is layered over this one, so if it ever does +/// render, its animation wins and ours is never seen. +/// +/// This type only turns a `PanelGlyphEffect` into animation. Which effect applies +/// is decided in `PanelGlyph`, from the flow's state, so the glyph cannot end up +/// breathing at a moment when nothing is actually listening to the sensor. +/// +/// Core Animation throughout rather than `NSSymbolEffect`, which needs macOS 14 +/// while this package targets 13. One mechanism that works everywhere beats two +/// that have to be kept in step. +final class TouchIDGlyphView: NSView { + /// Resting colour. The system's own Touch ID art is in the pink and red + /// family, so ours matches rather than using the accent colour, which was + /// never a brand decision. One line to change if that turns out to be wrong. + static let restingColor: NSColor = .systemPink + static let successColor: NSColor = .systemGreen + + /// How faint the glyph sits when nothing is armed. Carries the whole + /// idle-versus-armed distinction when motion is turned off. + static let restingOpacity: Float = 0.6 + + private let imageView = NSImageView() + private var currentEffect: PanelGlyphEffect = .still + /// The colour the glyph rests and breathes in. Overridden when the glyph + /// sits on a coloured button, where the system's pink would be unreadable. + private var baseTint: NSColor = TouchIDGlyphView.restingColor + + private enum AnimationKey { + static let pulse = "varlock.glyph.pulse" + static let shake = "varlock.glyph.shake" + static let pop = "varlock.glyph.pop" + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + wantsLayer = true + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.image = NSImage(systemSymbolName: "touchid", accessibilityDescription: "Touch ID") + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.contentTintColor = baseTint + imageView.wantsLayer = true + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + /// Scale has to grow from the middle, and an AppKit backing layer does not + /// necessarily anchor there. Fixing it on layout keeps the pop centred. + override func layout() { + super.layout() + guard let layer = imageView.layer else { return } + let centre = CGPoint(x: 0.5, y: 0.5) + if layer.anchorPoint != centre { + layer.anchorPoint = centre + layer.position = CGPoint(x: imageView.bounds.midX, y: imageView.bounds.midY) + } + } + + /// Draw in a different colour, for a glyph on a coloured background. + func setBaseTint(_ color: NSColor) { + baseTint = color + imageView.contentTintColor = color + } + + func apply(_ effect: PanelGlyphEffect) { + guard effect != currentEffect || effect == .shakeThenStill else { return } + currentEffect = effect + guard let layer = imageView.layer else { return } + layer.removeAnimation(forKey: AnimationKey.pulse) + + switch effect { + case .still: + // Dim: nothing is listening. Without motion this is the only thing + // separating "idle" from "armed", so the two must not look alike. + imageView.contentTintColor = baseTint + layer.opacity = Self.restingOpacity + case .armedStill: + // Reduce motion: say "listening" with full strength rather than + // movement. + imageView.contentTintColor = baseTint + layer.opacity = 1 + case .pulse: + imageView.contentTintColor = baseTint + layer.opacity = 1 + startPulse(on: layer) + case .shakeThenStill: + imageView.contentTintColor = baseTint + layer.opacity = 1 + shake(layer) + case .failedStill: + imageView.contentTintColor = .secondaryLabelColor + layer.opacity = 1 + case .successPop: + imageView.contentTintColor = Self.successColor + layer.opacity = 1 + pop(layer) + case .successStill: + imageView.contentTintColor = Self.successColor + layer.opacity = 1 + } + } + + // MARK: - The animations + + /// Slow breathing, well short of a flash. This sits in a floating panel that + /// may be on screen for a while, so it stays quiet enough to ignore. + private func startPulse(on layer: CALayer) { + let opacity = CABasicAnimation(keyPath: "opacity") + opacity.fromValue = 1.0 + opacity.toValue = 0.55 + + let scale = CABasicAnimation(keyPath: "transform.scale") + scale.fromValue = 1.0 + scale.toValue = 1.06 + + let group = CAAnimationGroup() + group.animations = [opacity, scale] + group.duration = 1.1 + group.autoreverses = true + group.repeatCount = .infinity + group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + layer.add(group, forKey: AnimationKey.pulse) + } + + /// The short horizontal shake the system uses for a rejected fingerprint. + private func shake(_ layer: CALayer) { + let shake = CAKeyframeAnimation(keyPath: "transform.translation.x") + shake.values = [0, -6, 6, -4, 4, -2, 2, 0] + shake.keyTimes = [0, 0.12, 0.28, 0.44, 0.6, 0.76, 0.9, 1] + shake.duration = 0.42 + shake.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(shake, forKey: AnimationKey.shake) + } + + /// A small confirmation before the panel closes, so an approval reads as + /// finished rather than as the window disappearing. + private func pop(_ layer: CALayer) { + let pop = CAKeyframeAnimation(keyPath: "transform.scale") + pop.values = [1.0, 1.18, 1.0] + pop.keyTimes = [0, 0.45, 1] + pop.duration = 0.28 + pop.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(pop, forKey: AnimationKey.pop) + } + + /// How long the success animation wants before the panel closes over it. + static let successHoldSeconds: TimeInterval = 0.32 + + /// Whether the system asks us not to animate. + static var reduceMotion: Bool { + return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UiAvailability.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UiAvailability.swift new file mode 100644 index 000000000..9787e7568 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UiAvailability.swift @@ -0,0 +1,57 @@ +import Foundation +import AppKit +import CoreGraphics + +/// Whether this process can actually put a window in front of a human. +/// +/// The daemon is the trusted display, so an approval it cannot draw is an +/// approval it must refuse. Over SSH, or from a launchd job with no console +/// session, there is no window server to draw on: the honest answer is to fail +/// with `NO_UI` and let the client fall back to telling the user in the terminal. +/// Silently skipping the panel would turn a consent surface into a rubber stamp. +enum UiAvailability { + /// Forces the answer to "no window server" for tests and for the headless + /// end-to-end run. It can only ever make the daemon refuse more, never less, + /// so it is safe to honour from the environment. + static let modeEnvVar = "_VARLOCK_UI_MODE" + + /// Treats every unlock as if its key were presence gated, so the panel path + /// can be exercised against a `--no-auth` key in tests. Also strictly a + /// tightening: it adds a question, it never removes one. + static let forcePromptEnvVar = "_VARLOCK_FORCE_UNLOCK_PROMPT" + + static var isHeadlessForced: Bool { + return ProcessInfo.processInfo.environment[modeEnvVar] == "headless" + } + + static var isPromptForced: Bool { + let value = ProcessInfo.processInfo.environment[forcePromptEnvVar] + return value == "1" || value == "true" + } + + /// Turns the panel's inline Touch ID prompt off, sending it back to the system + /// dialog raised by the panel's own button. + /// + /// The inline prompt is the shipped default, and now literally so: the panel + /// hosts `LAAuthenticationView` and the scan happens in our own window, with + /// no system alert over the top. `scripts/sign-probe.ts` established that it + /// renders under every signature we can produce, which is what settled an + /// earlier arc that had concluded the opposite. + /// + /// The hatch stays because the inline view is the one piece whose behaviour + /// depends on how the process was launched, and an escape that needs no + /// rebuild is worth having. Setting it to `0` costs a gesture and a system + /// dialog; it never weakens the check. + static let embeddedPromptEnvVar = "_VARLOCK_EMBEDDED_PROMPT" + + static var embeddedPromptEnabled: Bool { + let value = ProcessInfo.processInfo.environment[embeddedPromptEnvVar] + return !(value == "0" || value == "false") + } + + /// True when there is a graphical login session attached to this process. + static func canShowUi() -> Bool { + if isHeadlessForced { return false } + return CGSessionCopyCurrentDictionary() != nil + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UnlockPreferenceStore.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UnlockPreferenceStore.swift new file mode 100644 index 000000000..8f89446c5 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/UnlockPreferenceStore.swift @@ -0,0 +1,90 @@ +import Foundation +import IdentitySessions + +/// Where the remembered narrowings live on disk. +/// +/// Under the user's varlock directory, beside the identities, the audit log and +/// the machine config. Never in a project: what this machine will hand over is +/// this machine's business, and a preference committed to a repository is a +/// preference anyone who can open a pull request gets to set. +/// +/// Read fresh at every panel, like the machine config, so deleting the file (or +/// a `varlock lock --forget-preferences`) takes effect on the next unlock with +/// no daemon restart. +enum UnlockPreferenceStore { + static var filePath: String { + return IdentityStore.userVarlockDir + "/" + UnlockPreferences.fileName + } + + static func load() -> [String: UnlockNarrowing] { + guard let data = FileManager.default.contents(atPath: filePath) else { return [:] } + return UnlockPreferences.decode(data) + } + + /// What is remembered for one project and key, if anything. + static func narrowing(projectPath: String?, keyId: String) -> UnlockNarrowing? { + guard let rowKey = UnlockPreferences.rowKey(projectPath: projectPath, keyId: keyId) else { return nil } + return load()[rowKey] + } + + /// Fold an approval in: remember what was tightened, forget what was not. + /// + /// Best effort. A preference that cannot be written costs the next panel a + /// slightly broader preselection, which is a nuisance; failing an unlock the + /// user already approved over it would be worse. + /// - Parameter breadth: nil where the user was never offered the choice + /// (`once` draws no checkbox), which leaves that axis exactly as it was. + static func record( + projectPath: String?, + keyIds: [String], + breadth: SessionGrantBreadth?, + window: GrantWindow + ) { + guard projectPath != nil, !keyIds.isEmpty else { return } + var rows = load() + let now = Int64(Date().timeIntervalSince1970 * 1000) + for keyId in keyIds { + rows = UnlockPreferences.apply( + rows: rows, + rowKey: UnlockPreferences.rowKey(projectPath: projectPath, keyId: keyId), + breadth: breadth, + window: window, + now: now + ) + } + write(rows) + } + + /// Forget rows. No arguments forgets everything on this machine. + @discardableResult + static func forget(projectPath: String? = nil, keyId: String? = nil) -> Int { + let result = UnlockPreferences.forget(rows: load(), projectPath: projectPath, keyId: keyId) + write(result.rows) + return result.forgotten + } + + private static func write(_ rows: [String: UnlockNarrowing]) { + guard let data = UnlockPreferences.encode(rows) else { return } + let directory = (filePath as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory( + atPath: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + // Written through a temporary file so a crash mid-write cannot leave a + // half-parsed preferences file behind. A file that fails to parse is + // treated as empty, which is safe, but it would also silently throw away + // every narrowing the user had chosen. + let temporary = filePath + ".tmp" + guard FileManager.default.createFile( + atPath: temporary, + contents: data, + attributes: [.posixPermissions: 0o600] + ) else { return } + _ = try? FileManager.default.replaceItemAt( + URL(fileURLWithPath: filePath), + withItemAt: URL(fileURLWithPath: temporary) + ) + try? FileManager.default.removeItem(atPath: temporary) + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/WindowPixels.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/WindowPixels.swift new file mode 100644 index 000000000..f29ef9b8b --- /dev/null +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/WindowPixels.swift @@ -0,0 +1,101 @@ +import AppKit +import CoreGraphics + +/// Did that view actually draw anything? +/// +/// Counting subviews and layer contents does not answer it: `LAAuthenticationView` +/// has both whether or not a fingerprint appears, which is how an earlier round of +/// this investigation talked itself into "it renders" while a person was looking +/// at an empty square. The only honest answer comes from the pixels. +/// +/// So this photographs our own window and counts how many distinct greys a region +/// contains. A blank region is one flat colour; anything Apple actually drew is +/// dozens. That turns "does the inline prompt render" from a question only eyes +/// can answer into one a bisection can run unattended. +/// +/// Needs screen-recording permission to see window contents, which is reported +/// alongside the count rather than assumed: a zero from a denied permission and a +/// zero from a blank view must never look the same. +enum WindowPixels { + struct Sample { + /// Distinct luminance values found. 0 means nothing could be read. + let distinctGreys: Int + /// Whether the system let us photograph the window at all. + let permitted: Bool + /// What was measured, in window coordinates, for the record. + let rect: String + + var asDictionary: [String: Any] { + return ["distinctGreys": distinctGreys, "screenCapturePermitted": permitted, "rect": rect] + } + + /// Enough variation that something was drawn. A flat fill and a one-pixel + /// border both stay well under this. + var looksDrawn: Bool { permitted && distinctGreys >= 8 } + } + + /// Photograph `view`'s area of its own window and count the greys in it. + static func sample(_ view: NSView) -> Sample { + let permitted = CGPreflightScreenCaptureAccess() + guard let window = view.window else { + return Sample(distinctGreys: 0, permitted: permitted, rect: "") + } + let inWindow = view.convert(view.bounds, to: nil) + let rectLabel = "\(Int(inWindow.origin.x)),\(Int(inWindow.origin.y)) " + + "\(Int(inWindow.width))x\(Int(inWindow.height))" + guard permitted, inWindow.width > 1, inWindow.height > 1 else { + return Sample(distinctGreys: 0, permitted: permitted, rect: rectLabel) + } + + let windowId = CGWindowID(window.windowNumber) + guard let image = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowId, + [.boundsIgnoreFraming, .nominalResolution] + ) else { + return Sample(distinctGreys: 0, permitted: permitted, rect: rectLabel) + } + + // The captured image is top-left origin; AppKit window coordinates are + // bottom-left. Flipping here rather than at the call site keeps the + // caller's rect in the coordinates it already thinks in. + let scale = CGFloat(image.width) / max(window.frame.width, 1) + let flippedY = window.frame.height - inWindow.origin.y - inWindow.height + let cropRect = CGRect( + x: inWindow.origin.x * scale, + y: flippedY * scale, + width: inWindow.width * scale, + height: inWindow.height * scale + ).integral + guard let cropped = image.cropping(to: cropRect) else { + return Sample(distinctGreys: 0, permitted: permitted, rect: rectLabel) + } + + return Sample( + distinctGreys: countGreys(in: cropped), + permitted: permitted, + rect: rectLabel + ) + } + + private static func countGreys(in image: CGImage) -> Int { + let width = min(image.width, 96) + let height = min(image.height, 96) + guard width > 0, height > 0 else { return 0 } + + var pixels = [UInt8](repeating: 0, count: width * height) + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return 0 } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return Set(pixels).count + } +} diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 3cf10dc67..02288d502 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -1,5 +1,7 @@ import Foundation import AppKit +import IdentitySessions +import SessionScoping // MARK: - JSON Output Helpers @@ -27,6 +29,26 @@ func jsonSuccess(_ result: [String: Any]) -> Never { _exit(0) } +/// Attach a stable code to identity/session errors so the TS client can branch on +/// them (re-unlock, create an identity, upgrade varlock) without matching on text. +func identityErrorResponse(_ error: Error) -> [String: Any] { + var response: [String: Any] = ["error": error.localizedDescription] + if let grantError = error as? SessionGrantError { + response["errorCode"] = grantError.code + } else if let storeError = error as? IdentityStore.IdentityStoreError { + response["errorCode"] = storeError.code + } else if let sessionError = error as? IdentitySessionManager.IdentitySessionError { + response["errorCode"] = sessionError.code + } else if let approvalError = error as? ApprovalRequest.ParseError { + response["errorCode"] = approvalError.code + } else if let auditError = error as? AuthorizationAuditError { + response["errorCode"] = auditError.code + } else if let eciesError = error as? Ecies.EciesError { + response["errorCode"] = eciesError.code + } + return response +} + func keychainErrorResponse(_ error: Error) -> [String: Any] { if let keychainError = error as? KeychainError { return [ @@ -50,19 +72,113 @@ func getArg(_ flag: String) -> String? { let defaultKeyId = "varlock-default" let noAuth = args.contains("--no-auth") // CI mode: skip biometric requirement +/// IPC protocol version reported by `ping`. +/// +/// 1 (reported as absent) is the original action set. 2 adds the identity session +/// ops: unlock-session, decrypt-v2, list-sessions, and the per-session form of +/// invalidate-session. 3 adds the daemon-drawn approval panel: unlock-session can +/// now answer APPROVAL_DENIED or NO_UI, and request-approval exists. +let daemonProtocolVersion = 3 + +/// Who is asking, read off the peer rather than taken from the message. +/// +/// The summary is the flattened line the audit log and the biometric prompt use; +/// the panel draws the chain. Both are derived here, so neither can be dressed +/// up by a caller. +func panelRequesterForPid(_ pid: pid_t?) -> PanelRequester { + guard let pid else { + return PanelRequester(summary: "Requested by an unidentified process") + } + let described = describeRequester(forPid: pid) + // Best effort and bounded: a chain that could not be read costs the panel + // its detail, never its appearance. The timing is recorded because that is + // the failure worth catching, and it is invisible from the outside. + let startedAt = Date() + let chain = ExecutionChainBuilder().build(forPid: pid) + PanelDebug.note("requester-chain", [ + "hops": chain.hops.count, + "agent": chain.agentSession?.productName ?? "", + "ms": Int(Date().timeIntervalSince(startedAt) * 1000), + ]) + return PanelRequester( + summary: described.summaryLine, + details: described.detailLines.map { .derived($0) }, + chain: chain + ) +} + +/// The panel for a device-key read, which is the pre-identity payload format. +/// +/// One builder for the daemon and for `panel-preview`, so the picture a designer +/// looks at is the window a user gets. What it says, and why none of it is a +/// control, is `LegacyDeviceKeyPanel`. +func legacyDeviceKeyPanelContent(requester: PanelRequester) -> PanelContent { + return PanelContent( + titleSegments: [.plain("Unlock "), .code(UnlockPanelContent.defaultKeyDisplayName)], + subtitle: nil, + requester: requester, + keyRows: [PanelKeyRow( + keyId: defaultKeyId, + displayName: UnlockPanelContent.defaultKeyDisplayName + )], + // Both derived from the constant this path actually hands macOS, so the + // panel cannot end up describing a window nobody grants. + notes: [LegacyDeviceKeyPanel.formatNote(reuse: SessionManager.sessionTimeout)], + windowFactLine: LegacyDeviceKeyPanel.windowFactLine(reuse: SessionManager.sessionTimeout), + factLine: "Recorded to the audit log", + // `once` is what this path can OFFER, since there is no grant table + // behind it to hold anything longer. It is not what approving GRANTS, + // which is why the window is stated above rather than drawn as the + // ladder's narrowest rung. + scopes: [.once], + defaultScope: .once, + confirmButtonTitle: "Unlock" + ) +} + +/// One line naming the peer, for the authorization log. Same derivation as the +/// panel's, flattened. +func requesterSummaryForPid(_ pid: pid_t?) -> String? { + guard let pid else { return nil } + return describeRequester(forPid: pid).auditSummary +} + switch command { // MARK: - generate-key case "generate-key": let keyId = getArg("--key-id") ?? defaultKeyId + // A key that asks every time never receives a lasting grant: every batch of + // decrypts costs a fresh approval and a fresh scan. + let authEveryTime = args.contains("--auth-every-time") + + if authEveryTime && noAuth { + jsonError("--auth-every-time and --no-auth ask for opposite things") + } + + // First gated key on this machine: say what is being set up before macOS + // starts asking for fingerprints. Once, ever. + FirstRunSetup.showIfNeeded(requireAuth: !noAuth) do { let pubKeyData = try SecureEnclaveManager.generateKey(keyId: keyId, requireAuth: !noAuth) + // Always written now, not just for --auth-every-time. The access-control + // flag set above cannot be read back off a stored key, so this sidecar is + // the only record that a --no-auth key carries no presence gate, and + // `status` needs it to tell the TS side how to route decrypts. + try KeyAuthPolicyStore.write( + record: KeyAuthRecord( + policy: authEveryTime ? .everyTime : .standard, + requireAuth: !noAuth + ), + for: keyId + ) jsonSuccess([ "keyId": keyId, "publicKey": pubKeyData.base64EncodedString(), "publicKeyBytes": pubKeyData.count, + "authMode": (authEveryTime ? KeyAuthPolicy.everyTime : KeyAuthPolicy.standard).rawValue, ]) } catch { jsonError(error.localizedDescription) @@ -73,6 +189,7 @@ case "generate-key": case "delete-key": let keyId = getArg("--key-id") ?? defaultKeyId let deleted = SecureEnclaveManager.deleteKey(keyId: keyId) + KeyAuthPolicyStore.remove(for: keyId) jsonSuccess(["keyId": keyId, "deleted": deleted]) // MARK: - list-keys @@ -140,6 +257,193 @@ case "decrypt": jsonError(error.localizedDescription) } +// MARK: - probe-session-unlock (manual, needs a real Mac + enrolled biometrics) + +case "probe-session-unlock": + let probeKeyId = getArg("--key-id") ?? defaultKeyId + jsonSuccess(SessionUnlockProbe.run(keyId: probeKeyId)) + +// MARK: - probe-embedded-unlock (manual, needs a real Mac + enrolled biometrics) + +case "probe-laright": + jsonSuccess(LARightProbe.run( + verbose: args.contains("--verbose"), + timeoutSeconds: TimeInterval(getArg("--timeout").flatMap { Double($0) } ?? 60), + custodyOnly: args.contains("--custody-only") + )) + +case "probe-embedded-unlock": + let embeddedProbeKeyId = getArg("--key-id") ?? defaultKeyId + let embeddedProbeTimeout = TimeInterval(getArg("--timeout").flatMap { Double($0) } ?? 60) + jsonSuccess(EmbeddedUnlockProbe.run( + keyId: embeddedProbeKeyId, + verbose: args.contains("--verbose"), + timeoutSeconds: embeddedProbeTimeout + )) + +// MARK: - panel-preview (design tool, draws the panel without asking anything) + +case "panel-preview": + // The panel's correctness is visual, and the real one is modal, floating, + // and impossible to look at on a headless session. This draws the same view + // tree to a PNG. Nothing is unlocked, no key is touched, and no presence + // check runs; the plan is built from the file so every state the panel has + // (delta, strict keys, no biometrics) can be inspected on demand. + guard let outPath = getArg("--out") else { + jsonError("panel-preview needs --out ") + } + let previewPayload: [String: Any] + if let payloadPath = getArg("--payload") { + guard let data = FileManager.default.contents(atPath: payloadPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + jsonError("Could not read a JSON payload from \(payloadPath)") + } + previewPayload = parsed + } else { + previewPayload = ["keyIds": [defaultKeyId]] + } + + let previewApp = NSApplication.shared + previewApp.setActivationPolicy(.accessory) + + let previewKeyIds = UnlockRequestKeys.from(payload: previewPayload) + let previewDisplay = UnlockDisplayInfo.from(payload: previewPayload) + let strictKeyIds = Set((previewPayload["strictKeyIds"] as? [String]) ?? []) + let coveredKeyIds = Set((previewPayload["coveredKeyIds"] as? [String]) ?? []) + // Item digests, so the breadth choice can be looked at. A preview has no + // real ciphertexts, so a payload may instead say how many items each key + // brought and the preview stands in synthetic digests for them: the panel + // only ever counts them, and nothing here is granted or decrypted. + let previewItems = UnlockRequestItems.from(payload: previewPayload) + let previewItemCounts = (previewPayload["itemDigestCounts"] as? [String: Any]) ?? [:] + func previewDigests(_ keyId: String) -> Set { + if let real = previewItems[keyId] { return real } + guard let count = (previewItemCounts[keyId] as? NSNumber)?.intValue, count > 0 else { return [] } + return Set((0.. [String: Any] in + var detail: [String: Any] = [ + "keyId": keyId, + "requireAuth": KeyAuthPolicyStore.record(for: keyId).requireAuth, + "protection": "secure-enclave", + ] + if let createdAt = SecureEnclaveManager.keyCreatedAt(keyId: keyId) { + detail["createdAt"] = createdAt + } + return detail + }, ]) // MARK: - daemon @@ -176,8 +496,84 @@ case "daemon": } let sessionManager = SessionManager() + let identitySessions = IdentitySessionManager() let server = IPCServer(socketPath: socketPath) + // The panel is drawn by this process on purpose: the daemon is the one that + // verified the peer and holds the keys, so it is the only party that can say + // truthfully who is asking. + identitySessions.promptHandler = { content, reason, attempt in + guard let outcome = ApprovalPanel.present( + content: content, + presenceReason: reason, + attempt: attempt + ) else { return .noUi } + return outcome.decision.approved + ? .approved(outcome.decision, outcome.proof) + : .denied + } + identitySessions.keyPolicy = { keyId in KeyAuthPolicyStore.policy(for: keyId) } + // Where "has Touch ID been set up for varlock here" is recorded. Kept out of + // the manager so it never has to know about the file system. + identitySessions.needsBiometricSetup = { BiometricSetupStore.needsSetup() } + identitySessions.recordBiometricSetup = { BiometricSetupStore.markSetupComplete() } + + /// Approval for a device-key read (the pre-identity payload format). + /// + /// These values are as sensitive as any other, and until now this path went + /// straight to `evaluatePolicy`, which is the system sheet with nothing + /// behind it. It now draws the same panel every other release goes through, + /// so there is no path to a secret that does not say who is asking first. + sessionManager.authorize = { [weak identitySessions] peerPid in + guard let identitySessions else { + throw IdentitySessionManager.IdentitySessionError.noUi + } + // Setup first and alone, exactly as an identity unlock does it. + try identitySessions.runBiometricSetupIfNeeded() + + let content = legacyDeviceKeyPanelContent(requester: panelRequesterForPid(peerPid)) + let attempt = identitySessions.beginPresence() + // The sheet says what the panel says, in the shortest form that is true. + guard let outcome = ApprovalPanel.present( + content: content, + presenceReason: content.presenceReason, + attempt: attempt + ) else { + attempt?.context.invalidate() + throw IdentitySessionManager.IdentitySessionError.noUi + } + guard outcome.decision.approved else { + outcome.proof?.context.invalidate() + throw IdentitySessionManager.IdentitySessionError.approvalDenied + } + guard let proof = outcome.proof else { + // Approved with no presence check behind it. Nothing here may open a + // key on the strength of a click alone. + throw IdentitySessionManager.IdentitySessionError.biometricFailed( + "The approval completed without a presence check" + ) + } + return proof.context + } + + // Never idle-quit while the daemon is holding an identity key for someone. + // Session state is memory-only, so quitting would silently cost them their + // unlock; the idle timer only applies when nothing is held. + sessionManager.hasLiveWork = { + identitySessions.hasLiveSessions() + } + + // An explicit lock erases every identity session, whatever their lock policy. + sessionManager.onSystemLock = { + identitySessions.invalidate() + } + + // Sleep and screen lock are judged per session: each one is erased only if its + // own resolved lockOn policy says that event ends it. + sessionManager.onLockEvent = { event in + identitySessions.handleLockEvent(event) + } + // Write PID file let pidPath = getArg("--pid-path") if let pidPath = pidPath { @@ -214,7 +610,7 @@ case "daemon": } // Handle IPC messages (sessionId is resolved from the peer's TTY or process tree) - server.messageHandler = { message, sessionId in + server.messageHandler = { message, sessionId, peerPid in guard let action = message["action"] as? String else { return ["error": "Missing action"] } @@ -230,7 +626,10 @@ case "daemon": let keyId = (payload["keyId"] as? String) ?? defaultKeyId do { - let context = try sessionManager.getAuthenticatedContext(sessionId: sessionId) + let context = try sessionManager.getAuthenticatedContext( + sessionId: sessionId, + peerPid: peerPid + ) let decrypted = try SecureEnclaveManager.decrypt( payload: ciphertext, keyId: keyId, @@ -251,6 +650,9 @@ case "daemon": "pong": true, "sessionWarm": sessionManager.isSessionWarm(sessionId: sessionId), "sessionId": sessionId as Any, + // Absent means 1 (a daemon predating identity sessions), so a + // client can tell a stale daemon from one that speaks these ops. + "protocolVersion": daemonProtocolVersion, ], ] @@ -277,9 +679,23 @@ case "daemon": let itemKey = promptPayload?["itemKey"] as? String let promptMessage = promptPayload?["message"] as? String ?? "Enter the secret value to encrypt:" + let promptKeyId = (promptPayload?["keyId"] as? String) ?? defaultKeyId + + // Check the recipient key before drawing anything. Finding out after + // the dialog means the user types a secret into a prompt that was + // never going to work, and leaves a modal on screen with nobody to + // dismiss it if the caller was a script. + var identityPublicKey: Data? + if let identityPublicKeyB64 = promptPayload?["identityPublicKey"] as? String { + do { + identityPublicKey = try Ecies.recipientPublicKeyData(base64: identityPublicKeyB64) + } catch { + return identityErrorResponse(error) + } + } guard let value = SecureInputDialog.prompt( - title: "Varlock — Enter Secret", + title: "Varlock: Enter Secret", message: promptMessage, itemKey: itemKey ) else { @@ -287,13 +703,24 @@ case "daemon": } // Encrypt the entered value immediately - let promptKeyId = (promptPayload?["keyId"] as? String) ?? defaultKeyId guard let valueData = value.data(using: .utf8) else { return ["error": "Value is not valid UTF-8"] } do { - let encrypted = try SecureEnclaveManager.encrypt(plaintext: valueData, keyId: promptKeyId) + let encrypted: Data + if let identityPublicKey { + // Encrypt to the identity here, so only ciphertext crosses the + // socket and the value never exists outside this process. + encrypted = try Ecies.encrypt( + plaintext: valueData, + toPublicKeyData: identityPublicKey, + version: Ecies.identityPayloadVersion + ) + } else { + // Legacy path: encrypt straight to the device key + encrypted = try SecureEnclaveManager.encrypt(plaintext: valueData, keyId: promptKeyId) + } return ["result": [ "ciphertext": encrypted.base64EncodedString(), ]] @@ -301,10 +728,150 @@ case "daemon": return ["error": error.localizedDescription] } + // MARK: Identity session actions + + case "unlock-session": + // A malformed message is refused rather than guessed at, the same way + // decrypt-v2 refuses one. Guessing here would mean unlocking a key the + // caller never named and dropping its display metadata in silence. + guard let payload = message["payload"] as? [String: Any] else { + return ["error": "Missing payload"] + } + let identityId = (payload["identityId"] as? String) ?? IdentityStore.defaultIdentityId + let scope = SessionGrantScope(wireValue: payload["scope"] as? String) ?? .session + + // Accept one key or several: one unlock, one scan, however many keys. + let requestedKeyIds = UnlockRequestKeys.from(payload: payload) + + // A caller may name the session it believes it is in, but it never + // overrides the identity we resolved from the peer process itself. + let durationMs = (payload["durationMs"] as? NSNumber)?.int64Value + + // Optional decoration from the client (item counts, project name). It + // only ever changes the wording on the panel. + let requestContext = IdentitySessionManager.UnlockRequestContext( + requester: panelRequesterForPid(peerPid), + display: UnlockDisplayInfo.from(payload: payload), + // The ciphertexts this unlock would cover, hashed on this side. + // Not decoration: this is what an item-scoped grant binds to. + itemDigests: UnlockRequestItems.from(payload: payload) + ) + + do { + let outcome = try identitySessions.unlock( + sessionId: sessionId, + keyIds: requestedKeyIds, + identityId: identityId, + scope: scope, + durationMs: durationMs, + lockOnOverride: payload["lockOn"] as? String, + requestContext: requestContext + ) + statusBarMenu?.refresh() + return ["result": [ + "sessionId": sessionId as Any, + "policy": outcome.policy.rawValue, + "lockOn": outcome.lockOn.rawValue, + "lockOnSource": outcome.lockOnSource.rawValue, + "prompted": outcome.prompted, + "grants": outcome.grants.map { $0.toDictionary() }, + ]] + } catch { + return identityErrorResponse(error) + } + + case "request-approval": + // Generic and stateless: put a question on the trusted display and + // report the answer. Nothing is unlocked and nothing is recorded here, + // so the caller (the proxy) keeps its own record of what it may do. + guard let approvalPayload = message["payload"] as? [String: Any] else { + return ["error": "Missing payload"] + } + do { + let request = try ApprovalRequest.from(payload: approvalPayload) + let content = request.panelContent(requester: panelRequesterForPid(peerPid)) + // A request that wants a biometric gets the same embedded prompt as + // an unlock: the scan is the approval. Without one, the panel keeps + // its plain button. + let attempt = request.requireBiometric ? identitySessions.beginPresence() : nil + guard let outcome = ApprovalPanel.present( + content: content, + presenceReason: request.title, + attempt: attempt + ) else { + attempt?.context.invalidate() + return identityErrorResponse(IdentitySessionManager.IdentitySessionError.noUi) + } + // Nothing here holds a key, so the proof has done its job by + // existing and is dropped rather than handed on. + outcome.proof?.context.invalidate() + if outcome.decision.approved && request.requireBiometric && outcome.proof == nil { + // The panel approved without a presence check, which can only + // happen if none could be started. Do not report an approval + // the caller asked to have verified. + try identitySessions.verifyUserPresence(reason: request.title) + } + return ["result": ApprovalOutcome(decision: outcome.decision).toDictionary()] + } catch { + return identityErrorResponse(error) + } + + case "decrypt-v2": + guard let payload = message["payload"] as? [String: Any] else { + return ["error": "Missing payload"] + } + let keyId = (payload["keyId"] as? String) ?? defaultKeyId + let identityId = (payload["identityId"] as? String) ?? IdentityStore.defaultIdentityId + + // Batch form is the normal one (a whole env file resolves at once); + // the single-ciphertext form is accepted for one-off callers. + var ciphertexts = (payload["ciphertexts"] as? [String]) ?? [] + if let single = payload["ciphertext"] as? String { ciphertexts.append(single) } + guard !ciphertexts.isEmpty else { + return ["error": "Missing ciphertext in payload"] + } + let payloadDatas = ciphertexts.compactMap { Data(base64Encoded: $0) } + guard payloadDatas.count == ciphertexts.count else { + return ["error": "Invalid base64 in ciphertext payload"] + } + + do { + let outcome = try identitySessions.decryptV2( + sessionId: sessionId, + keyId: keyId, + identityId: identityId, + payloads: payloadDatas, + requester: requesterSummaryForPid(peerPid) + ) + statusBarMenu?.refresh() + return ["result": [ + "plaintexts": outcome.plaintexts, + "grant": outcome.grant.toDictionary(), + ]] + } catch { + return identityErrorResponse(error) + } + + case "list-sessions": + return ["result": ["sessions": identitySessions.listGrants()]] + case "invalidate-session": - sessionManager.invalidateAllSessions() + let payload = message["payload"] as? [String: Any] + let targetSessionId = payload?["sessionId"] as? String + let targetKeyId = payload?["keyId"] as? String + + // No arguments keeps the original meaning: drop everything, including + // the cached biometric contexts. + if targetSessionId == nil && targetKeyId == nil { + sessionManager.invalidateAllSessions() + } + let invalidated = identitySessions.invalidate( + sessionId: targetSessionId, + keyId: targetKeyId, + requester: requesterSummaryForPid(peerPid) + ) statusBarMenu?.refresh() - return ["result": "all sessions invalidated"] + return ["result": ["invalidated": invalidated]] // MARK: Keychain actions @@ -340,7 +907,7 @@ case "daemon": // Password reads require biometric gate do { - _ = try sessionManager.getAuthenticatedContext(sessionId: sessionId) + _ = try sessionManager.getAuthenticatedContext(sessionId: sessionId, peerPid: peerPid) } catch { return ["error": error.localizedDescription] } @@ -431,6 +998,27 @@ case "daemon": do { try server.start() + // Signal handling goes in before anything announces itself, and the + // dispatch sources go in before the default action is turned off. + // + // Order matters both ways. A SIGTERM arriving between `SIG_IGN` and a + // resumed source would be neither killed nor handled, and the daemon + // would sit there holding session keys with nothing left to stop it; the + // other way round, the worst case is the default action, which at least + // ends the process. And doing all of it before the status item is built + // means a slow window server cannot leave a stretch of startup where the + // daemon cannot be asked to stop. + let sigTermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + sigTermSource.setEventHandler { shutdownDaemon() } + sigTermSource.resume() + + let sigIntSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) + sigIntSource.setEventHandler { shutdownDaemon() } + sigIntSource.resume() + + signal(SIGTERM, SIG_IGN) + signal(SIGINT, SIG_IGN) + // Print ready message to stdout so the JS launcher knows we're ready jsonOutput(["ready": true, "pid": ProcessInfo.processInfo.processIdentifier, "socketPath": socketPath]) fflush(stdout) @@ -438,28 +1026,42 @@ case "daemon": // Set up status bar menu statusBarMenu = StatusBarMenu( sessionManager: sessionManager, - onLock: { - sessionManager.invalidateAllSessions() - statusBarMenu?.refresh() - }, - onQuit: { - shutdownDaemon() - } + actions: StatusBarMenu.Actions( + liveGrants: { + identitySessions.liveGrantInfos() + }, + lockAll: { + // Explicit lock: cached biometric contexts AND every identity + // session, whatever lock policy those sessions were opened with. + sessionManager.handleSystemLock() + statusBarMenu?.refresh() + }, + lockSession: { sessionId in + identitySessions.invalidate(sessionId: sessionId, requester: "menu bar") + statusBarMenu?.refresh() + }, + currentLockPolicy: { + // Read from the file rather than cached, for the same reason + // unlock does: an edit applies without a restart. + LockPolicyResolution.machineLockPolicy( + fromConfigData: IdentityStore.readMachineConfigData() + ) ?? .builtInDefault + }, + setLockPolicy: { policy in + let updated = try MachineConfigEdit.settingLockOn( + policy, + in: IdentityStore.readMachineConfigData() + ) + try IdentityStore.writeMachineConfigData(updated) + }, + quit: { + shutdownDaemon() + } + ) ) // We need a run loop for NSWorkspace notifications (sleep/lock detection) // and for the status bar menu to work - signal(SIGTERM, SIG_IGN) - signal(SIGINT, SIG_IGN) - - let sigTermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) - sigTermSource.setEventHandler { shutdownDaemon() } - sigTermSource.resume() - - let sigIntSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) - sigIntSource.setEventHandler { shutdownDaemon() } - sigIntSource.resume() - app.run() } catch IPCError.lockHeld { // Another daemon won the race (parallel spawn — e.g. turbo tasks). @@ -480,7 +1082,8 @@ case "help", "--help", "-h": varlock-enclave - Secure Enclave encryption daemon for Varlock COMMANDS: - generate-key [--key-id ] Create a new Secure Enclave key + generate-key [--key-id ] [--auth-every-time] + Create a new Secure Enclave key delete-key [--key-id ] Delete a Secure Enclave key list-keys List all Varlock Secure Enclave keys key-exists [--key-id ] Check if a key exists @@ -489,6 +1092,21 @@ case "help", "--help", "-h": decrypt --data [--key-id ] Decrypt data (one-shot, testing) status Check Secure Enclave availability daemon --socket-path [--pid-path ] Start IPC daemon + probe-session-unlock [--key-id ] Check that one biometric scan covers a + whole unlock on this machine + probe-embedded-unlock [--key-id ] Same check for the panel's embedded + Touch ID prompt + probe-laright [--custody-only] Spike: does LARight draw its prompt + inline, and can its key hold our wrap + panel-preview --out [--payload ] + Draw the approval panel to a PNG, without + showing it or unlocking anything. The + payload's "expandChain" and "expandKeys" + open the disclosures a still cannot click, + "durationMs", "customUnit" and + "focusCustom" draw the custom window rung + in states only a click can reach, and + "legacy" draws the device-key panel OPTIONS: --key-id Key identifier (default: varlock-default) @@ -496,6 +1114,8 @@ case "help", "--help", "-h": --data-stdin Read base64 data from stdin (one line) --socket-path Unix socket path for daemon mode --pid-path PID file path for daemon mode + --no-auth Create a key with no user-presence requirement (CI) + --auth-every-time Create a key that must be approved for every read All output is JSON. Errors return {"error": "message"}. """ diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalFlowTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalFlowTests.swift new file mode 100644 index 000000000..414c763d4 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalFlowTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import IdentitySessions + +/// The order an approval happens in. +/// +/// The promises being pinned here are the ones a user feels: the scan is the +/// approval and costs one gesture, what it approves is what the panel is showing +/// at that instant, a failed scan is not a refusal, and nothing re-arms itself. +final class ApprovalFlowTests: XCTestCase { + + private func embedded(default scope: SessionGrantScope = .session) -> ApprovalFlow { + return ApprovalFlow(defaultScope: scope, presenceMode: .embedded) + } + + // MARK: - The common case + + func testEmbeddedPromptArmsItselfAndTheScanIsTheApproval() { + var flow = embedded() + XCTAssertEqual(flow.start(), .beginScan) + XCTAssertEqual(flow.state, .scanning) + + // One gesture: no confirm press anywhere in this test. + let effect = flow.apply(.scanSucceeded) + XCTAssertEqual(effect, .finish(PanelDecision(approved: true, scope: .session, chosenBreadth: .wholeKey))) + XCTAssertEqual(flow.state, .finished(PanelDecision(approved: true, scope: .session, chosenBreadth: .wholeKey))) + } + + func testAScanApprovesWhatIsSelectedWhenItLands() { + var flow = embedded() + _ = flow.start() + + // Nothing is modal over the panel, so the user can still change their mind + // while the prompt is armed. The scan must honour that, not the state the + // panel opened in. + flow.select(scope: .once) + XCTAssertEqual(flow.apply(.scanSucceeded), .finish(PanelDecision(approved: true, scope: .once, breadth: .listedItems))) + } + + func testADurationSelectionCarriesItsWindow() { + var flow = embedded() + _ = flow.start() + // A window nothing on the ladder names, which is what the custom rung + // produces. The flow carries whatever it is handed: it is not in the + // business of second-guessing a number the panel already clamped. + let typed: Int64 = 2_700_000 + flow.select(scope: .duration, durationMs: typed) + + XCTAssertEqual( + flow.apply(.scanSucceeded), + .finish(PanelDecision( + approved: true, + scope: .duration, + durationMs: typed, + chosenBreadth: .wholeKey + )) + ) + } + + func testADurationSelectionWithNoWindowTakesThePresetDefault() { + var flow = embedded() + _ = flow.start() + flow.select(scope: .duration) + + XCTAssertEqual( + flow.apply(.scanSucceeded), + .finish(PanelDecision( + approved: true, + scope: .duration, + durationMs: DurationPreset.default.milliseconds, + chosenBreadth: .wholeKey + )) + ) + } + + func testMovingOffDurationDropsTheWindow() { + var flow = embedded() + _ = flow.start() + flow.select(scope: .duration, durationMs: DurationPreset.oneHour.milliseconds) + flow.select(scope: .session) + + XCTAssertEqual(flow.apply(.scanSucceeded), .finish(PanelDecision(approved: true, scope: .session, chosenBreadth: .wholeKey))) + } + + // MARK: - A scan that does not land + + func testAFailedScanIsNotARefusal() { + var flow = embedded() + _ = flow.start() + + XCTAssertEqual(flow.apply(.scanFailed), .showControls) + XCTAssertEqual(flow.state, .awaitingInput, "the panel stays up with its controls live") + XCTAssertEqual(flow.failedScans, 1) + } + + func testNothingReArmsOnItsOwn() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanFailed) + + // The only way back to a scan is the user pressing the button, which is + // what stops a failing sensor turning into a loop. + XCTAssertEqual(flow.state, .awaitingInput) + XCTAssertEqual(flow.apply(.confirmPressed), .beginScan) + XCTAssertEqual(flow.state, .scanning) + } + + func testAScanAfterARetryStillApprovesTheCurrentSelection() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanFailed) + flow.select(scope: .once) + _ = flow.apply(.confirmPressed) + + XCTAssertEqual(flow.apply(.scanSucceeded), .finish(PanelDecision(approved: true, scope: .once, breadth: .listedItems))) + } + + func testRepeatedFailuresAreCounted() { + var flow = embedded() + _ = flow.start() + for expected in 1...3 { + _ = flow.apply(.scanFailed) + XCTAssertEqual(flow.failedScans, expected) + _ = flow.apply(.confirmPressed) + } + } + + // MARK: - Refusal + + func testCancelIsTheOnlyRefusal() { + var flow = embedded() + _ = flow.start() + + XCTAssertEqual(flow.apply(.cancelPressed), .finish(PanelDecision(approved: false, scope: .session))) + if case .finished(let decision) = flow.state { + XCTAssertFalse(decision.approved) + } else { + XCTFail("cancel should finish the flow") + } + } + + func testCancelWhileTheScanIsArmedStillRefuses() { + var flow = embedded() + _ = flow.start() + XCTAssertEqual(flow.state, .scanning) + + XCTAssertEqual(flow.apply(.cancelPressed), .finish(PanelDecision(approved: false, scope: .session))) + } + + func testRunningOutOfTimeRefuses() { + var flow = embedded() + _ = flow.start() + + XCTAssertEqual(flow.apply(.timedOut), .finish(PanelDecision(approved: false, scope: .session))) + } + + // MARK: - Nothing answers twice + + func testAnAnswerIsFinal() { + var flow = embedded() + _ = flow.start() + let approved = flow.apply(.scanSucceeded) + + // A scan callback landing after the user already cancelled, or a second + // press, must not change what was answered. + XCTAssertEqual(flow.apply(.cancelPressed), approved) + XCTAssertEqual(flow.apply(.scanSucceeded), approved) + XCTAssertEqual(flow.apply(.timedOut), approved) + } + + func testSelectionIsIgnoredOnceAnswered() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanSucceeded) + + flow.select(scope: .once) + XCTAssertEqual(flow.scope, .session, "a late control event cannot rewrite a decision") + } + + // MARK: - The other two modes + + func testWithoutAPresenceCheckTheButtonIsTheAnswer() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .none) + XCTAssertEqual(flow.start(), .showControls, "nothing arms itself") + XCTAssertEqual(flow.state, .awaitingInput) + + flow.select(scope: .once) + XCTAssertEqual(flow.apply(.confirmPressed), .finish(PanelDecision(approved: true, scope: .once, breadth: .listedItems))) + } + + func testTheSystemDialogFallbackWaitsForTheButton() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .systemDialog) + XCTAssertEqual(flow.start(), .showControls, "no dialog until the user asks for one") + + // The button raises the dialog rather than answering by itself, so the + // approval still costs a real presence check. + XCTAssertEqual(flow.apply(.confirmPressed), .beginScan) + XCTAssertEqual(flow.apply(.scanSucceeded), .finish(PanelDecision(approved: true, scope: .session, chosenBreadth: .wholeKey))) + } + + func testTheFallbackAlsoTreatsAFailedCheckAsNotARefusal() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .systemDialog) + _ = flow.start() + _ = flow.apply(.confirmPressed) + + XCTAssertEqual(flow.apply(.scanFailed), .showControls) + XCTAssertEqual(flow.state, .awaitingInput) + } + + // MARK: - Strict batches + + func testAStrictOnlyBatchScansStraightIntoOnce() { + // The planner offers `once` alone for these, so the default the scan + // approves is already the only thing on offer. + var flow = embedded(default: .once) + XCTAssertEqual(flow.start(), .beginScan) + XCTAssertEqual(flow.apply(.scanSucceeded), .finish(PanelDecision(approved: true, scope: .once, breadth: .listedItems))) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalRequestTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalRequestTests.swift new file mode 100644 index 000000000..ed66aab86 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/ApprovalRequestTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import IdentitySessions + +/// The generic approval op. +/// +/// The caller writes the words on this panel, so the checks here are about what a +/// caller is allowed to put on the trusted display: a bounded number of bounded +/// lines, a scope list it cannot reorder, and no way to pass itself off as one of +/// the lines the daemon derived. +final class ApprovalRequestTests: XCTestCase { + + func testMinimalRequestDefaultsToOnce() throws { + let request = try ApprovalRequest.from(payload: ["title": "Send this request?"]) + XCTAssertEqual(request.title, "Send this request?") + XCTAssertEqual(request.allowedScopes, [.once]) + XCTAssertEqual(request.defaultScope, .once) + XCTAssertFalse(request.requireBiometric) + } + + func testTitleIsRequired() { + XCTAssertThrowsError(try ApprovalRequest.from(payload: [:])) { error in + XCTAssertEqual(error as? ApprovalRequest.ParseError, .missingTitle) + XCTAssertEqual((error as? ApprovalRequest.ParseError)?.code, "APPROVAL_MISSING_TITLE") + } + XCTAssertThrowsError(try ApprovalRequest.from(payload: ["title": " "])) + XCTAssertThrowsError(try ApprovalRequest.from(payload: nil)) + } + + func testScopesKeepTheCanonicalOrderWhateverTheCallerSent() throws { + let request = try ApprovalRequest.from(payload: [ + "title": "Use the deploy token?", + "allowedScopes": ["duration", "once", "session"], + ]) + XCTAssertEqual(request.allowedScopes, [.session, .once, .duration]) + } + + func testUnknownScopesAreDroppedAndAnEmptyListIsRefused() { + XCTAssertThrowsError(try ApprovalRequest.from(payload: [ + "title": "Use the deploy token?", + "allowedScopes": ["forever", "whenever"], + ])) { error in + XCTAssertEqual(error as? ApprovalRequest.ParseError, .noUsableScopes) + } + } + + func testDefaultScopeMustBeOneOfTheAllowedOnes() throws { + let request = try ApprovalRequest.from(payload: [ + "title": "Use the deploy token?", + "allowedScopes": ["once", "session"], + "defaultScope": "duration", + ]) + XCTAssertEqual(request.defaultScope, .session, "falls back to the first allowed scope") + + let honored = try ApprovalRequest.from(payload: [ + "title": "Use the deploy token?", + "allowedScopes": ["once", "session"], + "defaultScope": "once", + ]) + XCTAssertEqual(honored.defaultScope, .once) + } + + func testLinesAreCappedInCountAndLength() throws { + let request = try ApprovalRequest.from(payload: [ + "title": String(repeating: "t", count: 500), + "descriptionLines": (0..<20).map { "line \($0)" }, + "contextLines": (0..<20).map { "context \($0)" }, + ]) + XCTAssertEqual(request.title.count, ApprovalRequest.maxTitleLength) + XCTAssertEqual(request.descriptionLines.count, ApprovalRequest.maxDescriptionLines) + XCTAssertEqual(request.clientContextLines.count, ApprovalRequest.maxContextLines) + } + + func testCallerLinesCannotFakeExtraLines() throws { + let request = try ApprovalRequest.from(payload: [ + "title": "Approve", + "descriptionLines": ["first\nRequested by launchd"], + ]) + XCTAssertEqual(request.descriptionLines, ["first Requested by launchd"]) + } + + func testDerivedLinesComeFirstAndCallerLinesStayMarked() throws { + let request = try ApprovalRequest.from(payload: [ + "title": "Use the deploy token?", + "contextLines": ["POST https://api.example.com/deploy"], + ]) + let content = request.panelContent(requester: PanelRequester( + summary: "Requested by node in ttys002", + details: [.derived("Process: node"), .derived("Terminal ttys002")] + )) + XCTAssertEqual(content.requester.summary, "Requested by node in ttys002") + XCTAssertEqual(content.requester.details.prefix(2).map { $0.isDerived }, [true, true]) + XCTAssertEqual( + content.requester.details.last, + PanelContextLine.clientSupplied("POST https://api.example.com/deploy") + ) + XCTAssertEqual(content.title, "Use the deploy token?") + } + + func testRequireBiometricIsRead() throws { + let request = try ApprovalRequest.from(payload: ["title": "Approve", "requireBiometric": true]) + XCTAssertTrue(request.requireBiometric) + } + + // MARK: - Outcome + + func testApprovedOutcomeReportsTheScope() { + let outcome = ApprovalOutcome(decision: PanelDecision(approved: true, scope: .duration, durationMs: 3_600_000)) + let dict = outcome.toDictionary() + XCTAssertEqual(dict["decision"] as? String, "approved") + XCTAssertEqual(dict["scope"] as? String, "duration") + XCTAssertEqual(dict["durationMs"] as? Int64, 3_600_000) + } + + func testDeniedOutcomeCarriesNoDuration() { + let outcome = ApprovalOutcome(decision: PanelDecision.denied(defaultScope: .duration)) + let dict = outcome.toDictionary() + XCTAssertEqual(dict["decision"] as? String, "denied") + XCTAssertNil(dict["durationMs"]) + } + + func testNonDurationApprovalCarriesNoDuration() { + let outcome = ApprovalOutcome(decision: PanelDecision(approved: true, scope: .session, durationMs: 999)) + XCTAssertNil(outcome.toDictionary()["durationMs"]) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/AuthorizationAuditTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/AuthorizationAuditTests.swift new file mode 100644 index 000000000..aacbab3d3 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/AuthorizationAuditTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import IdentitySessions + +/// The authorization log's two promises: a record that is reported as written is +/// really on disk, and a record that cannot be written is an error rather than a +/// shrug. The second one is what lets the decrypt path treat a failed append as a +/// reason to refuse. +final class AuthorizationAuditTests: XCTestCase { + + private var root: String = "" + private var auditDir: String { return root + "/audit" } + + override func setUpWithError() throws { + root = NSTemporaryDirectory() + "varlock-audit-tests-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + // Put permissions back before deleting, or a test that made something + // read-only would leave the directory behind. + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: auditDir) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: auditDir + "/" + AuthorizationAuditLog.fileName + ) + try? FileManager.default.removeItem(atPath: root) + } + + private func makeLog(timestamp: String = "2026-01-01T00:00:00.000Z") -> AuthorizationAuditLog { + return AuthorizationAuditLog(directoryPath: auditDir, timestamp: { timestamp }) + } + + private func decryptRecord(payloads: Int = 3) -> AuthorizationRecord { + return AuthorizationRecord( + kind: .decrypt, + sessionId: "tty:ttys004:1700000000", + keyIds: ["varlock-default"], + identityId: "default", + payloadCount: payloads, + scope: "session", + requester: "node ← claude ← zsh (ttys004)" + ) + } + + private func lines() throws -> [String] { + let contents = try String(contentsOfFile: auditDir + "/" + AuthorizationAuditLog.fileName, encoding: .utf8) + return contents.split(separator: "\n").map(String.init) + } + + // MARK: - Writing + + func testAppendWritesOneJsonLinePerRecord() throws { + let log = makeLog() + try log.append(decryptRecord()) + try log.append(decryptRecord(payloads: 1)) + + let written = try lines() + XCTAssertEqual(written.count, 2) + + let first = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(written[0].utf8)) as? [String: Any] + ) + XCTAssertEqual(first["event"] as? String, "decrypt-v2") + XCTAssertEqual(first["sessionId"] as? String, "tty:ttys004:1700000000") + XCTAssertEqual(first["keyIds"] as? [String], ["varlock-default"]) + XCTAssertEqual(first["payloadCount"] as? Int, 3) + XCTAssertEqual(first["scope"] as? String, "session") + XCTAssertEqual(first["requester"] as? String, "node ← claude ← zsh (ttys004)") + XCTAssertEqual(first["ts"] as? String, "2026-01-01T00:00:00.000Z") + } + + func testTheFileOnlyGrows() throws { + let log = makeLog() + try log.append(decryptRecord()) + let afterFirst = try lines() + + try log.append(AuthorizationRecord(kind: .unlock, sessionId: "tty:a", keyIds: ["k1"])) + let afterSecond = try lines() + + XCTAssertEqual(afterSecond.count, 2) + XCTAssertEqual(afterSecond[0], afterFirst[0]) + } + + func testDirectoryAndFileAreOwnerOnly() throws { + let log = makeLog() + try log.append(decryptRecord()) + + let dirMode = try FileManager.default.attributesOfItem(atPath: auditDir)[.posixPermissions] as? Int + let fileMode = try FileManager.default.attributesOfItem(atPath: log.filePath)[.posixPermissions] as? Int + XCTAssertEqual(dirMode, 0o700) + XCTAssertEqual(fileMode, 0o600) + } + + /// The log is meant to be readable by a person and by tooling, so it must not + /// accumulate anything that would be dangerous to read. + func testRecordsCarryNoSecretMaterial() throws { + let log = makeLog() + try log.append(decryptRecord()) + + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(try lines()[0].utf8)) as? [String: Any] + ) + let allowedKeys: Set = [ + "ts", "event", "sessionId", "keyIds", "identityId", "payloadCount", "scope", "requester", + ] + XCTAssertTrue(Set(object.keys).isSubset(of: allowedKeys), "unexpected fields: \(object.keys)") + } + + // MARK: - Failing to write + + func testAppendThrowsWhenTheFileCannotBeOpened() throws { + let log = makeLog() + try log.append(decryptRecord()) + + // Anything that makes the append fail has to surface: no silent skip, and + // no success reported for a line that never landed. + try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: log.filePath) + + XCTAssertThrowsError(try log.append(decryptRecord())) { error in + XCTAssertEqual((error as? AuthorizationAuditError)?.code, "AUDIT_WRITE_FAILED") + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: log.filePath) + XCTAssertEqual(try lines().count, 1) + } + + func testAppendThrowsWhenTheDirectoryCannotBeCreated() { + // A plain file where the audit directory should be: nothing can be written + // under it, and the writer must say so rather than carry on. + FileManager.default.createFile(atPath: auditDir, contents: Data("not a directory".utf8)) + let log = makeLog() + + XCTAssertThrowsError(try log.append(decryptRecord())) { error in + XCTAssertEqual((error as? AuthorizationAuditError)?.code, "AUDIT_WRITE_FAILED") + } + } + + func testAppendThrowsWhenTheDirectoryIsUnwritable() throws { + try FileManager.default.createDirectory(atPath: auditDir, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: auditDir) + let log = makeLog() + + XCTAssertThrowsError(try log.append(decryptRecord())) { error in + XCTAssertEqual((error as? AuthorizationAuditError)?.code, "AUDIT_WRITE_FAILED") + } + } + + func testErrorSaysWhySoTheDenialIsExplainable() { + let error = AuthorizationAuditError.notPersisted("disk is full") + XCTAssertTrue(error.localizedDescription.contains("disk is full")) + XCTAssertTrue(error.localizedDescription.contains("Refusing to release secrets")) + } + + // MARK: - Record shape + + func testInvalidationRecordsUseWildcardsForWholeSweeps() throws { + let log = makeLog() + try log.append(AuthorizationRecord(kind: .invalidate, sessionId: "*", keyIds: ["*"])) + + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(try lines()[0].utf8)) as? [String: Any] + ) + XCTAssertEqual(object["event"] as? String, "invalidate-session") + XCTAssertEqual(object["sessionId"] as? String, "*") + XCTAssertNil(object["scope"]) + XCTAssertNil(object["requester"]) + } + + func testTimestampsAreUtcIso8601WithMilliseconds() { + let stamp = AuthorizationAuditLog.iso8601(Date(timeIntervalSince1970: 1_700_000_000.25)) + XCTAssertEqual(stamp, "2023-11-14T22:13:20.250Z") + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/BiometricSetupTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/BiometricSetupTests.swift new file mode 100644 index 000000000..633d04a45 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/BiometricSetupTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import IdentitySessions + +/// When setting Touch ID up has to happen on its own, before the panel exists. +/// +/// The bug this encodes: on first use the system's sheet appeared over the +/// approval panel, and one finger satisfied both, so secrets were released +/// against a panel nobody had read. Setup is now its own scan with its own +/// wording, and the approval is a second one taken while the panel is on screen. +final class BiometricSetupTests: XCTestCase { + func testFirstUseNeedsSetup() { + XCTAssertTrue(BiometricSetupPolicy.needsSetup(recordedDomainState: nil, currentDomainState: "a")) + XCTAssertTrue(BiometricSetupPolicy.needsSetup(recordedDomainState: "", currentDomainState: "a")) + } + + func testAChangedEnrolmentNeedsSetupAgain() { + // A new finger is the point where macOS starts asking for itself again, + // which is exactly when the setup step has to be repeated. + XCTAssertTrue(BiometricSetupPolicy.needsSetup(recordedDomainState: "a", currentDomainState: "b")) + } + + func testTheSameEnrolmentDoesNot() { + XCTAssertFalse(BiometricSetupPolicy.needsSetup(recordedDomainState: "a", currentDomainState: "a")) + } + + func testAnUnreadableEnrolmentIsNotTreatedAsAChange() { + // Not knowing is not evidence of a change, and a setup scan on every + // unlock would be worse than the bug this exists to fix. + XCTAssertFalse(BiometricSetupPolicy.needsSetup(recordedDomainState: "a", currentDomainState: nil)) + XCTAssertFalse(BiometricSetupPolicy.needsSetup(recordedDomainState: "a", currentDomainState: "")) + // With nothing recorded either, setup still has to happen: a machine + // with no biometrics has nothing to set up and will fail the check + // honestly rather than skipping it. + XCTAssertTrue(BiometricSetupPolicy.needsSetup(recordedDomainState: nil, currentDomainState: nil)) + } + + func testTheSetupPromptSaysWhatItIs() { + // Not approval wording: nothing is being unlocked yet, and a prompt that + // says "unlock" while nothing is unlocked teaches people to scan without + // reading. + XCTAssertEqual(BiometricSetupPolicy.setupReason, "set up Touch ID approvals") + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/CustomDurationTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/CustomDurationTests.swift new file mode 100644 index 000000000..f586902ff --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/CustomDurationTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import IdentitySessions + +/// The typed window, and the promise that it can never be illegal. +/// +/// The sensor is armed while somebody is typing into this field, so every one of +/// these is really a statement about what a scan landing mid-word would grant. +final class CustomDurationTests: XCTestCase { + func testNothingATyperCanDoProducesAnIllegalWindow() { + // Empty, blank, nonsense and zero all read as the floor rather than as a + // complaint. There is no error state to get stuck in, which is the whole + // reason this is a clamp and not a validator. + for text in ["", " ", "abc", "0", "-4", " 0 "] { + XCTAssertEqual( + CustomDuration.parse(text, unit: .minutes), + CustomDuration(amount: 1, unit: .minutes), + "\(text) should read as the floor" + ) + } + // Past the cap reads as the cap, in whichever unit is showing. + XCTAssertEqual( + CustomDuration.parse("9999", unit: .minutes).milliseconds, + SessionGrantTable.maxGrantMs + ) + XCTAssertEqual( + CustomDuration.parse("48", unit: .hours).milliseconds, + SessionGrantTable.maxGrantMs + ) + // A number too large to even be a number is still somebody asking for + // more than the cap, and reads as the cap rather than as the floor. + XCTAssertEqual( + CustomDuration.parse("99999999999999999999999", unit: .hours).milliseconds, + SessionGrantTable.maxGrantMs + ) + } + + func testAPartialNumberIsAlwaysShorterThanTheOneBeingTyped() { + // The property that makes it safe to grant whatever the field currently + // holds: every prefix of a number somebody is typing is smaller than the + // number, so a scan landing mid-word can only ever come out narrower. + let target = "240" + var previous: Int64 = 0 + for length in 1...target.count { + let partial = CustomDuration.parse(String(target.prefix(length)), unit: .minutes) + XCTAssertGreaterThanOrEqual(partial.milliseconds, previous) + previous = partial.milliseconds + } + XCTAssertEqual(previous, CustomDuration(amount: 240, unit: .minutes).milliseconds) + } + + func testSwitchingUnitsConvertsTheWindowRatherThanRereadingTheNumber() { + // The mistake this exists to rule out: 90 minutes becoming 90 hours. + XCTAssertEqual( + CustomDuration(amount: 90, unit: .minutes).converted(to: .hours), + CustomDuration(amount: 1, unit: .hours) + ) + XCTAssertEqual( + CustomDuration(amount: 2, unit: .hours).converted(to: .minutes), + CustomDuration(amount: 120, unit: .minutes) + ) + // A window that does not divide evenly rounds to the SHORTER neighbour, + // so the rounding a unit switch does can only narrow a grant. + XCTAssertEqual( + CustomDuration(amount: 119, unit: .minutes).converted(to: .hours), + CustomDuration(amount: 1, unit: .hours) + ) + // Except below one whole unit, where there is no shorter answer left. + XCTAssertEqual( + CustomDuration(amount: 5, unit: .minutes).converted(to: .hours), + CustomDuration(amount: 1, unit: .hours) + ) + // And the cap holds across the switch in both directions. + XCTAssertEqual( + CustomDuration(amount: 720, unit: .minutes).converted(to: .hours), + CustomDuration(amount: 12, unit: .hours) + ) + XCTAssertEqual( + CustomDuration(amount: 12, unit: .hours).converted(to: .minutes), + CustomDuration(amount: 720, unit: .minutes) + ) + } + + func testAValueComesBackInTheUnitThatSaysItMostPlainly() { + // What a remembered window looks like when it returns: whole hours in + // hours, everything else in minutes, so nothing comes back as a fraction + // of something. + XCTAssertEqual( + CustomDuration.forMilliseconds(2_700_000), + CustomDuration(amount: 45, unit: .minutes) + ) + XCTAssertEqual( + CustomDuration.forMilliseconds(7_200_000), + CustomDuration(amount: 2, unit: .hours) + ) + XCTAssertEqual( + CustomDuration.forMilliseconds(5_400_000), + CustomDuration(amount: 90, unit: .minutes) + ) + // And a value past the cap comes back at the cap rather than as a rung + // the grant table would clip. + XCTAssertEqual( + CustomDuration.forMilliseconds(999_999_999), + CustomDuration(amount: 12, unit: .hours) + ) + } + + func testTheRungAndTheSentenceSayTheSameThingInTwoRegisters() { + XCTAssertEqual(DurationText.short(2_700_000), "45min") + XCTAssertEqual(DurationText.prose(2_700_000), "45 minutes") + XCTAssertEqual(DurationText.short(3_600_000), "1hr") + XCTAssertEqual(DurationText.prose(3_600_000), "1 hour") + XCTAssertEqual(DurationText.short(600_000), "10min") + XCTAssertEqual(DurationText.prose(600_000), "10 minutes") + XCTAssertEqual(DurationText.short(60_000), "1min") + XCTAssertEqual(DurationText.prose(60_000), "1 minute") + } + + func testTheFieldOpensOnSomethingShorterThanTheLongestPreset() { + // An untouched control must not assert more than the row was already + // offering, so somebody who picks the custom rung and then walks away is + // never handed a longer window than the presets beside it. + let longestPreset = DurationPreset.allCases.map { $0.milliseconds }.max() ?? 0 + XCTAssertLessThan(CustomDuration.unset.milliseconds, longestPreset) + // And it is not a copy of a preset either, which would make the fresh + // custom rung a duplicate of a rung to its left. + XCTAssertFalse(DurationPreset.allCases.contains { $0.milliseconds == CustomDuration.unset.milliseconds }) + } + + func testTheLegacyPanelStatesTheWindowItReallyGrants() { + // The panel used to say "Allowed for: once" over a note admitting "a few + // minutes". The number now comes from the constant handed to macOS, and + // it is an upper bound because the reuse is a ceiling, not a promise. + XCTAssertEqual( + LegacyDeviceKeyPanel.windowFactLine(reuse: 300), + "Allowed for up to 5 minutes" + ) + XCTAssertEqual( + LegacyDeviceKeyPanel.windowFactLine(reuse: 60), + "Allowed for up to 1 minute" + ) + XCTAssertTrue(LegacyDeviceKeyPanel.formatNote(reuse: 300).contains("up to 5 minutes")) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/EciesCompatTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/EciesCompatTests.swift new file mode 100644 index 000000000..78d5801e3 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/EciesCompatTests.swift @@ -0,0 +1,146 @@ +import XCTest +import CryptoKit +@testable import IdentitySessions + +/// Proves the Swift software ECIES path reads exactly what the TypeScript +/// implementation writes. +/// +/// The fixture is produced by `packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts` +/// using `packages/varlock/src/lib/local-encrypt/crypto.ts`, then checked in. A failure +/// here means the two implementations have drifted, so regenerate the fixture only when +/// the wire format changed on purpose. +final class EciesCompatTests: XCTestCase { + + private struct Vector: Decodable { + let version: UInt8 + let publicKey: String + let privateKeyPkcs8: String + let plaintext: String + let payload: String + } + + private struct Fixture: Decodable { + let identity: Vector + let device: Vector + } + + private func loadFixture() throws -> Fixture { + guard let url = Bundle.module.url(forResource: "ecies-vector", withExtension: "json", subdirectory: "fixtures") else { + XCTFail("missing ecies-vector.json fixture") + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(Fixture.self, from: try Data(contentsOf: url)) + } + + private func privateKey(_ vector: Vector) throws -> P256.KeyAgreement.PrivateKey { + let der = try XCTUnwrap(Data(base64Encoded: vector.privateKeyPkcs8)) + return try IdentityKeyImport.p256KeyAgreementKey(fromPkcs8: der) + } + + // MARK: - Reading what TypeScript wrote + + func testDecryptsIdentityPayloadWrittenByTypeScript() throws { + let fixture = try loadFixture() + let payload = try XCTUnwrap(Data(base64Encoded: fixture.identity.payload)) + XCTAssertEqual(payload.first, Ecies.identityPayloadVersion) + + let decrypted = try Ecies.decrypt( + payload: payload, + using: try privateKey(fixture.identity), + acceptedVersions: [Ecies.identityPayloadVersion] + ) + XCTAssertEqual(String(data: decrypted, encoding: .utf8), fixture.identity.plaintext) + } + + func testDecryptsDevicePayloadWrittenByTypeScript() throws { + let fixture = try loadFixture() + let payload = try XCTUnwrap(Data(base64Encoded: fixture.device.payload)) + XCTAssertEqual(payload.first, Ecies.devicePayloadVersion) + + let decrypted = try Ecies.decrypt( + payload: payload, + using: try privateKey(fixture.device), + acceptedVersions: [Ecies.devicePayloadVersion] + ) + XCTAssertEqual(String(data: decrypted, encoding: .utf8), fixture.device.plaintext) + } + + /// The PKCS#8 in the fixture must yield the same public key the fixture names, + /// which is what makes the HKDF `info` binding line up across implementations. + func testImportedPrivateKeyMatchesFixturePublicKey() throws { + let fixture = try loadFixture() + let key = try privateKey(fixture.identity) + XCTAssertEqual(key.publicKeyX963.base64EncodedString(), fixture.identity.publicKey) + } + + // MARK: - Writing what TypeScript can read + + /// Swift-side encryption of the prompt-secret path, verified by decrypting with + /// the fixture's key. The TS half of this direction is covered by the fixture's + /// own round trip in the generator script. + func testSwiftEncryptionRoundTripsAgainstFixtureKey() throws { + let fixture = try loadFixture() + let recipient = try XCTUnwrap(Data(base64Encoded: fixture.identity.publicKey)) + let secret = "value captured in the daemon, never crossing the socket in the clear" + + let payload = try Ecies.encrypt( + plaintext: Data(secret.utf8), + toPublicKeyData: recipient, + version: Ecies.identityPayloadVersion + ) + XCTAssertEqual(payload.first, Ecies.identityPayloadVersion) + + let decrypted = try Ecies.decrypt( + payload: payload, + using: try privateKey(fixture.identity), + acceptedVersions: [Ecies.identityPayloadVersion] + ) + XCTAssertEqual(String(data: decrypted, encoding: .utf8), secret) + } + + // MARK: - Framing + + func testRejectsUnexpectedVersionByte() throws { + let fixture = try loadFixture() + var payload = try XCTUnwrap(Data(base64Encoded: fixture.identity.payload)) + payload[0] = 0x09 + + XCTAssertThrowsError(try Ecies.decrypt( + payload: payload, + using: try privateKey(fixture.identity), + acceptedVersions: [Ecies.identityPayloadVersion] + )) + } + + func testRejectsTamperedCiphertext() throws { + let fixture = try loadFixture() + var payload = try XCTUnwrap(Data(base64Encoded: fixture.identity.payload)) + payload[payload.count - 20] ^= 0xff + + XCTAssertThrowsError(try Ecies.decrypt( + payload: payload, + using: try privateKey(fixture.identity), + acceptedVersions: [Ecies.identityPayloadVersion] + )) + } + + func testRejectsTruncatedPayload() throws { + XCTAssertThrowsError(try Ecies.parse(payload: Data(repeating: 0, count: 40))) + } + + // MARK: - HKDF + + /// RFC 5869 test case 1, so a refactor of the derivation cannot quietly change + /// what both implementations agree on. + func testHkdfMatchesRfc5869TestCase1() { + let ikm = Data(repeating: 0x0b, count: 22) + let salt = Data((0x00...0x0c).map { UInt8($0) }) + let info = Data((0xf0...0xf9).map { UInt8($0) }) + let okm = Ecies.deriveKey(sharedSecret: ikm, salt: salt, info: info, outputByteCount: 42) + let hex = okm.withUnsafeBytes { Data($0) }.map { String(format: "%02x", $0) }.joined() + XCTAssertEqual( + hex, + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/GrantBreadthTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/GrantBreadthTests.swift new file mode 100644 index 000000000..b20f8c739 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/GrantBreadthTests.swift @@ -0,0 +1,552 @@ +import XCTest +@testable import IdentitySessions + +/// The breadth axis: what an item-scoped grant covers, and what it refuses. +/// +/// The point of these is that item scope is ENFORCEMENT and not description. A +/// grant either opens a ciphertext or it does not, and the answer comes from a +/// digest the daemon computed rather than from anything a caller said about it. +final class GrantBreadthTests: XCTestCase { + private let sessionId = "tty:ttys004" + private let keyId = "varlock-default" + private var ref: SessionGrantRef { SessionGrantRef(sessionId: sessionId, keyId: keyId) } + + private func digest(_ text: String) -> String { + return GrantItemDigest.of(Data(text.utf8)) + } + + // MARK: - Enforcement + + func testItemScopedGrantServesTheCiphertextsItWasApprovedOver() throws { + let table = SessionGrantTable() + let approved = Set([digest("a"), digest("b")]) + table.grant(ref: ref, identityId: "default", scope: .session, coveredItems: approved) + + let served = try table.consume(ref: ref, itemDigests: [digest("a"), digest("b")]) + XCTAssertEqual(served.info.breadth, .listedItems) + XCTAssertEqual(served.info.coveredItemCount, 2) + } + + func testItemScopedGrantRefusesACiphertextItWasNotApprovedOver() { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .session, coveredItems: [digest("a")]) + + XCTAssertThrowsError(try table.consume(ref: ref, itemDigests: [digest("a"), digest("smuggled")])) { error in + guard let error = error as? SessionGrantError else { return XCTFail("wrong error type") } + XCTAssertEqual(error.code, "GRANT_ITEM_NOT_COVERED") + } + } + + /// A refusal must leave the grant exactly as it was. Charging it would spend + /// a `once` grant on a batch that returned nothing, and the caller's next + /// move (ask again) would then be answered with "there is no grant" instead + /// of the delta prompt it was supposed to raise. + func testARefusalNeitherChargesNorDropsTheGrant() throws { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .once, coveredItems: [digest("a")]) + + XCTAssertThrowsError(try table.consume(ref: ref, itemDigests: [digest("b")])) + + let still = try table.consume(ref: ref, itemDigests: [digest("a")]) + XCTAssertEqual(still.info.useCount, 1, "the refused batch must not have been charged") + } + + func testAWholeKeyGrantOpensAnythingOnThatKey() throws { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .session) + + let served = try table.consume(ref: ref, itemDigests: [digest("anything at all")]) + XCTAssertEqual(served.info.breadth, .wholeKey) + XCTAssertNil(served.info.coveredItemCount) + } + + /// Breadth and duration are independent. A narrow grant that has been given + /// a window keeps both halves of what it was given: it still runs out on + /// time, and it still refuses a ciphertext outside its set while it lives. + func testItemScopeSurvivesADurationGrant() throws { + var now: Int64 = 1_000_000 + var monotonic: Int64 = 500 + let table = SessionGrantTable(clock: { now }, monotonicClock: { monotonic }) + table.grant( + ref: ref, + identityId: "default", + scope: .duration, + durationMs: 60_000, + coveredItems: [digest("a")] + ) + + // Inside the window: the listed item opens, an unlisted one does not. + now += 30_000 + monotonic += 30_000 + XCTAssertNoThrow(try table.consume(ref: ref, itemDigests: [digest("a")])) + XCTAssertThrowsError(try table.consume(ref: ref, itemDigests: [digest("b")])) { error in + XCTAssertEqual((error as? SessionGrantError)?.code, "GRANT_ITEM_NOT_COVERED") + } + + // Past the window: even the listed item is gone, and the reason given is + // the expiry rather than the breadth. + now += 40_000 + monotonic += 40_000 + XCTAssertThrowsError(try table.consume(ref: ref, itemDigests: [digest("a")])) { error in + XCTAssertEqual((error as? SessionGrantError)?.code, "SESSION_GRANT_EXPIRED") + } + } + + /// The value cache is never item scoped. A ciphertext the daemon verified + /// against varlock's own cache file is admitted even by a narrow grant, and + /// remembered so the same entry does not re-check on every read. + func testAStructurallyCoveredCiphertextIsAdmittedAndRemembered() throws { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .session, coveredItems: [digest("listed")]) + let cached = digest("cache entry") + + var cacheReads = 0 + let served = try table.consume(ref: ref, itemDigests: [cached], alsoCovered: { + cacheReads += 1 + return [cached] + }) + XCTAssertEqual(served.info.coveredItemCount, 2, "the admitted entry joins the covered set") + + // Second read: covered outright, so the cache file is not consulted again. + _ = try table.consume(ref: ref, itemDigests: [cached], alsoCovered: { + cacheReads += 1 + return [] + }) + XCTAssertEqual(cacheReads, 1) + } + + func testStructuralCoverDoesNotExcuseAnUnrelatedCiphertext() { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .session, coveredItems: [digest("listed")]) + + XCTAssertThrowsError( + try table.consume( + ref: ref, + itemDigests: [digest("somebody else's secret")], + alsoCovered: { [self.digest("cache entry")] } + ) + ) { error in + XCTAssertEqual((error as? SessionGrantError)?.code, "GRANT_ITEM_NOT_COVERED") + } + } + + // MARK: - Planning + + /// A batch carrying something a live narrow grant never covered goes back + /// through the panel, on the same path a brand-new key takes. + func testAnUncoveredCiphertextRaisesADeltaPrompt() { + let live = ExistingGrantSnapshot( + scope: .session, + remainingMs: 4 * 60 * 60 * 1000, + coveredItems: [digest("a")] + ) + let plan = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, itemDigests: [digest("a"), digest("b")])], + requestedScope: .session, + existing: [keyId: live] + ) + XCTAssertTrue(plan.requiresPrompt) + XCTAssertEqual(plan.refreshKeys.map { $0.keyId }, [keyId]) + } + + func testACoveredCiphertextCostsNoPanel() { + let live = ExistingGrantSnapshot( + scope: .session, + remainingMs: 4 * 60 * 60 * 1000, + coveredItems: [digest("a"), digest("b")] + ) + let plan = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, itemDigests: [digest("a")])], + requestedScope: .session, + existing: [keyId: live] + ) + XCTAssertFalse(plan.requiresPrompt) + XCTAssertEqual(plan.coveredKeys.map { $0.keyId }, [keyId]) + } + + func testAWholeKeyGrantCoversAnythingTheBatchBrings() { + let live = ExistingGrantSnapshot(scope: .session, remainingMs: 4 * 60 * 60 * 1000) + let plan = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, itemDigests: [digest("never seen before")])], + requestedScope: .session, + existing: [keyId: live] + ) + XCTAssertFalse(plan.requiresPrompt) + } + + // MARK: - What the panel may offer + + func testTheNarrowChoiceIsOfferedOnlyWhenEveryKeyBroughtItems() { + let withItems = UnlockPlanner.plan( + requested: [ + RequestedKey(keyId: "a", itemDigests: [digest("1")]), + RequestedKey(keyId: "b", itemDigests: [digest("2")]), + ], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual(withItems.offeredBreadths, [.listedItems, .wholeKey]) + XCTAssertEqual(withItems.listedItemCount, 2) + + // One key with nothing to narrow to would get a grant that opens nothing. + let mixed = UnlockPlanner.plan( + requested: [ + RequestedKey(keyId: "a", itemDigests: [digest("1")]), + RequestedKey(keyId: "b"), + ], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual(mixed.offeredBreadths, [.wholeKey]) + XCTAssertFalse(mixed.offersBreadthChoice) + } + + // MARK: - Reading the request + + func testDigestsAreComputedFromPayloadsAndNotTakenFromTheCaller() { + let payload = Data("ciphertext bytes".utf8) + let parsed = UnlockRequestItems.from(payload: [ + "items": ["varlock-default": [payload.base64EncodedString()]], + ]) + XCTAssertEqual(parsed["varlock-default"], [GrantItemDigest.of(payload)]) + } + + func testUnparseableItemsAreDroppedRatherThanTrusted() { + let parsed = UnlockRequestItems.from(payload: [ + "items": ["varlock-default": ["not base64 !!!", 42, Data("ok".utf8).base64EncodedString()]], + ]) + XCTAssertEqual(parsed["varlock-default"]?.count, 1) + } + + func testItemsAreCappedSoOneRequestCannotBindAnUnboundedSet() { + let many = (0..<(UnlockRequestItems.maxItemsPerKey + 50)).map { + Data("payload \($0)".utf8).base64EncodedString() + } + let parsed = UnlockRequestItems.from(payload: ["items": ["varlock-default": many]]) + XCTAssertEqual(parsed["varlock-default"]?.count, UnlockRequestItems.maxItemsPerKey) + } + + // MARK: - What the panel says + + /// The label says what ticking it does, in the words somebody would use for + /// it. The precise work is done by the sentence underneath, not here. + func testTheCheckboxLabelSaysWhatTickingItDoes() { + XCTAssertEqual( + PanelContent.breadthCheckboxLabel(vaultCount: 1), + "Auto-unlock all items in this vault" + ) + XCTAssertEqual( + PanelContent.breadthCheckboxLabel(vaultCount: 3), + "Auto-unlock all items in these vaults" + ) + } + + /// The whole point of the wording. A broad approval is over the VAULT, and + /// the list is what it covers right now rather than what defines it, so a + /// person who reads only this line is not surprised by a thirteenth value + /// later. The narrow sentence gets to say "only", because there the list + /// really is the definition and the daemon enforces it. + func testTheBroadSummaryFramesTheListRatherThanFollowingIt() { + let broad = PanelContent.selectionSummary( + breadth: .wholeKey, itemCount: 12, scope: .session, durationLabel: nil + ) + XCTAssertEqual( + broad, + "Covers anything this vault can open, not just the 12 listed above, until this session ends." + ) + XCTAssertTrue(broad.contains("not just"), "the list must not read as the definition of the grant") + XCTAssertFalse(broad.contains("only")) + } + + func testTheNarrowSummarySaysOnly() { + XCTAssertEqual( + PanelContent.selectionSummary( + breadth: .listedItems, itemCount: 12, scope: .session, durationLabel: nil + ), + "Covers only the 12 values listed above, until this session ends." + ) + XCTAssertEqual( + PanelContent.selectionSummary( + breadth: .listedItems, itemCount: 1, scope: .duration, durationLabel: "4 hours" + ), + "Covers only the 1 value listed above, for 4 hours." + ) + } + + func testTheSummarySpeaksInVaultsAndCarriesTheWindow() { + XCTAssertEqual( + PanelContent.selectionSummary( + breadth: .wholeKey, itemCount: 5, vaultCount: 2, scope: .once, durationLabel: nil + ), + "Covers anything these vaults can open, not just the 5 listed above, for this one read." + ) + // Nothing listed, so nothing to frame. + XCTAssertEqual( + PanelContent.selectionSummary( + breadth: .wholeKey, itemCount: 0, scope: .session, durationLabel: nil + ), + "Covers anything this vault can open, until this session ends." + ) + } + + /// A person picking the narrow option must not walk away believing they + /// restricted the value cache, so the caveat is drawn next to the choice. + func testTheCacheCaveatIsDrawnWhereTheChoiceIs() { + let cacheKey = UnlockKeyDisplay( + valueCount: 12, + sources: [ + UnlockValueSource(kind: .file, path: ".env.local", entries: [.init(name: "A")]), + UnlockValueSource(kind: .cache, reportedItemCount: 4), + ] + ) + let display = UnlockDisplayInfo(keys: [keyId: cacheKey]) + let plan = UnlockPlanner.plan( + requested: [RequestedKey( + keyId: keyId, + itemDigests: [digest("a")], + hasUnlistableSource: true + )], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: "a test"), + display: display + ) + XCTAssertTrue(content.hasUnlistableSource) + XCTAssertTrue(PanelContent.unlistableSourceNote.contains("value cache")) + // True in every state, including `once`, where the grant is narrow and + // there is no checkbox on screen to point at. + XCTAssertFalse( + PanelContent.unlistableSourceNote.lowercased().contains("tick"), + "the caveat must not refer to a control that is sometimes not drawn" + ) + } + + func testNoCaveatWhenThereIsNoChoiceToQualify() { + // Nothing to narrow to, so no narrow option, so nothing to caveat. + let plan = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, hasUnlistableSource: true)], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "a test")) + XCTAssertFalse(content.hasUnlistableSource) + XCTAssertEqual(content.breadths, [.wholeKey]) + } + + /// The count on the narrow label is the daemon's own, not the client's. + func testTheNarrowCountComesFromDigestsAndNotFromTheClientsClaim() { + let display = UnlockDisplayInfo(keys: [keyId: UnlockKeyDisplay(valueCount: 999)]) + let plan = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, itemDigests: [digest("a"), digest("b")])], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: "a test"), + display: display + ) + XCTAssertEqual(content.listedItemCount, 2) + } + + // MARK: - "once" implies narrow + + /// The panel draws no breadth checkbox under `once`, and the grant is narrow + /// whatever the hidden control was last set to. + func testOnceGrantsNarrowWhateverTheCheckboxSaid() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .embedded, defaultBreadth: .wholeKey) + XCTAssertEqual(flow.effectiveBreadth, .wholeKey) + + flow.select(scope: .once, breadth: .wholeKey) + XCTAssertEqual(flow.effectiveBreadth, .listedItems, "once is narrow, ticked box or not") + + // ...and moving back off `once` returns breadth to what it was, rather + // than leaving it stuck narrow because of a choice about time. + flow.select(scope: .session, breadth: .wholeKey) + XCTAssertEqual(flow.effectiveBreadth, .wholeKey) + } + + func testAnApprovalUnderOnceRecordsNoBreadthChoice() { + var flow = ApprovalFlow(defaultScope: .once, presenceMode: .embedded, defaultBreadth: .wholeKey) + guard case .finish(let decision) = flow.apply(.scanSucceeded) else { + return XCTFail("a scan should finish the flow") + } + XCTAssertEqual(decision.breadth, .listedItems, "the grant is narrow") + XCTAssertNil(decision.chosenBreadth, "and the user expressed no opinion about breadth") + } + + func testAnApprovalUnderASessionRecordsTheChoiceThatWasMade() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .embedded, defaultBreadth: .wholeKey) + flow.select(scope: .session, breadth: .listedItems) + guard case .finish(let decision) = flow.apply(.scanSucceeded) else { + return XCTFail("a scan should finish the flow") + } + XCTAssertEqual(decision.breadth, .listedItems) + XCTAssertEqual(decision.chosenBreadth, .listedItems) + } + + /// The summary has to say what the grant covers even where no control is + /// drawn, so `once` is not hidden state. + func testTheSummaryStatesTheBreadthEvenWithNoCheckboxOnScreen() { + XCTAssertEqual( + PanelContent.selectionSummary( + breadth: .listedItems, itemCount: 12, scope: .once, durationLabel: nil + ), + "Covers only the 12 values listed above, for this one read." + ) + } + + /// Narrow never means an empty set. A `once` approval on a key that brought + /// no digests still opens the read it was approved for, rather than refusing + /// everything on a technicality. + func testANarrowGrantWithNothingToNarrowToStillOpensItsRead() throws { + let table = SessionGrantTable() + // What the manager does for a key with no digests, whatever the breadth. + table.grant(ref: ref, identityId: "default", scope: .once, coveredItems: nil) + XCTAssertNoThrow(try table.consume(ref: ref, itemDigests: [digest("whatever was in the batch")])) + } + + func testOnceIsNarrowEvenWhenThePanelOfferedNoBreadthAtAll() { + // A batch where one key brought no digests offers no breadth control, + // but `once` still grants narrow: the clamp guards a panel ANSWER, and + // under once there was none to clamp. + let decision = PanelDecision(approved: true, scope: .once, breadth: .listedItems) + let granted = UnlockBreadthSelection.granted(by: decision, offered: [.wholeKey]) + XCTAssertEqual(granted.breadth(forVault: "local"), .listedItems) + } + + func testAnAnswerOnTheOtherScopesIsStillClampedToWhatWasOffered() { + let decision = PanelDecision( + approved: true, scope: .session, breadth: .listedItems, chosenBreadth: .listedItems + ) + XCTAssertEqual( + UnlockBreadthSelection.granted(by: decision, offered: [.wholeKey]).breadth(forVault: "local"), + .wholeKey + ) + XCTAssertEqual( + UnlockBreadthSelection + .granted(by: decision, offered: [.listedItems, .wholeKey]) + .breadth(forVault: "local"), + .listedItems + ) + } + + // MARK: - The vault boundary + + /// Not a control. However broad the approval, it stops at the vaults the + /// panel showed: a key in a vault nobody was shown is a key nobody said yes + /// to. Today every key is in one implicit local vault, so this mostly holds + /// trivially, but it is written as a vault rule because that is the only + /// version that stays defensible once a second vault exists. + func testABroadGrantDoesNotReachAVaultThatWasNotShown() { + let live = ExistingGrantSnapshot( + scope: .session, + remainingMs: 4 * 60 * 60 * 1000, + coveredItems: nil, + vaultId: "local" + ) + let sameVault = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, vaultId: "local")], + requestedScope: .session, + existing: [keyId: live] + ) + XCTAssertFalse(sameVault.requiresPrompt) + + let crossedVault = UnlockPlanner.plan( + requested: [RequestedKey(keyId: keyId, vaultId: "team-production")], + requestedScope: .session, + existing: [keyId: live] + ) + XCTAssertTrue(crossedVault.requiresPrompt, "crossing a vault always asks") + XCTAssertEqual(crossedVault.refreshKeys.map { $0.keyId }, [keyId]) + } + + func testTheVaultBoundaryHoldsWhateverTheBreadthWas() { + // The broadest, longest grant there is. + let live = ExistingGrantSnapshot( + scope: .session, + remainingMs: SessionGrantTable.maxGrantMs, + coveredItems: nil, + vaultId: "local" + ) + XCTAssertFalse(UnlockPlanner.covers( + live: live, + requestedScope: .once, + requestedDurationMs: nil, + requestedVaultId: "team-production" + )) + XCTAssertFalse(VaultBoundary.covers(approvedVaultId: nil, requestedVaultId: "local")) + XCTAssertTrue(VaultBoundary.covers(approvedVaultId: "local", requestedVaultId: "local")) + } + + func testAGrantRemembersTheVaultItWasApprovedOver() { + let table = SessionGrantTable() + table.grant(ref: ref, identityId: "default", scope: .session, vaultId: "team-production") + XCTAssertEqual(table.vaultId(ref: ref), "team-production") + XCTAssertEqual(table.liveGrant(ref: ref)?.vaultId, "team-production") + } + + /// A key with no vault of its own is in the one implicit local vault, and a + /// caller that names only a label still gets a boundary between labels. + func testAVaultIdFallsBackToTheLabelAndThenToTheLocalVault() { + XCTAssertEqual(UnlockKeyDisplay().vaultId, VaultBoundary.localVaultId) + XCTAssertEqual(UnlockKeyDisplay(vaultLabel: "Production").vaultId, "label:production") + XCTAssertEqual(UnlockKeyDisplay(vaultLabel: "Production", vaultId: "v_42").vaultId, "v_42") + } + + // MARK: - One control, per-vault resolution + + /// The checkbox sets every vault today. What reads it asks per vault anyway, + /// so a per-vault control later is a change to the panel and nothing else. + func testOneAnswerResolvesForEveryVault() { + let broad = UnlockBreadthSelection.uniform(.wholeKey) + XCTAssertEqual(broad.breadth(forVault: "local"), .wholeKey) + XCTAssertEqual(broad.breadth(forVault: "team-production"), .wholeKey) + XCTAssertEqual(broad.narrowest, .wholeKey) + + let narrow = UnlockBreadthSelection.uniform(.listedItems) + XCTAssertEqual(narrow.breadth(forVault: "anything"), .listedItems) + XCTAssertEqual(narrow.narrowest, .listedItems) + } + + func testAPerVaultAnswerIsAlreadyHonouredWhereOneIsGiven() { + // Not reachable from the panel yet, and deliberately supported by the + // model: broad on your own local vault, narrow on a shared one. + let mixed = UnlockBreadthSelection( + fallback: .wholeKey, + byVault: ["team-production": .listedItems] + ) + XCTAssertEqual(mixed.breadth(forVault: "local"), .wholeKey) + XCTAssertEqual(mixed.breadth(forVault: "team-production"), .listedItems) + XCTAssertEqual(mixed.narrowest, .listedItems, "a summary must never claim less caution than was applied") + } + + func testAnAnswerIsClampedToWhatThePanelOffered() { + let clamped = UnlockBreadthSelection + .uniform(.listedItems) + .clamped(to: [.wholeKey]) + XCTAssertEqual(clamped.breadth(forVault: "local"), .wholeKey) + } + + func testThePlanCountsTheVaultsItIsOver() { + let plan = UnlockPlanner.plan( + requested: [ + RequestedKey(keyId: "a", itemDigests: [digest("1")], vaultId: "local"), + RequestedKey(keyId: "b", itemDigests: [digest("2")], vaultId: "team-production"), + ], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual(plan.vaultIds, ["local", "team-production"]) + } + + // MARK: - The rule that cannot drift + + func testItemScopeReachesFilesAndNeverTheValueCache() { + XCTAssertTrue(UnlockValueSource.Kind.file.isItemScopable) + XCTAssertFalse(UnlockValueSource.Kind.cache.isItemScopable) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/InvocationEvidenceTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/InvocationEvidenceTests.swift new file mode 100644 index 000000000..18a2d42ae --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/InvocationEvidenceTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import IdentitySessions +import SessionScoping + +/// What the panel says about how varlock came to be running. +/// +/// The command lines are the kernel's and the mode is the client's, and these +/// pin down what happens when the two tell different stories: the kernel wins, +/// and the disagreement is written down rather than smoothed over. +final class InvocationEvidenceTests: XCTestCase { + private func chain(_ hops: [ExecutionHop]) -> ExecutionChain { + return ExecutionChain(hops: hops) + } + + private func varlockHop(invocation: String, runTarget: String? = nil) -> ExecutionHop { + return ExecutionHop( + pid: 400, + name: "varlock", + invocation: invocation, + runTarget: runTarget, + isRequester: true + ) + } + + func testEveryLineKeepsTheCommandApartFromOurWordsAboutIt() { + // The panel draws the command as a command (a tinted strip, a dimmed + // sigil, a monospaced face), so the split comes from here rather than + // from the view picking a finished sentence apart again. + let typed = InvocationEvidence.note( + chain: chain([varlockHop(invocation: "varlock run -- npm run build", runTarget: "npm run build")]), + claimed: .cli + ).commandLines + XCTAssertEqual(typed.map(\.command), ["varlock run -- npm run build", "npm run build"]) + XCTAssertEqual(typed.map(\.sigil), ["$", "\u{21B3}"]) + XCTAssertEqual(typed.map(\.suffix), [nil, "receives these values"]) + + let hosted = InvocationEvidence.note( + chain: chain([ + ExecutionHop(pid: 300, name: "next", invocation: "next dev"), + varlockHop(invocation: "varlock load"), + ]), + claimed: .autoLoad + ).commandLines + // The host's command is a command too, and gets the same treatment. + XCTAssertEqual(hosted.map(\.command), ["next dev"]) + XCTAssertEqual(hosted.map(\.prefix), ["auto-loaded inside"]) + XCTAssertNil(hosted.first?.sigil) + } + + func testATypedCommandIsShownAsOne() { + let note = InvocationEvidence.note( + chain: chain([ + ExecutionHop(pid: 200, name: "zsh", invocation: "-zsh"), + varlockHop(invocation: "varlock load"), + ]), + claimed: .cli + ) + + XCTAssertEqual(note.kind, .typed("varlock load")) + XCTAssertEqual(note.lines, ["$ varlock load"]) + XCTAssertNil(note.disagreement) + } + + func testARunSaysWhichCommandReceivesTheValues() { + let note = InvocationEvidence.note( + chain: chain([varlockHop(invocation: "varlock run -- npm run build", runTarget: "npm run build")]), + claimed: .cli + ) + + XCTAssertEqual(note.target, "npm run build") + XCTAssertEqual(note.lines, [ + "$ varlock run -- npm run build", + "\u{21B3} npm run build receives these values", + ]) + } + + func testAnAutoLoadNamesTheHostCommand() { + let note = InvocationEvidence.note( + chain: chain([ + ExecutionHop(pid: 200, name: "zsh", invocation: "-zsh"), + ExecutionHop(pid: 300, name: "next", invocation: "next dev"), + varlockHop(invocation: "varlock load"), + ]), + claimed: .autoLoad + ) + + // varlock's own line here is a command nobody typed, so the host's is the + // one worth showing. + XCTAssertEqual(note.kind, .hosted("next dev")) + XCTAssertEqual(note.lines, ["auto-loaded inside next dev"]) + XCTAssertNil(note.disagreement) + } + + func testALibraryInsideAHostIsHostedWhateverTheClientClaims() { + // The peer is not varlock's CLI at all, so nobody typed varlock here. + let note = InvocationEvidence.note( + chain: chain([ + ExecutionHop(pid: 200, name: "zsh", invocation: "-zsh"), + ExecutionHop(pid: 300, name: "vite.js", invocation: "vite dev", isRequester: true), + ]), + claimed: .cli + ) + + XCTAssertEqual(note.kind, .hosted("vite dev")) + XCTAssertEqual(note.disagreement, "client claimed cli, but the peer is vite.js rather than varlock's CLI") + } + + func testAnAutoLoadClaimWithNothingAboveItIsOverruled() { + // A claim that cannot be true: the CLI's parent is a shell, so there is + // no program that could have loaded it. + let note = InvocationEvidence.note( + chain: chain([ + ExecutionHop(pid: 100, name: "iTerm2", isLauncher: true), + ExecutionHop(pid: 200, name: "zsh", invocation: "-zsh"), + varlockHop(invocation: "varlock load"), + ]), + claimed: .autoLoad + ) + + XCTAssertEqual(note.kind, .typed("varlock load")) + XCTAssertEqual( + note.disagreement, + "client claimed auto-load, but nothing above the CLI could have loaded it" + ) + } + + func testNothingIsSaidWhenNothingCouldBeRead() { + let note = InvocationEvidence.note(chain: .empty, claimed: .cli) + XCTAssertEqual(note.kind, .unknown) + XCTAssertTrue(note.lines.isEmpty) + } + + func testAMissingClaimIsTreatedAsTyped() { + // No mode reported at all: the kernel shows a varlock CLI, and inventing + // a host it never named would be worse than saying what is there. + let note = InvocationEvidence.note( + chain: chain([varlockHop(invocation: "varlock load")]), + claimed: nil + ) + XCTAssertEqual(note.kind, .typed("varlock load")) + XCTAssertNil(note.disagreement) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/KeyAuthRecordTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/KeyAuthRecordTests.swift new file mode 100644 index 000000000..e94dff5f4 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/KeyAuthRecordTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import IdentitySessions + +/// The sidecar that `status` reads to answer "does this key need a person?". +/// +/// The routing on the other side of that answer is real: a key reported as not +/// needing auth stops going through the daemon at all. So the defaults matter +/// more than the happy path, and that is most of what is pinned here. +final class KeyAuthRecordTests: XCTestCase { + func testMissingFileIsGatedAndStandard() { + let record = KeyAuthRecord(json: nil) + XCTAssertTrue(record.requireAuth) + XCTAssertEqual(record.policy, .standard) + } + + func testFileWrittenBeforeRequireAuthExistedReadsAsGated() { + // the shape the store wrote when it only recorded the every-time policy + let record = KeyAuthRecord(json: ["version": 1, "authMode": "every-time"]) + XCTAssertTrue(record.requireAuth, "an absent requireAuth must never drop a prompt") + XCTAssertEqual(record.policy, .everyTime) + } + + func testNoAuthKeyIsRecordedAsUngated() { + let record = KeyAuthRecord(json: ["version": 1, "authMode": "standard", "requireAuth": false]) + XCTAssertFalse(record.requireAuth) + XCTAssertEqual(record.policy, .standard) + } + + func testUnrecognizedAuthModeFallsBackToStandard() { + let record = KeyAuthRecord(json: ["version": 1, "authMode": "sometimes", "requireAuth": true]) + XCTAssertEqual(record.policy, .standard) + XCTAssertTrue(record.requireAuth) + } + + func testRoundTripsThroughItsJsonForm() throws { + for original in [ + KeyAuthRecord(policy: .standard, requireAuth: true), + KeyAuthRecord(policy: .standard, requireAuth: false), + KeyAuthRecord(policy: .everyTime, requireAuth: true), + ] { + // through real serialization, since that is what the sidecar stores + let data = try JSONSerialization.data(withJSONObject: original.jsonObject) + let parsed = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + XCTAssertEqual(KeyAuthRecord(json: parsed), original) + XCTAssertEqual(parsed["version"] as? Int, KeyAuthRecord.fileVersion) + } + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/MachineConfigEditTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/MachineConfigEditTests.swift new file mode 100644 index 000000000..5a0c37cce --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/MachineConfigEditTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import IdentitySessions + +/// Editing `sessions.lockOn` from the menu, without eating the rest of the file. +/// +/// The config file is shared with telemetry and with whatever varlock adds next, +/// and people edit it by hand. A menu click that dropped a field would be a data +/// loss bug that nobody notices until the setting it lost mattered. +final class MachineConfigEditTests: XCTestCase { + + private func object(_ data: Data) throws -> [String: Any] { + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + func testWritesTheFieldIntoAnEmptyConfig() throws { + let written = try MachineConfigEdit.settingLockOn(.screenLock, in: nil) + let sessions = try XCTUnwrap(object(written)["sessions"] as? [String: Any]) + XCTAssertEqual(sessions["lockOn"] as? String, "screenLock") + } + + func testKeepsEveryOtherKeyInTheFile() throws { + let existing = Data(#""" + { + "anonymousId": "1a2b3c", + "telemetryDisabled": true, + "nested": { "deep": [1, 2, 3] }, + "sessions": { "lockOn": "sleep", "somethingElse": 7 } + } + """#.utf8) + + let root = try object(try MachineConfigEdit.settingLockOn(.never, in: existing)) + XCTAssertEqual(root["anonymousId"] as? String, "1a2b3c") + XCTAssertEqual(root["telemetryDisabled"] as? Bool, true) + XCTAssertEqual((root["nested"] as? [String: Any])?["deep"] as? [Int], [1, 2, 3]) + + let sessions = try XCTUnwrap(root["sessions"] as? [String: Any]) + XCTAssertEqual(sessions["lockOn"] as? String, "none") + XCTAssertEqual(sessions["somethingElse"] as? Int, 7) + } + + func testAddsTheSessionsSectionWithoutTouchingTheRest() throws { + let existing = Data(#"{"anonymousId":"1a2b3c"}"#.utf8) + let root = try object(try MachineConfigEdit.settingLockOn(.sleep, in: existing)) + XCTAssertEqual(root["anonymousId"] as? String, "1a2b3c") + XCTAssertEqual((root["sessions"] as? [String: Any])?["lockOn"] as? String, "sleep") + } + + func testEmptyFileIsTreatedAsNoConfig() throws { + let root = try object(try MachineConfigEdit.settingLockOn(.sleep, in: Data())) + XCTAssertEqual(root.count, 1) + } + + /// Better to tell the user their file is broken than to replace it with a + /// one-key object and lose whatever they had written. + func testRefusesToOverwriteAFileItCannotParse() { + XCTAssertThrowsError(try MachineConfigEdit.settingLockOn(.sleep, in: Data("{ not json".utf8))) { error in + XCTAssertEqual(error as? MachineConfigEdit.EditError, .unparseable) + } + } + + func testRefusesAFileThatIsNotAnObject() { + XCTAssertThrowsError(try MachineConfigEdit.settingLockOn(.sleep, in: Data("[1,2,3]".utf8))) { error in + XCTAssertEqual(error as? MachineConfigEdit.EditError, .notAnObject) + } + } + + func testOutputIsReadableAndEndsWithANewline() throws { + let written = try MachineConfigEdit.settingLockOn(.sleep, in: Data(#"{"anonymousId":"x"}"#.utf8)) + let text = try XCTUnwrap(String(data: written, encoding: .utf8)) + XCTAssertTrue(text.hasSuffix("}\n")) + XCTAssertTrue(text.contains("\n "), "expected pretty-printed output") + } + + /// What is written has to be what the resolver reads back, or the menu's + /// checkmark would disagree with the daemon's behavior. + func testWhatIsWrittenIsWhatTheResolverReads() throws { + for policy in SessionLockPolicy.allCases { + let written = try MachineConfigEdit.settingLockOn(policy, in: nil) + XCTAssertEqual( + LockPolicyResolution.machineLockPolicy(fromConfigData: written, warn: { _ in }), + policy + ) + } + } +} + +extension MachineConfigEdit.EditError: Equatable { + public static func == (lhs: MachineConfigEdit.EditError, rhs: MachineConfigEdit.EditError) -> Bool { + switch (lhs, rhs) { + case (.unparseable, .unparseable), (.notAnObject, .notAnObject): return true + default: return false + } + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PanelGlyphTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PanelGlyphTests.swift new file mode 100644 index 000000000..766c68888 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PanelGlyphTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import IdentitySessions + +/// What the panel's glyph is allowed to say. +/// +/// The load-bearing promise is the negative one: the glyph must never breathe as +/// though a finger would be read, unless a presence check is genuinely running. +/// A panel that looked armed while nothing was listening is exactly the failure +/// this whole feature spent several rounds chasing, and it would be cheap to +/// reintroduce as a purely cosmetic change. +final class PanelGlyphTests: XCTestCase { + + private func embedded(default scope: SessionGrantScope = .session) -> ApprovalFlow { + return ApprovalFlow(defaultScope: scope, presenceMode: .embedded) + } + + // MARK: - State mapping + + func testTheGlyphOnlyPulsesWhileSomethingIsActuallyArmed() { + var flow = embedded() + XCTAssertEqual(flow.glyphState, .idle, "nothing has started yet") + + _ = flow.start() + XCTAssertEqual(flow.state, .scanning) + XCTAssertEqual(flow.glyphState, .armed) + } + + func testAFailedScanReportsFailedRatherThanStillArmed() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanFailed) + + // The sensor is not listening again until the user asks, so the glyph + // must not go back to promising that it is. + XCTAssertEqual(flow.glyphState, .failed) + XCTAssertEqual( + PanelGlyph.effect(for: .failed, reduceMotion: false), + .shakeThenStill, + "shake, then rest: not shake, then resume pulsing" + ) + } + + func testArmingAgainAfterAFailureGoesBackToArmed() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanFailed) + _ = flow.apply(.confirmPressed) + + XCTAssertEqual(flow.glyphState, .armed) + } + + func testApprovalReportsApproved() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.scanSucceeded) + + XCTAssertEqual(flow.glyphState, .approved) + } + + func testARefusalIsNotDressedUpAsAnything() { + var flow = embedded() + _ = flow.start() + _ = flow.apply(.cancelPressed) + + XCTAssertEqual(flow.glyphState, .idle) + } + + func testTheButtonDrivenModesNeverPulse() { + for mode in [ApprovalPresenceMode.systemDialog, .none] { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: mode) + _ = flow.start() + XCTAssertEqual(flow.glyphState, .idle, "\(mode) waits for a button, so nothing is armed") + + _ = flow.apply(.confirmPressed) + XCTAssertEqual( + flow.glyphState, + .idle, + "\(mode) raises the system's own dialog, which carries its own affordance" + ) + } + } + + func testAnUnattendedApprovalWithNoPresenceCheckStaysStill() { + var flow = ApprovalFlow(defaultScope: .session, presenceMode: .none) + _ = flow.start() + _ = flow.apply(.confirmPressed) + + // Approved by a button press, not by a scan: nothing to celebrate on the + // glyph, which is not even shown in this mode. + XCTAssertEqual(flow.glyphState, .idle) + } + + // MARK: - Effects + + func testEachStateHasItsOwnEffect() { + XCTAssertEqual(PanelGlyph.effect(for: .idle, reduceMotion: false), .still) + XCTAssertEqual(PanelGlyph.effect(for: .armed, reduceMotion: false), .pulse) + XCTAssertEqual(PanelGlyph.effect(for: .failed, reduceMotion: false), .shakeThenStill) + XCTAssertEqual(PanelGlyph.effect(for: .approved, reduceMotion: false), .successPop) + } + + func testReduceMotionDropsEveryMovement() { + for state in [PanelGlyphState.idle, .armed, .failed, .approved] { + let effect = PanelGlyph.effect(for: state, reduceMotion: true) + XCTAssertFalse(effect.isAnimated, "\(state) still moved under reduce motion") + } + } + + func testReduceMotionStillTellsTheStatesApart() { + // Dropping the movement must not flatten four meanings into one picture. + let effects = [PanelGlyphState.idle, .armed, .failed, .approved] + .map { PanelGlyph.effect(for: $0, reduceMotion: true) } + XCTAssertEqual(Set(effects).count, effects.count, "each state needs its own still form") + XCTAssertEqual(PanelGlyph.effect(for: .idle, reduceMotion: true), .still) + XCTAssertEqual(PanelGlyph.effect(for: .armed, reduceMotion: true), .armedStill) + XCTAssertEqual(PanelGlyph.effect(for: .failed, reduceMotion: true), .failedStill) + XCTAssertEqual(PanelGlyph.effect(for: .approved, reduceMotion: true), .successStill) + } + + func testOnlyTheMovingEffectsCountAsAnimated() { + XCTAssertTrue(PanelGlyphEffect.pulse.isAnimated) + XCTAssertTrue(PanelGlyphEffect.shakeThenStill.isAnimated) + XCTAssertTrue(PanelGlyphEffect.successPop.isAnimated) + XCTAssertFalse(PanelGlyphEffect.still.isAnimated) + XCTAssertFalse(PanelGlyphEffect.armedStill.isAnimated) + XCTAssertFalse(PanelGlyphEffect.failedStill.isAnimated) + XCTAssertFalse(PanelGlyphEffect.successStill.isAnimated) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PeerPostureTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PeerPostureTests.swift new file mode 100644 index 000000000..75ee41719 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/PeerPostureTests.swift @@ -0,0 +1,232 @@ +import XCTest +import SessionScoping +@testable import IdentitySessions + +/// Which peers the daemon will talk to, decided on facts rather than on a live +/// socket, so the whole matrix runs headlessly. +/// +/// The gating is the delicate part. A development daemon has to keep working +/// while being driven by an unhardened `bun` from a working tree, and a signed +/// release one must not quietly stop checking. These tests pin both ends of that. +final class PeerPostureTests: XCTestCase { + + private func facts( + traced: Bool = false, + hardened: Bool = true, + valid: Bool = true, + readable: Bool = true + ) -> PeerPostureFacts { + return PeerPostureFacts( + isTraced: traced, + hasHardenedRuntime: hardened, + signatureValid: valid, + isReadable: readable + ) + } + + private func config(_ value: String) -> Data { + return Data(#"{"anonymousId":"abc","sessions":{"peerPosture":"\#(value)"}}"#.utf8) + } + + // MARK: - Evaluating + + func testCleanPeerPassesEverything() { + let outcome = PeerPostureEvaluator.evaluate(facts: facts(), requirements: .strict) + XCTAssertEqual(outcome, .clean) + XCTAssertTrue(outcome.isAllowed) + } + + func testTracedPeerIsRejectedWhenTheCheckBites() { + let outcome = PeerPostureEvaluator.evaluate(facts: facts(traced: true), requirements: .signedRelease) + XCTAssertEqual(outcome.rejection, .debuggerAttached) + XCTAssertFalse(outcome.isAllowed) + } + + func testUnhardenedPeerIsRejectedOnlyUnderStrict() { + let unhardened = facts(hardened: false) + XCTAssertEqual( + PeerPostureEvaluator.evaluate(facts: unhardened, requirements: .strict).rejection, + .hardenedRuntimeMissing + ) + + let lenient = PeerPostureEvaluator.evaluate(facts: unhardened, requirements: .signedRelease) + XCTAssertNil(lenient.rejection) + XCTAssertEqual(lenient.warnings, [.hardenedRuntimeMissing]) + } + + /// A debugger is the more useful thing to be told about, so it is the one + /// reported when a peer fails both checks at once. + func testDebuggerIsNamedAheadOfTheHardeningCheck() { + let outcome = PeerPostureEvaluator.evaluate( + facts: facts(traced: true, hardened: false), + requirements: .strict + ) + XCTAssertEqual(outcome.rejection, .debuggerAttached) + } + + func testUnreadablePostureTakesTheHardeningSeverity() { + let unknown = facts(readable: false) + XCTAssertEqual( + PeerPostureEvaluator.evaluate(facts: unknown, requirements: .strict).rejection, + .postureUnreadable + ) + + let lenient = PeerPostureEvaluator.evaluate(facts: unknown, requirements: .signedRelease) + XCTAssertNil(lenient.rejection) + XCTAssertEqual(lenient.warnings, [.postureUnreadable]) + } + + func testWarningsAreStillCollectedWhenSomethingElseRejects() { + let outcome = PeerPostureEvaluator.evaluate( + facts: facts(traced: true, hardened: false), + requirements: PeerPostureRequirements(debugger: .reject, hardenedRuntime: .warn) + ) + XCTAssertEqual(outcome.rejection, .debuggerAttached) + XCTAssertEqual(outcome.warnings, [.hardenedRuntimeMissing]) + } + + // MARK: - What each build demands + + /// The allowance dev builds run under. A `swift build` daemon is ad-hoc + /// signed, so it never refuses a peer on posture, and the repo keeps working + /// when driven by an unhardened runtime. + func testDevelopmentDaemonRefusesNobody() { + let requirements = PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: false), + machineConfigData: nil, + warn: { _ in } + ) + XCTAssertEqual(requirements, .development) + + let outcome = PeerPostureEvaluator.evaluate( + facts: facts(traced: true, hardened: false), + requirements: requirements + ) + XCTAssertNil(outcome.rejection) + XCTAssertEqual(outcome.warnings, [.debuggerAttached, .hardenedRuntimeMissing]) + } + + /// And the release build does not inherit that allowance. + func testSignedReleaseDaemonStillRejectsTracedPeers() { + let requirements = PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: true), + machineConfigData: nil, + warn: { _ in } + ) + XCTAssertEqual(requirements, .signedRelease) + XCTAssertEqual(requirements.debugger, .reject) + } + + // MARK: - Config + + func testStrictConfigTightensADevelopmentDaemon() { + let requirements = PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: false), + machineConfigData: config("strict"), + warn: { _ in } + ) + XCTAssertEqual(requirements, .strict) + } + + func testWarnConfigLoosensAReleaseDaemon() { + let requirements = PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: true), + machineConfigData: config("warn"), + warn: { _ in } + ) + XCTAssertEqual(requirements, .warnOnly) + } + + func testDefaultConfigValueKeepsTheBuildDefault() { + XCTAssertEqual( + PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: true), + machineConfigData: config("default"), + warn: { _ in } + ), + .signedRelease + ) + } + + func testInvalidConfigValueIsReportedAndIgnored() { + var warnings: [String] = [] + let requirements = PeerPostureEvaluator.resolve( + selfFacts: facts(hardened: true), + machineConfigData: config("whatever"), + warn: { warnings.append($0) } + ) + XCTAssertEqual(requirements, .signedRelease) + XCTAssertEqual(warnings.count, 1) + XCTAssertTrue(warnings[0].contains("peerPosture")) + } + + func testMissingOrUnparseableConfigIsNotAnError() { + var warnings: [String] = [] + XCTAssertEqual( + PeerPostureEvaluator.resolve(selfFacts: facts(), machineConfigData: nil, warn: { warnings.append($0) }), + .signedRelease + ) + XCTAssertEqual( + PeerPostureEvaluator.resolve( + selfFacts: facts(), + machineConfigData: Data(#"{"sessions":{"lockOn":"sleep"}}"#.utf8), + warn: { warnings.append($0) } + ), + .signedRelease + ) + XCTAssertTrue(warnings.isEmpty) + + XCTAssertEqual( + PeerPostureEvaluator.resolve( + selfFacts: facts(), + machineConfigData: Data("{ not json".utf8), + warn: { warnings.append($0) } + ), + .signedRelease + ) + XCTAssertEqual(warnings.count, 1) + } + + // MARK: - Reporting + + func testEachCheckHasItsOwnCodeAndLine() { + let all: [PeerPostureViolation] = [.debuggerAttached, .hardenedRuntimeMissing, .postureUnreadable] + let codes = all.map(\.code) + XCTAssertEqual(Set(codes).count, all.count) + XCTAssertEqual(codes, [ + "PEER_DEBUGGER_ATTACHED", + "PEER_HARDENED_RUNTIME_MISSING", + "PEER_POSTURE_UNREADABLE", + ]) + + let lines = all.map { $0.stderrLine(pid: 42, path: "/usr/local/bin/node", severity: .reject) } + XCTAssertEqual(Set(lines).count, all.count) + for line in lines { + XCTAssertTrue(line.contains("pid=42")) + XCTAssertTrue(line.contains("/usr/local/bin/node")) + XCTAssertTrue(line.hasSuffix("\n")) + } + } + + func testWarnLinesSayTheConnectionWasServed() { + let rejected = PeerPostureViolation.debuggerAttached.stderrLine(pid: 1, path: "x", severity: .reject) + let warned = PeerPostureViolation.debuggerAttached.stderrLine(pid: 1, path: "x", severity: .warn) + XCTAssertTrue(rejected.contains("rejected")) + XCTAssertTrue(warned.contains("allowed")) + } + + // MARK: - Live reader + + /// The reader has to work against a real process, so it is checked against the + /// one process a test can be sure about: itself, untraced. + func testReaderDescribesThisProcess() { + let selfFacts = PeerPostureReader().selfFacts() + XCTAssertTrue(selfFacts.isReadable) + XCTAssertFalse(selfFacts.isTraced) + } + + func testReaderReportsAnUnknownPidAsUnreadable() { + // A pid that cannot exist, so csops has nothing to answer about. + XCTAssertEqual(PeerPostureReader().facts(forPid: -1), .unreadable) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionGrantTableTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionGrantTableTests.swift new file mode 100644 index 000000000..fc7716b7c --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionGrantTableTests.swift @@ -0,0 +1,329 @@ +import XCTest +@testable import IdentitySessions + +/// Lifetime rules for unlock sessions, on a clock the test controls. +/// +/// These encode the promises the daemon makes about how long it may hold an +/// identity key: a grant belongs to one (session x key) pair, a `once` grant +/// serves exactly one call, nothing outlives the 12h cap measured from the +/// session's first unlock, and the last grant leaving a session is what tells +/// the daemon to crypto-erase that session's key. +final class SessionGrantTableTests: XCTestCase { + + /// The settable clock, as a user would read it. + private var now: Int64 = 1_700_000_000_000 + /// The monotonic clock, which starts somewhere unrelated on purpose: nothing + /// may assume the two share an origin. + private var monotonicNow: Int64 = 42_000_000 + + private func makeTable() -> SessionGrantTable { + return SessionGrantTable( + clock: { [unowned self] in self.now }, + monotonicClock: { [unowned self] in self.monotonicNow } + ) + } + + private func ref(_ session: String, _ key: String) -> SessionGrantRef { + return SessionGrantRef(sessionId: session, keyId: key) + } + + /// Time passing normally: both clocks move together. + private func advance(hours: Double) { + advance(ms: Int64(hours * 60 * 60 * 1000)) + } + + private func advance(ms: Int64) { + now += ms + monotonicNow += ms + } + + // MARK: - Granting + + func testGrantIsScopedToSessionAndKey() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + XCTAssertNoThrow(try table.consume(ref: ref("tty:a", "k1"))) + // same key, different session + XCTAssertThrowsError(try table.consume(ref: ref("tty:b", "k1"))) + // same session, different key + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k2"))) + } + + func testSessionScopedGrantSurvivesRepeatedUse() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + for expectedCount in 1...5 { + let result = try table.consume(ref: ref("tty:a", "k1")) + XCTAssertEqual(result.info.useCount, expectedCount) + XCTAssertEqual(result.info.lastUsedAt, now) + } + XCTAssertTrue(table.isSessionLive("tty:a")) + } + + func testOnceGrantIsSpentAfterASingleCall() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .once) + + let result = try table.consume(ref: ref("tty:a", "k1")) + XCTAssertEqual(result.info.useCount, 1) + XCTAssertEqual(result.change.dropped, 1) + XCTAssertEqual(result.change.closedSessions, ["tty:a"]) + + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) + XCTAssertFalse(table.isSessionLive("tty:a")) + } + + func testOnceGrantDoesNotCloseSessionThatStillHoldsAnotherKey() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .once) + table.grant(ref: ref("tty:a", "k2"), identityId: "default", scope: .session) + + let result = try table.consume(ref: ref("tty:a", "k1")) + XCTAssertEqual(result.change.closedSessions, []) + XCTAssertTrue(table.isSessionLive("tty:a")) + } + + // MARK: - Expiry + + func testDurationGrantExpiresAtItsWindow() throws { + let table = makeTable() + let info = table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 60_000) + XCTAssertEqual(info.expiresAt, now + 60_000) + + advance(ms: 59_000) + XCTAssertNoThrow(try table.consume(ref: ref("tty:a", "k1"))) + + advance(ms: 2_000) + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) { error in + XCTAssertEqual((error as? SessionGrantError)?.code, "SESSION_GRANT_EXPIRED") + } + XCTAssertFalse(table.isSessionLive("tty:a")) + } + + func testDurationLongerThanCapIsClamped() { + let table = makeTable() + let info = table.grant( + ref: ref("tty:a", "k1"), + identityId: "default", + scope: .duration, + durationMs: 48 * 60 * 60 * 1000 + ) + XCTAssertEqual(info.expiresAt, now + SessionGrantTable.maxGrantMs) + } + + func testSessionScopedGrantStillDiesAtTheHardCap() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + advance(hours: 11.9) + XCTAssertNoThrow(try table.consume(ref: ref("tty:a", "k1"))) + + advance(hours: 0.2) + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) + XCTAssertFalse(table.hasLiveSessions()) + } + + /// Re-granting must not let a caller ratchet a session past its cap: the cap is + /// measured from the first unlock, not from the latest grant. + func testRegrantingDoesNotExtendTheSessionCap() throws { + let table = makeTable() + let opened = now + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + advance(hours: 6) + let second = table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + XCTAssertEqual(second.sessionUnlockedAt, opened) + XCTAssertEqual(second.expiresAt, opened + SessionGrantTable.maxGrantMs) + + advance(hours: 6.1) + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) + } + + func testExpiredSessionCanBeUnlockedAgainWithAFreshCap() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + advance(hours: 13) + XCTAssertFalse(table.hasLiveSessions()) + + let reopened = table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + XCTAssertEqual(reopened.sessionUnlockedAt, now) + XCTAssertNoThrow(try table.consume(ref: ref("tty:a", "k1"))) + } + + // MARK: - Invalidation + + func testInvalidateOneGrant() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + table.grant(ref: ref("tty:a", "k2"), identityId: "default", scope: .session) + + let change = table.invalidate(sessionId: "tty:a", keyId: "k1") + XCTAssertEqual(change.dropped, 1) + XCTAssertEqual(change.closedSessions, []) + XCTAssertTrue(table.isSessionLive("tty:a")) + } + + func testInvalidateOneSession() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + table.grant(ref: ref("tty:a", "k2"), identityId: "default", scope: .session) + table.grant(ref: ref("tty:b", "k1"), identityId: "default", scope: .session) + + let change = table.invalidate(sessionId: "tty:a") + XCTAssertEqual(change.dropped, 2) + XCTAssertEqual(change.closedSessions, ["tty:a"]) + XCTAssertFalse(table.isSessionLive("tty:a")) + XCTAssertTrue(table.isSessionLive("tty:b")) + } + + func testInvalidateEverything() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + table.grant(ref: ref("tty:b", "k1"), identityId: "default", scope: .session) + + let change = table.invalidate() + XCTAssertEqual(change.dropped, 2) + XCTAssertEqual(change.closedSessions, ["tty:a", "tty:b"]) + XCTAssertFalse(table.hasLiveSessions()) + XCTAssertEqual(table.liveSessionIds(), []) + } + + // MARK: - Listing + + func testListReportsLiveGrantsOldestSessionFirst() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + advance(hours: 1) + table.grant(ref: ref("tty:b", "k2"), identityId: "work", scope: .duration, durationMs: 60_000) + + let listed = table.list() + XCTAssertEqual(listed.map(\.sessionId), ["tty:a", "tty:b"]) + XCTAssertEqual(listed.map(\.scope), [.session, .duration]) + XCTAssertEqual(listed.map(\.identityId), ["default", "work"]) + } + + func testListOmitsExpiredGrants() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 1_000) + table.grant(ref: ref("tty:b", "k1"), identityId: "default", scope: .session) + + advance(ms: 2_000) + XCTAssertEqual(table.list().map(\.sessionId), ["tty:b"]) + } + + func testWireDictionaryCarriesRemainingTtl() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 90_000) + + advance(ms: 30_000) + let dict = try XCTUnwrap(table.list().first).toDictionary() + XCTAssertEqual(dict["expiresInMs"] as? Int64, 60_000) + XCTAssertEqual(dict["scope"] as? String, "duration") + XCTAssertNil(dict["lastUsedAt"]) + } + + // MARK: - Clock changes + + /// The point of the monotonic half of every deadline: winding the settable + /// clock back must not buy a grant one extra millisecond. + func testWindingTheWallClockBackDoesNotExtendAGrant() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 60_000) + + // A minute of real time passes, and someone puts the clock back an hour. + monotonicNow += 61_000 + now -= 60 * 60 * 1000 + + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) { error in + XCTAssertEqual((error as? SessionGrantError)?.code, "SESSION_GRANT_EXPIRED") + } + XCTAssertFalse(table.hasLiveSessions()) + } + + func testWindingTheWallClockBackDoesNotExtendTheSessionCap() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + monotonicNow += 13 * 60 * 60 * 1000 + now -= 24 * 60 * 60 * 1000 + + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) + XCTAssertFalse(table.hasLiveSessions()) + } + + /// The other direction still ends the grant early: whichever deadline lands + /// first wins, and a forward jump is the wall clock landing first. + func testJumpingTheWallClockForwardStillExpiresAGrant() throws { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + now += 13 * 60 * 60 * 1000 + + XCTAssertThrowsError(try table.consume(ref: ref("tty:a", "k1"))) + XCTAssertFalse(table.hasLiveSessions()) + } + + /// Reported time left is never read off the settable clock, so a clock that + /// has been moved cannot make a grant look longer-lived than it is. + func testRemainingTimeIgnoresAWallClockThatMovedBackwards() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 60_000) + + monotonicNow += 45_000 + now -= 60 * 60 * 1000 + + XCTAssertEqual(table.list().first?.remainingMs, 15_000) + } + + func testRemainingTimeTakesTheNearerOfTheTwoDeadlines() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .duration, durationMs: 60_000) + + // The wall clock jumped forward, so it now runs out first. + now += 50_000 + monotonicNow += 10_000 + + XCTAssertEqual(table.list().first?.remainingMs, 10_000) + } + + func testDeadlineClampingAppliesToBothClocks() { + let table = makeTable() + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + + advance(hours: 6) + // Asking for a full 12h window six hours in must not reach past the cap on + // either clock, or the monotonic half would outlive the session. + table.grant( + ref: ref("tty:a", "k2"), + identityId: "default", + scope: .duration, + durationMs: SessionGrantTable.maxGrantMs + ) + + monotonicNow += 6 * 60 * 60 * 1000 + 1_000 + XCTAssertFalse(table.hasLiveSessions()) + } + + // MARK: - Daemon lifetime + + func testDaemonSeesNoLiveSessionsUntilAGrantExists() { + let table = makeTable() + XCTAssertFalse(table.hasLiveSessions()) + + table.grant(ref: ref("tty:a", "k1"), identityId: "default", scope: .session) + XCTAssertTrue(table.hasLiveSessions()) + + table.invalidate() + XCTAssertFalse(table.hasLiveSessions()) + } + + func testScopeParsingRejectsUnknownWireValues() { + XCTAssertEqual(SessionGrantScope(wireValue: "once"), .once) + XCTAssertEqual(SessionGrantScope(wireValue: "session"), .session) + XCTAssertEqual(SessionGrantScope(wireValue: "duration"), .duration) + XCTAssertNil(SessionGrantScope(wireValue: "forever")) + XCTAssertNil(SessionGrantScope(wireValue: nil)) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionLockPolicyTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionLockPolicyTests.swift new file mode 100644 index 000000000..a4194900b --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionLockPolicyTests.swift @@ -0,0 +1,271 @@ +import XCTest +@testable import IdentitySessions + +/// What ends an unlock session, and who gets to decide. +/// +/// The resolution order is per-session override, then machine config, then the +/// built-in default. A bad value anywhere is reported and skipped rather than +/// failing the unlock: a typo in a config file must never lock someone out of +/// their own secrets, and it must never silently make sessions live LONGER than +/// they asked for either, which is why a rejected value falls through to the next +/// source rather than to `none`. +final class SessionLockPolicyTests: XCTestCase { + + private func configData(_ json: String) -> Data { + return Data(json.utf8) + } + + private func collectingWarnings() -> (warn: (String) -> Void, read: () -> [String]) { + var warnings: [String] = [] + return ({ warnings.append($0) }, { warnings }) + } + + // MARK: - The policy itself + + func testDefaultIsSleep() { + XCTAssertEqual(SessionLockPolicy.builtInDefault, .sleep) + } + + func testScreenLockPolicyErasesOnBothEvents() { + XCTAssertTrue(SessionLockPolicy.screenLock.erases(on: .screenLock)) + XCTAssertTrue(SessionLockPolicy.screenLock.erases(on: .sleep)) + } + + func testSleepPolicySurvivesScreenLock() { + XCTAssertFalse(SessionLockPolicy.sleep.erases(on: .screenLock)) + XCTAssertTrue(SessionLockPolicy.sleep.erases(on: .sleep)) + } + + func testNonePolicySurvivesEverything() { + XCTAssertFalse(SessionLockPolicy.never.erases(on: .screenLock)) + XCTAssertFalse(SessionLockPolicy.never.erases(on: .sleep)) + } + + func testWireValues() { + XCTAssertEqual(SessionLockPolicy(wireValue: "screenLock"), .screenLock) + XCTAssertEqual(SessionLockPolicy(wireValue: "sleep"), .sleep) + XCTAssertEqual(SessionLockPolicy(wireValue: "none"), .never) + XCTAssertEqual(SessionLockPolicy.never.rawValue, "none") + XCTAssertNil(SessionLockPolicy(wireValue: "never")) + XCTAssertNil(SessionLockPolicy(wireValue: nil)) + } + + // MARK: - Resolution order + + func testFallsBackToBuiltInDefault() { + let resolved = LockPolicyResolution.resolve(overrideWireValue: nil, machineConfigData: nil) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(resolved.source, .builtInDefault) + } + + func testMachineConfigBeatsTheDefault() { + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + machineConfigData: configData(#"{"sessions":{"lockOn":"none"}}"#) + ) + XCTAssertEqual(resolved.policy, .never) + XCTAssertEqual(resolved.source, .machineConfig) + } + + func testSessionOverrideBeatsMachineConfig() { + let resolved = LockPolicyResolution.resolve( + overrideWireValue: "screenLock", + machineConfigData: configData(#"{"sessions":{"lockOn":"none"}}"#) + ) + XCTAssertEqual(resolved.policy, .screenLock) + XCTAssertEqual(resolved.source, .sessionOverride) + } + + func testEmptyOverrideIsTreatedAsAbsent() { + let resolved = LockPolicyResolution.resolve( + overrideWireValue: "", + machineConfigData: configData(#"{"sessions":{"lockOn":"screenLock"}}"#) + ) + XCTAssertEqual(resolved.policy, .screenLock) + XCTAssertEqual(resolved.source, .machineConfig) + } + + // MARK: - Tolerating a config file that is missing or partial + + func testMissingConfigFileIsSilent() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve(overrideWireValue: nil, machineConfigData: nil, warn: warn) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(warnings(), []) + } + + func testConfigWithoutASessionsSectionIsSilent() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + // the keys varlock already keeps in this file + machineConfigData: configData(#"{"anonymousId":"abc","telemetryDisabled":true}"#), + warn: warn + ) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(resolved.source, .builtInDefault) + XCTAssertEqual(warnings(), []) + } + + func testSessionsSectionWithoutLockOnIsSilent() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + machineConfigData: configData(#"{"sessions":{"somethingElse":1}}"#), + warn: warn + ) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(warnings(), []) + } + + func testEmptyConfigFileIsSilent() { + let (warn, warnings) = collectingWarnings() + _ = LockPolicyResolution.resolve(overrideWireValue: nil, machineConfigData: Data(), warn: warn) + XCTAssertEqual(warnings(), []) + } + + // MARK: - Rejecting values that are present and wrong + + func testInvalidConfigValueWarnsAndFallsBackToDefault() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + machineConfigData: configData(#"{"sessions":{"lockOn":"forever"}}"#), + warn: warn + ) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(resolved.source, .builtInDefault) + XCTAssertEqual(warnings().count, 1) + XCTAssertTrue(warnings()[0].contains("forever"), warnings()[0]) + XCTAssertTrue(warnings()[0].contains("screenLock"), "the warning should list the valid values") + } + + func testNonStringConfigValueWarns() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + machineConfigData: configData(#"{"sessions":{"lockOn":true}}"#), + warn: warn + ) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(warnings().count, 1) + } + + func testUnparseableConfigWarnsAndDoesNotThrow() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: nil, + machineConfigData: configData("{not json at all"), + warn: warn + ) + XCTAssertEqual(resolved.policy, .sleep) + XCTAssertEqual(warnings().count, 1) + } + + /// A bad override must not discard a good machine config: it falls through to + /// the next source in the order, not past it. + func testInvalidOverrideFallsThroughToMachineConfig() { + let (warn, warnings) = collectingWarnings() + let resolved = LockPolicyResolution.resolve( + overrideWireValue: "sometimes", + machineConfigData: configData(#"{"sessions":{"lockOn":"screenLock"}}"#), + warn: warn + ) + XCTAssertEqual(resolved.policy, .screenLock) + XCTAssertEqual(resolved.source, .machineConfig) + XCTAssertEqual(warnings().count, 1) + } + + // MARK: - Per-session divergence, as the observers see it + + private func table() -> SessionGrantTable { + return SessionGrantTable(clock: { 1_700_000_000_000 }) + } + + private func ref(_ session: String) -> SessionGrantRef { + return SessionGrantRef(sessionId: session, keyId: "k1") + } + + /// Three sessions, three policies, one screen lock: only the strict one goes. + func testScreenLockErasesOnlySessionsThatOptedIn() { + let table = self.table() + table.grant(ref: ref("strict"), identityId: "default", scope: .session, lockOn: .screenLock) + table.grant(ref: ref("default"), identityId: "default", scope: .session, lockOn: .sleep) + table.grant(ref: ref("relaxed"), identityId: "default", scope: .session, lockOn: .never) + + let change = table.invalidate(onLockEvent: .screenLock) + XCTAssertEqual(change.dropped, 1) + XCTAssertEqual(change.closedSessions, ["strict"]) + XCTAssertEqual(table.liveSessionIds(), ["default", "relaxed"]) + } + + /// The same three, one sleep: the default policy goes too, `none` survives. + func testSleepErasesEverythingExceptNone() { + let table = self.table() + table.grant(ref: ref("strict"), identityId: "default", scope: .session, lockOn: .screenLock) + table.grant(ref: ref("default"), identityId: "default", scope: .session, lockOn: .sleep) + table.grant(ref: ref("relaxed"), identityId: "default", scope: .session, lockOn: .never) + + let change = table.invalidate(onLockEvent: .sleep) + XCTAssertEqual(change.dropped, 2) + XCTAssertEqual(change.closedSessions, ["default", "strict"]) + XCTAssertEqual(table.liveSessionIds(), ["relaxed"]) + } + + /// Whatever the policy, an explicit lock takes everything. + func testExplicitInvalidateIgnoresLockPolicy() { + let table = self.table() + table.grant(ref: ref("relaxed"), identityId: "default", scope: .session, lockOn: .never) + table.grant(ref: ref("also-relaxed"), identityId: "default", scope: .session, lockOn: .never) + + let change = table.invalidate() + XCTAssertEqual(change.dropped, 2) + XCTAssertFalse(table.hasLiveSessions()) + } + + func testLockEventDropsEveryGrantInAnAffectedSession() { + let table = self.table() + table.grant(ref: SessionGrantRef(sessionId: "s", keyId: "k1"), identityId: "default", scope: .session, lockOn: .screenLock) + table.grant(ref: SessionGrantRef(sessionId: "s", keyId: "k2"), identityId: "default", scope: .session, lockOn: .screenLock) + + let change = table.invalidate(onLockEvent: .screenLock) + XCTAssertEqual(change.dropped, 2) + XCTAssertEqual(change.closedSessions, ["s"]) + } + + func testDefaultPolicyAppliesWhenGrantDoesNotNameOne() { + let table = self.table() + table.grant(ref: ref("s"), identityId: "default", scope: .session) + XCTAssertEqual(table.lockPolicy(forSession: "s"), .sleep) + XCTAssertEqual(table.invalidate(onLockEvent: .screenLock).dropped, 0) + XCTAssertEqual(table.invalidate(onLockEvent: .sleep).dropped, 1) + } + + /// Re-unlocking is how a session changes its mind about its lock policy. + func testRegrantingUpdatesTheSessionPolicy() { + let table = self.table() + table.grant(ref: ref("s"), identityId: "default", scope: .session, lockOn: .screenLock) + XCTAssertEqual(table.lockPolicy(forSession: "s"), .screenLock) + + table.grant(ref: ref("s"), identityId: "default", scope: .session, lockOn: .never) + XCTAssertEqual(table.lockPolicy(forSession: "s"), .never) + XCTAssertEqual(table.invalidate(onLockEvent: .sleep).dropped, 0) + } + + func testPolicyIsReportedInTheWireDictionary() { + let table = self.table() + let info = table.grant(ref: ref("s"), identityId: "default", scope: .session, lockOn: .never) + XCTAssertEqual(info.lockOn, .never) + XCTAssertEqual(info.toDictionary()["lockOn"] as? String, "none") + } + + func testListReportsEachSessionsOwnPolicy() { + let table = self.table() + table.grant(ref: ref("a"), identityId: "default", scope: .session, lockOn: .screenLock) + table.grant(ref: ref("b"), identityId: "default", scope: .session, lockOn: .never) + + let byId = Dictionary(uniqueKeysWithValues: table.list().map { ($0.sessionId, $0.lockOn) }) + XCTAssertEqual(byId["a"], .screenLock) + XCTAssertEqual(byId["b"], .never) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionMenuModelTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionMenuModelTests.swift new file mode 100644 index 000000000..e9bb1dd88 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/SessionMenuModelTests.swift @@ -0,0 +1,170 @@ +import XCTest +import SessionScoping +@testable import IdentitySessions + +/// What the menu bar says about live sessions, checked without a window server. +/// +/// The menu itself is a thin translation of these rows into `NSMenuItem`s, so +/// everything worth getting wrong (grouping, wording, rounding, which session a +/// Lock item belongs to) is decided here. +final class SessionMenuModelTests: XCTestCase { + + private let hour: Int64 = 60 * 60 * 1000 + + private func grant( + session: String, + key: String, + scope: SessionGrantScope = .session, + remaining: Int64? = nil, + sessionRemaining: Int64 = 12 * 60 * 60 * 1000, + lockOn: SessionLockPolicy = .sleep + ) -> SessionGrantInfo { + return SessionGrantInfo( + sessionId: session, + keyId: key, + identityId: "default", + scope: scope, + grantedAt: 1_700_000_000_000, + expiresAt: 1_700_000_000_000 + (remaining ?? sessionRemaining), + remainingMs: remaining ?? sessionRemaining, + lastUsedAt: nil, + sessionUnlockedAt: 1_700_000_000_000, + sessionExpiresAt: 1_700_000_000_000 + sessionRemaining, + sessionRemainingMs: sessionRemaining, + lockOn: lockOn, + useCount: 0 + ) + } + + // MARK: - Grouping + + func testNoGrantsMeansNoRows() { + let model = SessionMenuModel.build(from: []) + XCTAssertTrue(model.isEmpty) + XCTAssertEqual(model.sessionCount, 0) + } + + func testKeysAreGroupedUnderTheirSession() { + let model = SessionMenuModel.build(from: [ + grant(session: "tty:ttys004:100", key: "dev"), + grant(session: "tty:ttys004:100", key: "prod", scope: .once), + grant(session: "ptree:900:100", key: "dev"), + ]) + + XCTAssertEqual(model.sessionCount, 2) + XCTAssertEqual(model.rows[0].sessionId, "tty:ttys004:100") + XCTAssertEqual(model.rows[0].keys.map(\.keyId), ["dev", "prod"]) + XCTAssertEqual(model.rows[1].keys.map(\.keyId), ["dev"]) + } + + func testRowOrderFollowsTheGrantTable() { + let model = SessionMenuModel.build(from: [ + grant(session: "ptree:900:100", key: "dev"), + grant(session: "tty:ttys004:100", key: "dev"), + ]) + XCTAssertEqual(model.rows.map(\.sessionId), ["ptree:900:100", "tty:ttys004:100"]) + } + + /// The Lock item on a row has to invalidate that row's session and no other, + /// so the id travels with it rather than being re-derived from the title. + func testEveryRowCarriesItsSessionId() { + let model = SessionMenuModel.build(from: [ + grant(session: "tty:ttys004:100", key: "dev"), + grant(session: "tty:ttys009:100", key: "dev"), + ]) + XCTAssertEqual(model.rows.map(\.sessionId), ["tty:ttys004:100", "tty:ttys009:100"]) + XCTAssertEqual(model.rows.map(\.title), ["Terminal ttys004", "Terminal ttys009"]) + } + + // MARK: - Wording + + func testKeyLineNamesTheKeyScopeAndTimeLeft() { + let model = SessionMenuModel.build(from: [ + grant(session: "tty:ttys004:100", key: "varlock-default", scope: .session, remaining: 9 * hour), + ]) + XCTAssertEqual(model.rows[0].keys[0].title, "varlock-default: this session, 9h left") + } + + func testScopeLabelsAreSpelledOut() { + XCTAssertEqual(SessionMenuModel.scopeLabel(.once), "single use") + XCTAssertEqual(SessionMenuModel.scopeLabel(.session), "this session") + XCTAssertEqual(SessionMenuModel.scopeLabel(.duration), "timed") + } + + func testCapLineComparesAgainstTheTwelveHourLimit() { + let model = SessionMenuModel.build(from: [ + grant(session: "tty:ttys004:100", key: "dev", remaining: hour, sessionRemaining: 9 * hour), + ]) + XCTAssertEqual(model.rows[0].capLine, "12h limit: 9h left") + // The key's own window is shorter, and says so separately. + XCTAssertEqual(model.rows[0].keys[0].remainingLabel, "1h left") + } + + func testLockLineNamesTheSessionsOwnPolicy() { + for (policy, expected) in [ + (SessionLockPolicy.screenLock, "Locks on screen lock"), + (SessionLockPolicy.sleep, "Locks on sleep"), + (SessionLockPolicy.never, "Stays unlocked until it expires"), + ] { + let model = SessionMenuModel.build(from: [ + grant(session: "tty:ttys004:100", key: "dev", lockOn: policy), + ]) + XCTAssertEqual(model.rows[0].lockLine, expected) + } + } + + func testLockPolicySettingLabels() { + XCTAssertEqual(SessionMenuModel.lockPolicyMenuLabel(.screenLock), "Screen lock") + XCTAssertEqual(SessionMenuModel.lockPolicyMenuLabel(.sleep), "Sleep") + XCTAssertEqual(SessionMenuModel.lockPolicyMenuLabel(.never), "Only manually") + } + + // MARK: - Rounding + + /// Coarse and rounded down. The menu is rebuilt when it opens rather than + /// ticking, so it must never promise time that has already gone. + func testRemainingTimeRoundsDown() { + XCTAssertEqual(SessionMenuModel.coarseRemaining(9 * hour + 59 * 60_000), "9h left") + XCTAssertEqual(SessionMenuModel.coarseRemaining(hour), "1h left") + XCTAssertEqual(SessionMenuModel.coarseRemaining(hour - 1), "59m left") + XCTAssertEqual(SessionMenuModel.coarseRemaining(90_000), "1m left") + XCTAssertEqual(SessionMenuModel.coarseRemaining(59_000), "under a minute left") + XCTAssertEqual(SessionMenuModel.coarseRemaining(0), "expired") + XCTAssertEqual(SessionMenuModel.coarseRemaining(-5), "expired") + } + + // MARK: - Session labels + + func testSessionLabelsReadAsPlaces() { + XCTAssertEqual(SessionLabel.describe(sessionId: "tty:ttys004:1700000000"), "Terminal ttys004") + XCTAssertEqual(SessionLabel.describe(sessionId: "ptree:41234:1700000000"), "Process 41234") + XCTAssertEqual( + SessionLabel.describe(sessionId: "tty:ttys004:1700000000:TMUX=/tmp/tmux-501/default,88,0"), + "Terminal ttys004 (tmux)" + ) + XCTAssertEqual( + SessionLabel.describe(sessionId: "tty:ttys004:1700000000:ZELLIJ=1"), + "Terminal ttys004 (zellij)" + ) + } + + /// An agent session id carries a UUID that identifies the session. It belongs + /// in the grant table, not on screen. + func testAgentSessionLabelsDropTheIdentifierValue() { + let label = SessionLabel.describe( + sessionId: "env:CLAUDE_CODE_SESSION_ID:0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0|tty:ttys004:1700000000" + ) + XCTAssertEqual(label, "Terminal ttys004, Claude Code session") + XCTAssertFalse(label.contains("0f1e2d3c")) + + let anchorless = SessionLabel.describe(sessionId: "env:CODEX_THREAD_ID:0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0") + XCTAssertEqual(anchorless, "Codex session") + XCTAssertFalse(anchorless.contains("0f1e2d3c")) + } + + func testUnrecognisedSessionIdIsTruncatedRatherThanDropped() { + let label = SessionLabel.describe(sessionId: String(repeating: "z", count: 60)) + XCTAssertTrue(label.hasSuffix("...")) + XCTAssertLessThan(label.count, 40) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockDecisionTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockDecisionTests.swift new file mode 100644 index 000000000..dbe43196a --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockDecisionTests.swift @@ -0,0 +1,919 @@ +import XCTest +@testable import IdentitySessions +import SessionScoping + +/// What the daemon decides before it draws anything. +/// +/// These are the rules a user is trusting: that a second unlock in the same +/// session only asks about what is genuinely new, that a key set to ask every +/// time never quietly picks up a session-long grant, and that a batch containing +/// such a key still says so on the panel. +final class UnlockDecisionTests: XCTestCase { + + private let hour: Int64 = 60 * 60 * 1000 + + private func key(_ id: String, _ policy: KeyAuthPolicy = .standard, items: Int? = nil) -> RequestedKey { + return RequestedKey(keyId: id, policy: policy, itemCount: items) + } + + private func live(_ scope: SessionGrantScope, expiresIn: Int64) -> ExistingGrantSnapshot { + return ExistingGrantSnapshot(scope: scope, remainingMs: expiresIn) + } + + // MARK: - First unlock + + func testFirstUnlockAsksAboutEveryKey() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod")], + requestedScope: .session, + existing: [:] + ) + XCTAssertTrue(plan.requiresPrompt) + XCTAssertFalse(plan.isDelta) + XCTAssertEqual(plan.newKeys.map { $0.keyId }, ["dev", "prod"]) + XCTAssertTrue(plan.coveredKeys.isEmpty) + XCTAssertEqual(plan.offeredScopes, [.session, .once, .duration]) + XCTAssertEqual(plan.defaultScope, .session) + } + + // MARK: - Delta + + func testSecondUnlockOnlyAsksAboutTheNewKey() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod")], + requestedScope: .session, + existing: ["dev": live(.session, expiresIn: 4 * hour)] + ) + XCTAssertTrue(plan.isDelta) + XCTAssertEqual(plan.promptKeys.map { $0.keyId }, ["prod"]) + XCTAssertEqual(plan.coveredKeys.map { $0.keyId }, ["dev"]) + } + + func testNothingNewMeansNoPrompt() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod")], + requestedScope: .session, + existing: [ + "dev": live(.session, expiresIn: 4 * hour), + "prod": live(.session, expiresIn: 4 * hour), + ] + ) + XCTAssertFalse(plan.requiresPrompt) + XCTAssertFalse(plan.isDelta) + XCTAssertEqual(plan.coveredKeys.count, 2) + } + + func testExpiredGrantCountsAsNew() { + let plan = UnlockPlanner.plan( + requested: [key("dev")], + requestedScope: .session, + existing: ["dev": ExistingGrantSnapshot(scope: .session, remainingMs: 0)] + ) + XCTAssertTrue(plan.requiresPrompt) + XCTAssertEqual(plan.refreshKeys.map { $0.keyId }, ["dev"]) + } + + // MARK: - Scope upgrades + + func testAskingForMoreThanTheLiveGrantCarriesPromptsAgain() { + // A once grant does not silently become a session grant. + let plan = UnlockPlanner.plan( + requested: [key("dev")], + requestedScope: .session, + existing: ["dev": live(.once, expiresIn: 4 * hour)] + ) + XCTAssertTrue(plan.requiresPrompt) + XCTAssertEqual(plan.refreshKeys.map { $0.keyId }, ["dev"]) + } + + func testASessionGrantCoversASmallerRequest() { + for requested in [SessionGrantScope.once, .duration, .session] { + let plan = UnlockPlanner.plan( + requested: [key("dev")], + requestedScope: requested, + requestedDurationMs: 8 * hour, + existing: ["dev": live(.session, expiresIn: 2 * hour)] + ) + XCTAssertFalse(plan.requiresPrompt, "session grant should cover a \(requested.rawValue) request") + } + } + + func testALongerDurationRequestPromptsButAShorterOneDoesNot() { + let existing = ["dev": live(.duration, expiresIn: 4 * hour)] + + let shorter = UnlockPlanner.plan( + requested: [key("dev")], + requestedScope: .duration, + requestedDurationMs: hour, + existing: existing + ) + XCTAssertFalse(shorter.requiresPrompt) + + let longer = UnlockPlanner.plan( + requested: [key("dev")], + requestedScope: .duration, + requestedDurationMs: 8 * hour, + existing: existing + ) + XCTAssertTrue(longer.requiresPrompt) + } + + func testAOnceGrantCoversOnlyAnotherOnceRequest() { + let existing = ["dev": live(.once, expiresIn: 4 * hour)] + XCTAssertFalse(UnlockPlanner.plan( + requested: [key("dev")], requestedScope: .once, existing: existing + ).requiresPrompt) + XCTAssertTrue(UnlockPlanner.plan( + requested: [key("dev")], requestedScope: .duration, requestedDurationMs: hour, existing: existing + ).requiresPrompt) + } + + // MARK: - Strict keys + + func testAKeyThatAsksEveryTimeIsNeverCovered() { + let plan = UnlockPlanner.plan( + requested: [key("prod", .everyTime)], + requestedScope: .session, + existing: ["prod": live(.session, expiresIn: 4 * hour)] + ) + XCTAssertTrue(plan.requiresPrompt) + XCTAssertEqual(plan.refreshKeys.map { $0.keyId }, ["prod"]) + XCTAssertTrue(plan.isStrictOnly) + } + + func testAStrictOnlyBatchOffersOnceAlone() { + let plan = UnlockPlanner.plan( + requested: [key("prod", .everyTime), key("staging", .everyTime)], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual(plan.offeredScopes, [.once]) + XCTAssertEqual(plan.defaultScope, .once) + } + + func testAMixedBatchStillOffersTheLastingScopes() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod", .everyTime)], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual(plan.offeredScopes, [.session, .once, .duration]) + XCTAssertFalse(plan.isStrictOnly) + XCTAssertEqual(plan.standardPromptKeys.map { $0.keyId }, ["dev"]) + XCTAssertEqual(plan.strictPromptKeys.map { $0.keyId }, ["prod"]) + } + + func testStrictKeysAreClampedToOnceWhateverWasChosen() { + XCTAssertEqual(UnlockPlanner.effectiveScope(chosen: .session, policy: .everyTime), .once) + XCTAssertEqual(UnlockPlanner.effectiveScope(chosen: .duration, policy: .everyTime), .once) + XCTAssertNil(UnlockPlanner.effectiveDurationMs(chosen: .duration, chosenDurationMs: hour, policy: .everyTime)) + + XCTAssertEqual(UnlockPlanner.effectiveScope(chosen: .session, policy: .standard), .session) + XCTAssertEqual(UnlockPlanner.effectiveDurationMs(chosen: .duration, chosenDurationMs: hour, policy: .standard), hour) + } + + func testNothingOnTheLadderReachesPastTheHardCap() { + for preset in DurationPreset.allCases { + XCTAssertLessThanOrEqual(preset.milliseconds, SessionGrantTable.maxGrantMs) + } + // The cap is no longer a preset; it is the ceiling of the typed rung. + // Neither unit can name a window past it, which is the property that + // used to be carried by "the last rung IS the cap". + for unit in DurationUnit.allCases { + XCTAssertEqual(unit.maxAmount * unit.milliseconds, SessionGrantTable.maxGrantMs) + } + } + + func testTheTimedPresetsAreTheTwoShapesMostApprovalsWant() { + // Two, not four. The row stopped guessing at the numbers people wanted + // and gave them a rung to say it on instead. + XCTAssertEqual( + DurationPreset.allCases.map { $0.label }, + ["10 minutes", "1 hour"] + ) + // The row says "10min" where the summary sentence says "10 minutes": a + // row of rungs is a scale, and a sentence is prose. + XCTAssertEqual( + DurationPreset.allCases.map { $0.shortLabel }, + ["10min", "1hr"] + ) + } + + func testTheWindowLadderRunsFromLeastToMostPermissive() { + // One question, one control, and an order that is itself information: + // reading left to right is reading the ladder you are picking a rung on. + // `Custom` sits after the presets and before the session, so a typed + // window reads as bounded by the session whatever number it holds. + XCTAssertEqual( + PanelContent.windowOptions(scopes: UnlockPlanner.fullScopes).map { $0.label }, + ["Once", "10min", "1hr", "Custom", "This session"] + ) + // Exactly one rung is the one you set, and it is a timed one. + let custom = PanelContent.windowOptions(scopes: UnlockPlanner.fullScopes) + .filter { $0.kind == .custom } + XCTAssertEqual(custom.count, 1) + XCTAssertEqual(custom.first?.window.scope, .duration) + } + + func testTheCustomRungKeepsItsNameWhateverValueItHolds() { + // The rung sits at a fixed place in an ordered row, so it must not wear + // a free value: `45min` between `1hr` and `This session` would break the + // order the row exists to show, and a value that landed on a preset + // would read as a duplicate of the rung beside it. The value goes in the + // rung's WINDOW, which is what a remembered answer matches on, and it is + // on screen in the field and in the summary sentence. + let options = PanelContent.windowOptions( + scopes: UnlockPlanner.fullScopes, + custom: CustomDuration(amount: 45, unit: .minutes) + ) + XCTAssertEqual(options.map { $0.label }, ["Once", "10min", "1hr", "Custom", "This session"]) + XCTAssertEqual( + options.first { $0.kind == .custom }?.window, + GrantWindow(scope: .duration, durationMs: 2_700_000) + ) + // Including a value that names a preset: still `Custom`, never `1hr`. + let onAPreset = PanelContent.windowOptions( + scopes: UnlockPlanner.fullScopes, + custom: CustomDuration(amount: 60, unit: .minutes) + ) + XCTAssertEqual(onAPreset.first { $0.kind == .custom }?.label, "Custom") + } + + func testTheLadderOnlyOffersWhatTheRequestAllows() { + // A strict key can only be answered once, and a panel drawing rungs it + // would then clamp would be lying about what approving does. No timed + // scope means no custom rung either: there would be nothing it could + // name that the request would honour. + XCTAssertEqual( + PanelContent.windowOptions(scopes: [.once]).map { $0.label }, + ["Once"] + ) + } + + func testAnAnswerWithNoRungFallsBackToTheNarrowestOne() { + let options = PanelContent.windowOptions(scopes: UnlockPlanner.fullScopes) + XCTAssertEqual( + PanelContent.windowOptionIndex( + of: GrantWindow(scope: .duration, durationMs: DurationPreset.oneHour.milliseconds), + in: options + ), + 2 + ) + // A duration nothing on the row names opens on the shortest timed rung, + // and an answer off the row entirely opens on the narrowest rung there + // is. Neither fallback may reach outwards: opening on more than was + // asked for is the one direction that can hand something away. In + // particular it does NOT land on the custom rung, whose value it has no + // claim over. + XCTAssertEqual( + PanelContent.windowOptionIndex( + of: GrantWindow(scope: .duration, durationMs: 90_000), + in: options + ), + 1 + ) + XCTAssertEqual( + PanelContent.windowOptionIndex(of: GrantWindow(scope: .session), in: [ + PanelWindowOption(window: GrantWindow(scope: .once)), + ]), + 0 + ) + } + + func testARememberedCustomWindowComesBackSelectedWithTheFieldPrimed() { + // The whole round trip, through the one `defaultDurationMs` path the + // preselection already used: a value that names no preset comes back on + // the custom rung, selected, with the field primed to it. The rung keeps + // its name; the value shows in the field it just revealed. + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + preselection: UnlockPreselection( + breadth: .wholeKey, + window: GrantWindow(scope: .duration, durationMs: 2_700_000), + risk: .routine, + isRemembered: true + ) + ) + XCTAssertEqual(content.customDuration, CustomDuration(amount: 45, unit: .minutes)) + XCTAssertEqual( + PanelContent.windowOptionIndex(of: content.defaultWindow, in: content.windowOptions), + 3 + ) + XCTAssertEqual(content.windowOptions[3].label, "Custom") + } + + func testARememberedWindowPastTheCapIsDrawnAtTheCap() { + // The preferences file is a text file somebody can edit. A rung reading + // `48hr` on a grant the table would cut to 12 is the panel telling a lie + // it did not author. + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + preselection: UnlockPreselection( + breadth: .wholeKey, + window: GrantWindow(scope: .duration, durationMs: 999_999_999), + risk: .routine + ) + ) + XCTAssertEqual(content.defaultDurationMs, SessionGrantTable.maxGrantMs) + XCTAssertEqual(content.customDuration, CustomDuration(amount: 12, unit: .hours)) + } + + // MARK: - Panel content + + func testFirstUnlockPanelNamesTheKeyAndTheRequester() { + let plan = UnlockPlanner.plan( + requested: [key("varlock-default", items: 12)], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester( + summary: "Requested by node in ttys004", + details: [.derived("Process: node ← claude"), .derived("Terminal ttys004")] + ) + ) + // The default key's id is an implementation detail; the panel says what + // it is instead, and draws the name as an identifier. + XCTAssertEqual(content.title, "Unlock local encryption") + XCTAssertEqual(content.titleSegments, [.plain("Unlock "), .code("local encryption")]) + XCTAssertEqual(content.confirmButtonTitle, "Unlock") + // One line at rest, the chain behind the disclosure. + XCTAssertEqual(content.requester.summary, "Requested by node in ttys004") + XCTAssertEqual(content.requester.details.count, 2) + XCTAssertEqual(content.keyRows.first?.keyId, "varlock-default") + XCTAssertEqual(content.keyRows.first?.valueCountLabel, "12 values") + } + + func testTwoKeysAreBothNamedInTheHeading() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod")], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "")) + XCTAssertEqual(content.title, "Unlock dev and prod") + XCTAssertEqual(content.keyRows.map { $0.displayName }, ["dev", "prod"]) + } + + func testTheProjectIsTheHerosSecondLine() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + display: UnlockDisplayInfo(projectName: "acme-api") + ) + XCTAssertEqual(content.subtitle, "for acme-api") + } + + func testTheVaultTagNamesTheVaultAndNeverRepeatsTheRow() { + let plan = UnlockPlanner.plan( + requested: [key("varlock-default"), key("prod")], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + display: UnlockDisplayInfo(keys: [ + "prod": UnlockKeyDisplay(vaultLabel: "acme-team vault", vaultColor: "#b48ce8"), + ]) + ) + let rows = Dictionary(uniqueKeysWithValues: content.keyRows.map { ($0.keyId, $0) }) + // The default key is already called "local encryption", so tagging it + // with the same words would only be the row saying itself twice. + XCTAssertNil(rows["varlock-default"]?.vaultLabel) + XCTAssertEqual(rows["prod"]?.vaultLabel, "acme-team vault") + XCTAssertEqual(rows["prod"]?.vaultColor, "#b48ce8") + } + + func testTheTopBarFactFollowsWhatTheApprovalCanActuallyDo() { + let sessionPlan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + XCTAssertEqual( + UnlockPanelContent.build( + plan: sessionPlan, + requester: PanelRequester(summary: ""), + lockOn: .screenLock + ).factLine, + "Sessions end on screen lock \u{00B7} 12h max" + ) + + // A batch that can only ever be approved once has no session to talk + // about, so the fact worth stating is the other one. + let strictPlan = UnlockPlanner.plan( + requested: [key("prod", .everyTime)], + requestedScope: .session, + existing: [:] + ) + XCTAssertEqual( + UnlockPanelContent.build(plan: strictPlan, requester: PanelRequester(summary: "")).factLine, + "Recorded to the audit log" + ) + } + + func testDeltaPanelAsksOnlyAboutTheNewKey() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod")], + requestedScope: .session, + existing: ["dev": live(.session, expiresIn: 4 * hour)] + ) + let content = UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "")) + XCTAssertEqual(content.title, "Also unlock prod") + XCTAssertEqual(content.notes, ["This session already has 1 other key unlocked."]) + XCTAssertEqual(content.keyRows.map { $0.keyId }, ["prod"]) + } + + func testAStrictKeyIsMarkedOnItsOwnRow() { + let plan = UnlockPlanner.plan( + requested: [key("dev"), key("prod", .everyTime)], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "")) + XCTAssertEqual(content.keyRows.map { $0.keyId }, ["dev", "prod"]) + XCTAssertNil(content.keyRows[0].note) + XCTAssertEqual(content.keyRows[1].note, "asks every time") + } + + func testValueNamesRideAlongOnTheirKeysRow() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + display: UnlockDisplayInfo(keys: [ + "dev": UnlockKeyDisplay( + valueCount: 2, + sources: [UnlockValueSource( + path: ".env", + entries: [.init(name: "DATABASE_URL"), .init(name: "STRIPE_KEY")] + )] + ), + ]) + ) + let row = content.keyRows[0] + XCTAssertEqual(row.valueCountLabel, "2 values") + XCTAssertTrue(row.isExpandable) + XCTAssertEqual(row.sources.first?.entries.map { $0.name }, ["DATABASE_URL", "STRIPE_KEY"]) + XCTAssertEqual(row.sources.first?.heading, ".env") + XCTAssertEqual(row.sources.first?.headingCount, 2) + XCTAssertEqual(row.sourceFootnote, PanelContent.valueSourceFootnote) + } + + /// The cache is one of the things the key opens, so it is a line in the same + /// list the files are in, and the line says which it is. + func testTheValueCacheIsListedAsASourceLikeAnyOther() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + display: UnlockDisplayInfo(keys: [ + "dev": UnlockKeyDisplay( + valueCount: 12, + sources: [UnlockValueSource( + kind: .cache, + entries: [.init(name: "1password", count: 8), .init(name: ".env.local", count: 4)], + reportedItemCount: 12 + )] + ), + ]) + ) + let row = content.keyRows[0] + XCTAssertTrue(row.isExpandable) + XCTAssertEqual(row.valueCountLabel, "12 values") + XCTAssertEqual(row.sources.first?.heading, "value cache") + XCTAssertEqual(row.sources.first?.headingCount, 12) + XCTAssertEqual( + row.sources.first?.entries.map { $0.label }, + ["1password \u{00B7} 8", ".env.local \u{00B7} 4"] + ) + XCTAssertEqual(row.sourceFootnote, "Sources and contents reported by the client") + } + + /// One request covering both needs no special shape: two sources, one row. + func testFilesAndTheCacheShareOneRowWhenOneRequestCoversBoth() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: ""), + display: UnlockDisplayInfo(keys: [ + "dev": UnlockKeyDisplay( + valueCount: 14, + sources: [ + UnlockValueSource( + path: ".env", + entries: [.init(name: "DATABASE_URL"), .init(name: "S3_KEY")] + ), + UnlockValueSource(kind: .cache, reportedItemCount: 12), + ] + ), + ]) + ) + let row = content.keyRows[0] + XCTAssertEqual(content.keyRows.count, 1, "one key means one row, however many sources it holds") + XCTAssertEqual(row.sources.map { $0.heading }, [".env", "value cache"]) + // the count sits beside the name as a badge, so a column of sources is + // compared rather than read + XCTAssertEqual(row.sources.map { $0.headingCount }, [2, 12]) + // A cache with nothing to chip about still draws: its heading is the + // fact that matters. + XCTAssertTrue(row.sources[1].isDrawable) + } + + /// A badge is a claim about size, so a source of unknown size gets none. + /// An empty pill would still say something, and a zero would say the wrong + /// thing. + func testASourceOfUnknownSizeGetsNoBadge() { + let unknown = UnlockValueSource(kind: .cache) + XCTAssertEqual(unknown.heading, "value cache") + XCTAssertNil(unknown.headingCount) + XCTAssertTrue(unknown.isDrawable) + + // a file the client did not name has no heading to hang a badge on, and + // its values are listed under nothing rather than under a guess + let nameless = UnlockValueSource(entries: [.init(name: "DATABASE_URL")]) + XCTAssertNil(nameless.heading) + XCTAssertNil(nameless.headingCount) + } + + /// Every count on the panel is counted the same way, so a badge and the + /// row's own total can never tell different stories. + func testABadgeCountsWhatTheSourceSaysItHolds() { + // enumerated: one per entry + XCTAssertEqual( + UnlockValueSource(path: ".env", entries: [.init(name: "A"), .init(name: "B")]).headingCount, + 2 + ) + // summarised: what the client reported, not the number of summary lines + XCTAssertEqual( + UnlockValueSource( + kind: .cache, + entries: [.init(name: "1password", count: 120), .init(name: "aws", count: 8)], + reportedItemCount: 128 + ).headingCount, + 128 + ) + // entries that each stand for several, with nothing reported over them + XCTAssertEqual( + UnlockValueSource(kind: .cache, entries: [.init(name: "1password", count: 8)]).headingCount, + 8 + ) + } + + /// A caller that said nothing must not leave a blank that reads as "there is + /// not much in here". + func testARowWithNoReportedContentsSaysSo() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "")) + let row = content.keyRows[0] + XCTAssertNil(row.valueCountLabel) + XCTAssertFalse(row.reportsContents) + XCTAssertEqual(row.contentsLabel, "contents not reported") + XCTAssertFalse(row.isExpandable) + } + + func testCacheSourcesAreReadOffTheWire() { + let display = UnlockDisplayInfo.from(payload: ["display": [ + "keys": [ + "dev": [ + "valueCount": 12, + "sources": [[ + "kind": "cache", + "itemCount": 12, + "entries": [["name": "1password", "count": 8], ["name": ".env.local", "count": 4]], + ]], + ], + ], + ]]) + + let source = display.keys["dev"]?.sources.first + XCTAssertEqual(source?.kind, .cache) + XCTAssertEqual(source?.itemCount, 12) + XCTAssertEqual(source?.entries.map { $0.name }, ["1password", ".env.local"]) + XCTAssertEqual(source?.entries.first?.count, 8) + } + + /// A source kind this daemon has never heard of is still one of the things + /// the grant would open, so it is drawn rather than dropped. + func testAnUnknownSourceKindIsDrawnRatherThanHidden() { + let display = UnlockDisplayInfo.from(payload: ["display": [ + "keys": ["dev": ["sources": [["kind": "something-new", "entries": [["name": "MYSTERY"]]]]]], + ]]) + XCTAssertEqual(display.keys["dev"]?.sources.first?.kind, .file) + XCTAssertEqual(display.keys["dev"]?.sources.first?.entries.map { $0.name }, ["MYSTERY"]) + } + + func testClientSuppliedLinesAreMarkedAsSuch() { + let plan = UnlockPlanner.plan(requested: [key("dev")], requestedScope: .session, existing: [:]) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: "Requested by node", details: [.derived("Process: node")]), + display: UnlockDisplayInfo(projectName: "my-app", projectPath: "~/code/my-app") + ) + // The derived line comes first among the details, and the client's line is + // not derived. Neither the summary nor the derived detail can be displaced + // by what the client sent. + XCTAssertEqual(content.requester.summary, "Requested by node") + XCTAssertTrue(content.requester.details[0].isDerived) + XCTAssertEqual(content.requester.details.last, PanelContextLine.clientSupplied("Project: my-app (~/code/my-app)")) + } + + // MARK: - What the system's own sheet says + + func testTheSystemSheetSaysTheShortestTrueThing() { + // macOS builds the sentence ("Varlock is trying to ..."), so this is only + // ever its tail. The panel is the surface that says who is asking and + // what they get; a sheet that covers the panel and repeats it badly is + // the worst of both. + let plan = UnlockPlanner.plan( + requested: [key("varlock-default", items: 12)], + requestedScope: .session, + existing: [:] + ) + let content = UnlockPanelContent.build( + plan: plan, + requester: PanelRequester(summary: "Requested by node in ttys004"), + display: UnlockDisplayInfo(projectName: "acme-api") + ) + XCTAssertEqual(content.presenceReason, "unlock local encryption") + // The requester belongs to the panel, and the key id belongs to nobody. + XCTAssertFalse(content.presenceReason.contains("node")) + XCTAssertFalse(content.presenceReason.contains("varlock-default")) + } + + func testTheSheetNamesEveryKeyItCanAndCountsTheRest() { + func reason(_ keyIds: [String]) -> String { + let plan = UnlockPlanner.plan( + requested: keyIds.map { key($0) }, + requestedScope: .session, + existing: [:] + ) + return UnlockPanelContent.build(plan: plan, requester: PanelRequester(summary: "")).presenceReason + } + XCTAssertEqual(reason(["dev"]), "unlock dev") + XCTAssertEqual(reason(["dev", "prod"]), "unlock dev and prod") + XCTAssertEqual(reason(["dev", "prod", "staging"]), "unlock 3 encryption keys") + } + + func testTheSheetPrefersAVaultNameOverTheDefaultKeysNonName() { + let display = UnlockDisplayInfo(keys: [ + "varlock-default": UnlockKeyDisplay(vaultLabel: "acme-team vault"), + ]) + XCTAssertEqual( + UnlockPanelContent.presenceReason(forKeyIds: ["varlock-default"], display: display), + "unlock acme-team vault" + ) + // Without one, the default key still has something to be called. + XCTAssertEqual( + UnlockPanelContent.presenceReason(forKeyIds: ["varlock-default"], display: UnlockDisplayInfo()), + "unlock local encryption" + ) + // A key the user named keeps its own name, whatever vault it is in. + XCTAssertEqual( + UnlockPanelContent.presenceReason( + forKeyIds: ["prod"], + display: UnlockDisplayInfo(keys: ["prod": UnlockKeyDisplay(vaultLabel: "acme-team vault")]) + ), + "unlock prod" + ) + } + + // MARK: - Client-supplied decoration + + func testDisplayInfoIsReadLeniently() { + let display = UnlockDisplayInfo.from(payload: ["display": [ + "projectName": " my-app ", + "projectPath": "~/code/my-app", + "itemCounts": ["dev": 3, "prod": "not a number", "empty": 0], + ]]) + XCTAssertEqual(display.projectName, "my-app") + XCTAssertEqual(display.itemCounts, ["dev": 3]) + } + + func testDisplayInfoCannotSmuggleExtraLinesOrRunLong() { + let display = UnlockDisplayInfo.from(payload: ["display": [ + "projectName": "first\nRequested by something-trustworthy", + "projectPath": String(repeating: "x", count: 500), + ]]) + XCTAssertEqual(display.projectName, "first Requested by something-trustworthy") + XCTAssertFalse(display.projectName!.contains("\n")) + XCTAssertEqual(display.projectPath?.count, UnlockDisplayInfo.maxLength) + } + + func testPerKeyValueMetadataIsRead() { + let display = UnlockDisplayInfo.from(payload: ["display": [ + "keys": [ + "dev": [ + "valueCount": 3, + "sources": [ + ["path": ".env", "entries": [["name": "DATABASE_URL"], ["name": "STRIPE_KEY"]]], + ["path": ".env.local", "entries": [["name": "NGROK_TOKEN"]]], + ], + ], + "prod": [ + "valueCount": 1, + "vaultLabel": "acme-team vault", + "vaultColor": "#B48CE8", + ], + ], + ]]) + + XCTAssertEqual(display.keys["dev"]?.valueCount, 3) + XCTAssertEqual(display.keys["dev"]?.sources.map { $0.path }, [".env", ".env.local"]) + XCTAssertEqual( + display.keys["dev"]?.sources.first?.entries.map { $0.name }, + ["DATABASE_URL", "STRIPE_KEY"] + ) + XCTAssertEqual(display.keys["prod"]?.vaultLabel, "acme-team vault") + XCTAssertEqual(display.keys["prod"]?.vaultColor, "#b48ce8") + XCTAssertEqual(display.valueCount(forKey: "dev"), 3) + } + + func testValueMetadataIsCappedAndSanitised() { + let manyNames = (0..<200).map { "VALUE_\($0)" } + let display = UnlockDisplayInfo.from(payload: ["display": [ + "keys": [ + "dev": [ + "valueCount": 0, + "sources": (0..<20).map { index in + ["path": ".env.\(index)", "entries": manyNames.map { ["name": $0] }] + }, + // not a colour: dropped rather than drawn + "vaultColor": "red; drop table", + ], + ], + ]]) + + let key = display.keys["dev"] + XCTAssertNil(key?.valueCount, "a count of zero says nothing, so it is not shown") + XCTAssertNil(key?.vaultColor) + XCTAssertLessThanOrEqual(key?.sources.count ?? 0, UnlockKeyDisplay.maxSources) + XCTAssertEqual( + key?.sources.reduce(0) { $0 + $1.entries.count }, + UnlockKeyDisplay.maxEntries + ) + } + + func testValueMetadataFallsBackToTheItemCountsForm() { + let display = UnlockDisplayInfo.from(payload: ["display": ["itemCounts": ["dev": 12]]]) + XCTAssertEqual(display.valueCount(forKey: "dev"), 12) + XCTAssertNil(display.valueCount(forKey: "prod")) + } + + // MARK: - Which keys were asked for + + func testBothKeyFormsAreAccepted() { + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyIds": ["prod", "dev"]]), ["dev", "prod"]) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyId": "dev"]), ["dev"]) + XCTAssertEqual( + UnlockRequestKeys.from(payload: ["keyIds": ["prod"], "keyId": "dev"]), + ["dev", "prod"] + ) + } + + func testKeysAreDedupedAndOrderedSoOneUnlockCoversTheSameSet() { + XCTAssertEqual( + UnlockRequestKeys.from(payload: ["keyIds": ["prod", "dev", "prod"], "keyId": "dev"]), + ["dev", "prod"] + ) + } + + func testNamingNoKeyResolvesToNothingRatherThanADefault() { + // The daemon turns an empty list into NO_KEYS_REQUESTED. What must never + // happen is a key the caller did not name appearing here. + XCTAssertEqual(UnlockRequestKeys.from(payload: nil), []) + XCTAssertEqual(UnlockRequestKeys.from(payload: [:]), []) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyIds": []]), []) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["scope": "session"]), []) + } + + func testBlankAndNonStringKeyIdsAreDropped() { + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyIds": ["", " ", "\n"]]), []) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyId": ""]), []) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyIds": [42, true, "dev"]]), ["dev"]) + XCTAssertEqual(UnlockRequestKeys.from(payload: ["keyIds": "not-an-array"]), []) + } + + func testMissingDisplayIsEmpty() { + XCTAssertTrue(UnlockDisplayInfo.from(payload: nil).isEmpty) + XCTAssertTrue(UnlockDisplayInfo.from(payload: ["keyIds": ["dev"]]).isEmpty) + } +} + +/// Cross-checking the agent session against the project being unlocked. +/// +/// Two halves that arrive by different routes: the session comes off the kernel +/// and the agent's own record of itself, the project comes off the client. Only +/// together do they say anything, and what they say is an observation rather +/// than an accusation, so these pin down when the panel stays quiet as hard as +/// when it speaks. +final class SessionAdvisoryTests: XCTestCase { + private func session(kind: String? = "interactive", cwd: String?) -> AgentSession { + return AgentSession( + productName: "Claude Code", + title: "a session", + startTime: nil, + kind: kind, + workingDirectory: cwd + ) + } + + func testAnAgentInsideTheProjectSaysNothing() { + XCTAssertTrue(UnlockPanelContent.sessionAdvisories( + session: session(cwd: "/Users/dev/projects/api/packages/core"), + projectPath: "/Users/dev/projects/api" + ).isEmpty) + // The project directory itself counts as inside it. + XCTAssertTrue(UnlockPanelContent.sessionAdvisories( + session: session(cwd: "/Users/dev/projects/api"), + projectPath: "/Users/dev/projects/api" + ).isEmpty) + } + + func testAnAgentSomewhereElseIsSaidOutLoud() { + let advisories = UnlockPanelContent.sessionAdvisories( + session: session(cwd: "/Users/dev/projects/other"), + projectPath: "/Users/dev/projects/api" + ) + XCTAssertEqual(advisories.count, 1) + XCTAssertTrue(advisories[0].contains("/Users/dev/projects/other")) + } + + func testANeighbourWithASharedPrefixIsNotInsideAnything() { + // "/a/project-two" starts with "/a/project" and is a different directory. + XCTAssertEqual( + UnlockPanelContent.sessionAdvisories( + session: session(cwd: "/Users/dev/projects/api-two"), + projectPath: "/Users/dev/projects/api" + ).count, + 1 + ) + } + + func testThePrivatePrefixAndSymlinksAreNotAnAnomaly() { + // /tmp is a symlink to /private/tmp on macOS, and the two sides of this + // comparison reach the same directory by different routes. A panel that + // cried anomaly over that would be trained away inside a week. + XCTAssertTrue(UnlockPanelContent.pathIsInside("/private/tmp/work", of: "/tmp/work")) + XCTAssertTrue(UnlockPanelContent.pathIsInside("/tmp/work/inner", of: "/private/tmp/work")) + XCTAssertFalse(UnlockPanelContent.pathIsInside("/tmp/other", of: "/tmp/work")) + } + + func testWithOnlyOneHalfNothingIsSaid() { + // The agent not saying where it is, is not evidence of anything. + XCTAssertTrue(UnlockPanelContent.sessionAdvisories( + session: session(cwd: nil), + projectPath: "/Users/dev/projects/api" + ).isEmpty) + XCTAssertTrue(UnlockPanelContent.sessionAdvisories( + session: session(cwd: "/Users/dev/projects/other"), + projectPath: nil + ).isEmpty) + XCTAssertTrue(UnlockPanelContent.sessionAdvisories(session: nil, projectPath: "/a").isEmpty) + } + + func testBothProblemsAreSaidWhenBothAreTrue() { + let advisories = UnlockPanelContent.sessionAdvisories( + session: session(kind: "print", cwd: "/Users/dev/projects/other"), + projectPath: "/Users/dev/projects/api" + ) + // Nobody watching comes first: it is the one that changes what "this + // session" means. + XCTAssertEqual(advisories.count, 2) + XCTAssertTrue(advisories[0].contains("no person is watching")) + XCTAssertTrue(advisories[1].contains("not in the project above")) + } + + func testTheClientsVersionClaimIsCarriedAndLabelled() { + let content = UnlockPanelContent.build( + plan: UnlockPlanner.plan( + requested: [RequestedKey(keyId: "varlock-default")], + requestedScope: .session, + existing: [:] + ), + requester: PanelRequester(summary: "Requested by varlock"), + display: UnlockDisplayInfo(varlockVersion: "1.17.1-dev") + ) + XCTAssertEqual(content.reportedVarlockVersion, "1.17.1-dev") + } + + func testAVersionThatIsNotOneIsDropped() { + // Client-supplied, so it is checked for being a version at all rather + // than drawn because it arrived. + XCTAssertEqual( + UnlockDisplayInfo.from(payload: ["display": ["varlockVersion": "1.17.1-dev"]]).varlockVersion, + "1.17.1-dev" + ) + XCTAssertNil( + UnlockDisplayInfo.from(payload: ["display": ["varlockVersion": "1.0 \u{1F600} and a sentence"]]) + .varlockVersion + ) + XCTAssertNil( + UnlockDisplayInfo.from(payload: ["display": ["varlockVersion": String(repeating: "9", count: 200)]]) + .varlockVersion + ) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockPreselectionTests.swift b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockPreselectionTests.swift new file mode 100644 index 000000000..883fdcc15 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/UnlockPreselectionTests.swift @@ -0,0 +1,361 @@ +import XCTest +import SessionScoping +@testable import IdentitySessions + +/// Where the panel opens, and why. +/// +/// Two properties matter more than any individual rule, and most of these are +/// about them: +/// +/// 1. nothing can move the preselection outwards. Whatever combination of +/// signals and memory arrives, the answer is never broader than the +/// built-in default. +/// 2. memory only ever narrows, and choosing the default again forgets it. +/// +/// Together those are why a stale or wrong memory is a nuisance rather than a +/// hole: the worst it can do is cost somebody a panel. +final class UnlockPreselectionTests: XCTestCase { + private let bothBreadths: [SessionGrantBreadth] = [.listedItems, .wholeKey] + private let allScopes: [SessionGrantScope] = UnlockPlanner.fullScopes + + private func preselect( + _ signals: UnlockRiskSignals, + remembered: UnlockNarrowing? = nil, + breadths: [SessionGrantBreadth]? = nil, + scopes: [SessionGrantScope]? = nil + ) -> UnlockPreselection { + return UnlockDefaults.preselect( + signals: signals, + remembered: remembered, + offeredBreadths: breadths ?? bothBreadths, + offeredScopes: scopes ?? allScopes + ) + } + + // MARK: - The risk ladder + + func testAKnownKeyInItsOwnProjectIsRoutineAndLandsOnTheBroadDefault() { + let answer = preselect(UnlockRiskSignals(seenBefore: true)) + XCTAssertEqual(answer.risk, .routine) + XCTAssertEqual(answer.breadth, .wholeKey) + XCTAssertEqual(answer.window.scope, .session) + XCTAssertNil(answer.note, "nothing narrowed it, so there is nothing to explain") + } + + func testFirstContactWithAKeyIsElevated() { + let answer = preselect(UnlockRiskSignals(seenBefore: false)) + XCTAssertEqual(answer.risk, .elevated) + XCTAssertEqual(answer.breadth, .listedItems) + XCTAssertEqual(answer.window.scope, .session) + XCTAssertEqual(answer.note, "Narrowed: this key has not been approved here before.") + } + + func testAnAgentSessionIsElevated() { + let answer = preselect(UnlockRiskSignals(hasAgentSession: true, seenBefore: true)) + XCTAssertEqual(answer.risk, .elevated) + XCTAssertEqual(answer.breadth, .listedItems) + XCTAssertEqual(answer.window.scope, .session) + } + + /// varlock's own JavaScript under a signed interpreter is how most installs + /// run, so it is deliberately NOT a foreign script and does not raise the + /// risk on its own. Somebody else's script driving varlock does. + func testAForeignScriptDrivingVarlockIsElevated() { + let answer = preselect(UnlockRiskSignals(actorIsForeignScript: true, seenBefore: true)) + XCTAssertEqual(answer.risk, .elevated) + XCTAssertEqual(answer.breadth, .listedItems) + } + + func testNobodyWatchingIsUnusual() { + let answer = preselect(UnlockRiskSignals(hasAgentSession: true, nobodyWatching: true, seenBefore: true)) + XCTAssertEqual(answer.risk, .unusual) + XCTAssertEqual(answer.breadth, .listedItems) + XCTAssertEqual(answer.window.scope, .once) + XCTAssertEqual(answer.note, "Narrowed: no person is watching this session.") + } + + func testASessionWorkingOutsideTheProjectIsUnusual() { + let answer = preselect(UnlockRiskSignals( + hasAgentSession: true, + sessionOutsideProject: true, + seenBefore: true + )) + XCTAssertEqual(answer.risk, .unusual) + XCTAssertEqual(answer.window.scope, .once) + XCTAssertEqual(answer.note, "Narrowed: this session is working outside the project.") + } + + func testUnverifiedActorCodeIsUnusual() { + let answer = preselect(UnlockRiskSignals(actorCodeUnverified: true, seenBefore: true)) + XCTAssertEqual(answer.risk, .unusual) + XCTAssertEqual(answer.breadth, .listedItems) + XCTAssertEqual(answer.window.scope, .once) + } + + /// The rules read a fact, not a line of copy. Rewording the advisory the + /// panel draws must never turn a risk rule off. + func testTheOutsideProjectSignalIsReadAsAFactRatherThanFromTheAdvisoryText() { + let session = AgentSession( + productName: "Claude Code", + title: nil, + startTime: nil, + kind: "interactive", + workingDirectory: "/code/somewhere-else" + ) + XCTAssertTrue(UnlockPanelContent.isWorkingOutside(session: session, projectPath: "/code/acme")) + XCTAssertFalse(UnlockPanelContent.isWorkingOutside(session: session, projectPath: "/code/somewhere-else")) + // Neither half on its own is evidence of anything. + XCTAssertFalse(UnlockPanelContent.isWorkingOutside(session: session, projectPath: nil)) + XCTAssertFalse(UnlockPanelContent.isWorkingOutside(session: AgentSession?.none, projectPath: "/code/acme")) + } + + // MARK: - Nothing may widen + + func testNoCombinationOfSignalsEverLandsBroaderThanTheDefault() { + // Every combination of the six signals, against a memory and without one. + for bits in 0..<64 { + let signals = UnlockRiskSignals( + hasAgentSession: bits & 1 != 0, + nobodyWatching: bits & 2 != 0, + sessionOutsideProject: bits & 4 != 0, + actorIsForeignScript: bits & 8 != 0, + actorCodeUnverified: bits & 16 != 0, + seenBefore: bits & 32 != 0 + ) + for memory in [nil, UnlockNarrowing(breadth: .listedItems, window: GrantWindow(scope: .once))] { + let answer = preselect(signals, remembered: memory) + XCTAssertLessThanOrEqual( + answer.breadth.restrictiveness, + UnlockDefaults.breadth.restrictiveness, + "signals \(bits) widened the breadth" + ) + XCTAssertLessThanOrEqual( + answer.window.lifetimeRank, + UnlockDefaults.window.lifetimeRank, + "signals \(bits) widened the window" + ) + } + } + } + + // MARK: - Memory only narrows + + func testARememberedNarrowingTightensARoutineRequest() { + let answer = preselect( + UnlockRiskSignals(seenBefore: true), + remembered: UnlockNarrowing(breadth: .listedItems, window: GrantWindow(scope: .once), approvedBefore: true) + ) + XCTAssertEqual(answer.risk, .routine) + XCTAssertEqual(answer.breadth, .listedItems) + XCTAssertEqual(answer.window.scope, .once) + XCTAssertTrue(answer.isRemembered) + XCTAssertEqual(answer.note, UnlockDefaults.rememberedNote) + } + + /// The most-restrictive rule, per axis. A memory that is narrower on one + /// axis and the risk on the other take one half each. + func testTheNarrowestOfEachAxisWins() { + let answer = preselect( + // unusual, so the risk alone wants once + only these + UnlockRiskSignals(nobodyWatching: true, seenBefore: true), + // and the memory wants a 45 minute window, which is longer + remembered: UnlockNarrowing( + breadth: .listedItems, + window: GrantWindow(scope: .duration, durationMs: 2_700_000), + approvedBefore: true + ) + ) + XCTAssertEqual(answer.window.scope, .once, "the shorter of the two windows wins") + XCTAssertEqual(answer.breadth, .listedItems) + } + + func testAMemoryTheRiskHasAlreadyOvertakenIsNotAnnouncedAsRemembered() { + let answer = preselect( + UnlockRiskSignals(nobodyWatching: true, seenBefore: true), + remembered: UnlockNarrowing(breadth: .listedItems, window: GrantWindow(scope: .once), approvedBefore: true) + ) + XCTAssertFalse(answer.isRemembered, "the risk rules got there on their own") + XCTAssertEqual(answer.note, "Narrowed: no person is watching this session.") + } + + // MARK: - Never preselect something the panel does not offer + + func testAPreselectionIsClampedToWhatIsOnOffer() { + // A batch with nothing to narrow to cannot open on the narrow choice. + let noItems = preselect(UnlockRiskSignals(nobodyWatching: true), breadths: [.wholeKey]) + XCTAssertEqual(noItems.breadth, .wholeKey) + XCTAssertEqual(noItems.window.scope, .once, "the other axis still narrows") + + // A strict key offers only `once`, so a remembered window cannot survive. + let strict = preselect( + UnlockRiskSignals(seenBefore: true), + remembered: UnlockNarrowing( + window: GrantWindow(scope: .duration, durationMs: DurationPreset.oneHour.milliseconds) + ), + scopes: [.once] + ) + XCTAssertEqual(strict.window.scope, .once) + } + + // MARK: - Writing it down + + func testOnlyANarrowingIsRemembered() { + let broad = UnlockPreferences.remembering( + existing: nil, + breadth: .wholeKey, + window: GrantWindow(scope: .session), + now: 10 + ) + XCTAssertNil(broad.breadth) + XCTAssertNil(broad.window) + XCTAssertTrue(broad.approvedBefore, "the pair has still been approved here") + } + + func testChoosingTheDefaultAgainForgetsAPreviousNarrowing() { + let key = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default") + var rows = UnlockPreferences.apply( + rows: [:], + rowKey: key, + breadth: .listedItems, + window: GrantWindow(scope: .once), + now: 1 + ) + XCTAssertEqual(rows[key!]?.breadth, .listedItems) + XCTAssertEqual(rows[key!]?.window?.scope, .once) + + rows = UnlockPreferences.apply( + rows: rows, + rowKey: key, + breadth: .wholeKey, + window: GrantWindow(scope: .session), + now: 2 + ) + XCTAssertNil(rows[key!]?.breadth) + XCTAssertNil(rows[key!]?.window) + XCTAssertEqual(rows[key!]?.approvedBefore, true) + } + + func testOneAxisCanBeForgottenWithoutTheOther() { + let key = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default") + var rows = UnlockPreferences.apply( + rows: [:], rowKey: key, breadth: .listedItems, window: GrantWindow(scope: .once), now: 1 + ) + rows = UnlockPreferences.apply( + rows: rows, rowKey: key, breadth: .listedItems, window: GrantWindow(scope: .session), now: 2 + ) + XCTAssertEqual(rows[key!]?.breadth, .listedItems, "still narrowed on breadth") + XCTAssertNil(rows[key!]?.window, "and back to the default on duration") + } + + func testARequestWithNoProjectIsNotRemembered() { + XCTAssertNil(UnlockPreferences.rowKey(projectPath: nil, keyId: "varlock-default")) + let rows = UnlockPreferences.apply( + rows: [:], rowKey: nil, breadth: .listedItems, window: GrantWindow(scope: .once), now: 1 + ) + XCTAssertTrue(rows.isEmpty, "one nameless bucket shared by every project would be worse than none") + } + + // MARK: - "once" says nothing about breadth + + /// The sequence that must not go wrong: tighten by picking `once`, then come + /// back and pick `this session`. Breadth has to be back at its own resolved + /// value, because `once` was an answer about TIME. A duration choice that + /// quietly left the breadth control tightened would be the panel putting + /// words in somebody's mouth. + func testChoosingOnceLeavesNoBreadthNarrowingBehind() { + let key = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default") + + // First visit: the user picks "once". The panel showed no checkbox, so + // there is no breadth choice to record. + var rows = UnlockPreferences.apply( + rows: [:], rowKey: key, breadth: nil, window: GrantWindow(scope: .once), now: 1 + ) + XCTAssertNil(rows[key!]?.breadth, "a duration answer must not write down a breadth opinion") + XCTAssertEqual(rows[key!]?.window?.scope, .once) + + // Second visit, a routine one. The window is still remembered, and it is + // the only thing narrowing anything. + let answer = preselect(UnlockRiskSignals(seenBefore: true), remembered: rows[key!]) + XCTAssertEqual(answer.window.scope, .once) + XCTAssertEqual(answer.breadth, .wholeKey, "breadth is back at its own default, not tightened by time") + + // The user now picks "this session", with the checkbox left ticked. + rows = UnlockPreferences.apply( + rows: rows, rowKey: key, breadth: .wholeKey, window: GrantWindow(scope: .session), now: 2 + ) + XCTAssertTrue(rows[key!] == nil || rows[key!]!.isEmpty || rows[key!]!.window == nil) + let after = preselect(UnlockRiskSignals(seenBefore: true), remembered: rows[key!]) + XCTAssertEqual(after.breadth, .wholeKey) + XCTAssertEqual(after.window.scope, .session) + } + + /// The other half: a narrowing chosen deliberately is not thrown away by a + /// later `once`, because `once` says nothing about breadth in either + /// direction. + func testChoosingOnceDoesNotForgetABreadthNarrowingEither() { + let key = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default") + var rows = UnlockPreferences.apply( + rows: [:], rowKey: key, breadth: .listedItems, window: GrantWindow(scope: .session), now: 1 + ) + XCTAssertEqual(rows[key!]?.breadth, .listedItems) + + rows = UnlockPreferences.apply( + rows: rows, rowKey: key, breadth: nil, window: GrantWindow(scope: .once), now: 2 + ) + XCTAssertEqual(rows[key!]?.breadth, .listedItems, "the unticked box survives a later once") + XCTAssertEqual(rows[key!]?.window?.scope, .once) + } + + // MARK: - The file + + func testAFileRoundTrips() { + let key = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default")! + // A window nothing on the ladder names, so this is also the round trip + // a custom answer takes: typed, remembered, and read back unchanged. + let rows = [key: UnlockNarrowing( + breadth: .listedItems, + window: GrantWindow(scope: .duration, durationMs: 2_700_000), + approvedBefore: true, + savedAt: 1_700_000_000_000 + )] + let decoded = UnlockPreferences.decode(UnlockPreferences.encode(rows)) + XCTAssertEqual(decoded, rows) + } + + /// A hand-edited file must not be able to widen a panel. `breadth: "key"` on + /// disk is not a narrowing, so it is read as no narrowing at all. + func testAFileCannotRememberABroadChoice() { + let json = """ + {"version":1,"projects":{"/code/acme\\u0000varlock-default": + {"breadth":"key","scope":"session","approvedBefore":true,"savedAt":1}}} + """ + let decoded = UnlockPreferences.decode(Data(json.utf8)) + let entry = decoded["/code/acme\u{0000}varlock-default"] + XCTAssertNil(entry?.breadth) + XCTAssertNil(entry?.window) + XCTAssertEqual(entry?.approvedBefore, true) + } + + func testAnUnreadableFileIsTreatedAsEmpty() { + XCTAssertTrue(UnlockPreferences.decode(Data("not json".utf8)).isEmpty) + XCTAssertTrue(UnlockPreferences.decode(Data("{\"version\":99}".utf8)).isEmpty) + XCTAssertTrue(UnlockPreferences.decode(nil).isEmpty) + } + + func testForgettingScopes() { + let acme = UnlockPreferences.rowKey(projectPath: "/code/acme", keyId: "varlock-default")! + let other = UnlockPreferences.rowKey(projectPath: "/code/other", keyId: "varlock-default")! + let rows = [ + acme: UnlockNarrowing(breadth: .listedItems, approvedBefore: true), + other: UnlockNarrowing(breadth: .listedItems, approvedBefore: true), + ] + + let oneProject = UnlockPreferences.forget(rows: rows, projectPath: "/code/acme") + XCTAssertEqual(oneProject.forgotten, 1) + XCTAssertEqual(Array(oneProject.rows.keys), [other]) + + let everything = UnlockPreferences.forget(rows: rows) + XCTAssertEqual(everything.forgotten, 2) + XCTAssertTrue(everything.rows.isEmpty) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json new file mode 100644 index 000000000..98d2840ec --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/IdentitySessionsTests/fixtures/ecies-vector.json @@ -0,0 +1,19 @@ +{ + "note": "Generated by packages/encryption-binary-swift/scripts/generate-ecies-fixture.ts. Do not hand-edit.", + "generatedBy": "varlock TypeScript crypto.ts", + "hkdfSalt": "varlock-ecies-v1", + "identity": { + "version": 2, + "publicKey": "BO2aORgYlp2TLiV55GJCjtD+99feqUL3uQmRWfql1eEtoQLBWJ74OzGJzJMvRC1bqR7u8RPRKDcEb2J8SdfPxiQ=", + "privateKeyPkcs8": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgGwXwH5a18QraGJ+mXawBQVZJNWSq22aVL2gdGDAjocOhRANCAATtmjkYGJadky4leeRiQo7Q/vfX3qlC97kJkVn6pdXhLaECwVie+DsxicyTL0QtW6ke7vET0Sg3BG9ifEnXz8Yk", + "plaintext": "sk-varlock-compat-vector-🔐-multibyte", + "payload": "AgT6qKozohc7u4+lzaPTe9VhIg3S6FCd1ERqJxgA9Q0fTm3ki3lHwRR1i8QS5WjuI4g2Rjqd5uECSFBW15wcXl4P3t6SoCmrhGu5k7BnMz8f4bt8mf75rgQOvftNwrfQjmBW/S6phbTcWjS21Uvd8P3kgPWTvG1uhTZgh0UiiwYOC8UwmA==" + }, + "device": { + "version": 1, + "publicKey": "BNBdvIjqsaqyNmk/9A9fHXrOX+eI7/pqlkLhNMtyVV5I21Px5xW6UYuW05LyqkDhCEYaVtcap7tbnph8Fceeiuo=", + "privateKeyPkcs8": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWkPAQtZru3VpcdiDV1a/9tIoZIsIGqqgvLC+DTF685ahRANCAATQXbyI6rGqsjZpP/QPXx16zl/niO/6apZC4TTLclVeSNtT8ecVulGLltOS8qpA4QhGGlbXGqe7W56YfBXHnorq", + "plaintext": "device payload, same wire format, different version byte", + "payload": "AQRZ0UnEpefEyiUBdMXc52rrv6jgnimRMJl01ZvunHH9PUGQU+D8K4RUfGsB17kYCtR9nCBnpZUZnpwlEU8sbdFu6Cwl1cjr0CuU6EwSL9KGnlo/OYLaMWtHEhDTNoFWdkbpI46U5/+wC8a5/hNZ66JJMzt08Zx5aO0dNgdjCmQ97K+FOGbz6KpaqOzLFcJc/cET4JQr" + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/AgentSessionMetadataTests.swift b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/AgentSessionMetadataTests.swift new file mode 100644 index 000000000..8a28c23f6 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/AgentSessionMetadataTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import SessionScoping + +/// Reading an agent's own record of the session a request came from. +/// +/// The panel says "Claude Code, 'vault panel redesign', started 2:14 PM" only +/// when it can say it truthfully. These cover the ways that record can be wrong +/// (a recycled pid, a session that has ended, a name that is really an id) and +/// assert that each of them costs the row its title rather than putting somebody +/// else's session on the panel. +final class AgentSessionMetadataTests: XCTestCase { + private var home: URL! + + override func setUpWithError() throws { + home = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("varlock-agent-metadata-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: home.appendingPathComponent(".claude/sessions"), + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: home) + } + + private func writeSession(pid: pid_t, _ record: [String: Any]) throws { + let data = try JSONSerialization.data(withJSONObject: record) + try data.write(to: home.appendingPathComponent(".claude/sessions/\(pid).json")) + } + + private var reader: LiveAgentSessionMetadataReader { + return LiveAgentSessionMetadataReader(homeDirectory: home.path) + } + + func testTheSessionsOwnNameAndStartAreRead() throws { + try writeSession(pid: 4242, [ + "pid": 4242, + "sessionId": "9cfa3fb6-c97f-4c5d-8f63-cc0bc6c46557", + "startedAt": 1_700_000_000_000, + "name": "vault panel redesign", + ]) + + let metadata = reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 1_700_000_000) + XCTAssertEqual(metadata?.title, "vault panel redesign") + XCTAssertEqual(metadata?.startTime, 1_700_000_000) + } + + func testARecordLeftBehindByAnotherProcessIsRefused() throws { + // Same pid, but the process running under it now started hours later: + // the record belongs to whatever held this pid before. + try writeSession(pid: 4242, [ + "pid": 4242, + "startedAt": 1_700_000_000_000, + "name": "somebody elses session", + ]) + + XCTAssertNil(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 1_700_050_000)) + } + + func testAMismatchedPidInTheRecordIsRefused() throws { + try writeSession(pid: 4242, ["pid": 9999, "name": "not ours"]) + XCTAssertNil(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)) + } + + func testAnIdIsNeverShownAsATitle() throws { + try writeSession(pid: 4242, [ + "pid": 4242, + "name": "9cfa3fb6-c97f-4c5d-8f63-cc0bc6c46557", + ]) + // The agent falls back to the session id when it has no better name. A + // uuid is not something a person can check anything against, so the row + // goes without a title instead. + XCTAssertNil(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)?.title) + } + + func testATitleIsFlattenedAndCapped() throws { + try writeSession(pid: 4242, [ + "pid": 4242, + "name": " first\nsecond \(String(repeating: "x", count: 200)) ", + ]) + let title = try XCTUnwrap(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)?.title) + XCTAssertFalse(title.contains("\n")) + XCTAssertTrue(title.hasPrefix("first second")) + XCTAssertLessThanOrEqual(title.count, 64) + } + + func testTheFieldsThatChangeADecisionAreRead() throws { + try writeSession(pid: 4242, [ + "pid": 4242, + "startedAt": 1_700_000_000_000, + "name": "worktree-a5147ee0-2c", + "nameSource": "derived", + "kind": "print", + "cwd": "/Users/dev/projects/other", + "entrypoint": "claude-desktop", + "version": "2.1.234", + ]) + + let metadata = try XCTUnwrap(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)) + XCTAssertEqual(metadata.kind, "print") + XCTAssertEqual(metadata.workingDirectory, "/Users/dev/projects/other") + XCTAssertEqual(metadata.entrypoint, "claude-desktop") + XCTAssertEqual(metadata.version, "2.1.234") + // The agent generated this name from a directory. It is still worth + // showing, and it is not somebody's words. + XCTAssertTrue(metadata.isTitleDerived) + } + + func testANameTheUserTypedIsNotMarkedAsGenerated() throws { + try writeSession(pid: 4242, ["pid": 4242, "name": "vault panel redesign"]) + XCTAssertFalse(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)?.isTitleDerived ?? true) + } + + func testAFieldTooLongToBeTheFieldWeWantedIsCut() throws { + // Everything in this file is written by a process running as the user + // and could be anything at all, so a value that is not the short word we + // are looking for is bounded rather than trusted to be small. + try writeSession(pid: 4242, [ + "pid": 4242, + "kind": String(repeating: "k", count: 500), + "cwd": "/tmp/" + String(repeating: "d", count: 500), + ]) + let metadata = try XCTUnwrap(reader.metadata(for: .claudeCode, pid: 4242, processStartTime: 0)) + XCTAssertEqual(metadata.kind?.count, LiveAgentSessionMetadataReader.maxFieldLength) + XCTAssertEqual(metadata.workingDirectory?.count, LiveAgentSessionMetadataReader.maxPathLength) + } + + func testOnlyAStatedKindThatIsNotInteractiveRaisesTheAlarm() { + func session(kind: String?) -> AgentSession { + return AgentSession(productName: "Claude Code", title: nil, startTime: nil, kind: kind) + } + XCTAssertNil(session(kind: "interactive").unattendedNote) + // "The agent did not say" is not evidence that nobody is watching. + XCTAssertNil(session(kind: nil).unattendedNote) + XCTAssertEqual( + session(kind: "print").unattendedNote, + "a print session: no person is watching this agent" + ) + } + + func testAGeneratedNameIsNeverDressedAsSomebodysWords() { + func mark(derived: Bool) -> SessionRootMark { + return SessionRootMark( + label: "Terminal ttys004", + kind: .terminal, + terminal: "ttys004", + agent: AgentSession( + productName: "Claude Code", + title: "some-project-7a", + isTitleDerived: derived, + startTime: nil + ) + ) + } + // Quotation marks are what say "a person wrote this". + XCTAssertEqual(mark(derived: false).quotedTitle, "\u{201C}some-project-7a\u{201D}") + XCTAssertEqual(mark(derived: true).quotedTitle, "some-project-7a") + XCTAssertEqual(mark(derived: true).descriptionLine, "some-project-7a \u{00B7} ttys004") + } + + func testNoRecordAtAllIsNotAFailure() { + XCTAssertNil(reader.metadata(for: .claudeCode, pid: 1234, processStartTime: 0)) + } + + func testCodexHasNoRecordToReadYet() throws { + // Codex keys its rollouts by time and directory rather than by pid, so + // there is nothing to look up without guessing. Detection and the start + // time still work; only the title is missing. + XCTAssertNil(reader.metadata(for: .codex, pid: 4242, processStartTime: 0)) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/ExecutionChainTests.swift b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/ExecutionChainTests.swift new file mode 100644 index 000000000..c98ea4727 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/ExecutionChainTests.swift @@ -0,0 +1,1130 @@ +import XCTest +@testable import SessionScoping + +/// What the panel shows about who is asking, against synthetic process trees. +/// +/// The point of the chain is that one process name is not an answer. These +/// assert the parts a person actually reads: which hop is emphasised, what a +/// script running under an interpreter is called, what the daemon will and will +/// not claim about a signature, and that a tree it cannot read degrades to a +/// smaller answer rather than to no panel. +final class ExecutionChainTests: XCTestCase { + /// A posture answer per pid, for the pids a test cares about. + private struct FakePosture: PostureProbe { + var facts: [pid_t: PeerPostureFacts] = [:] + func posture(forPid pid: pid_t) -> PeerPostureFacts { + return facts[pid] ?? .unreadable + } + } + + private static let signed = PeerPostureFacts( + isTraced: false, + hasHardenedRuntime: true, + signatureValid: true, + isReadable: true + ) + + /// Session records as an agent would have written them, by pid. + private struct FakeSessionMetadata: AgentSessionMetadataReader { + var records: [pid_t: AgentSessionMetadata] = [:] + func metadata(for product: AgentProduct, pid: pid_t, processStartTime: Int) -> AgentSessionMetadata? { + return records[pid] + } + } + + /// Names for the tty devices these trees use. + /// + /// A default, because a device with no name is a machine whose `ttyname` + /// failed, and session scoping falls back to the process tree there. A + /// realistic terminal tree should never be testing that by accident. + private static let ttyNames: [dev_t: String] = [16: "ttys004", 17: "ttys005"] + + override func setUp() { + super.setUp() + // The owning-package lookup is cached by resolved path. These tests write + // manifests under a fresh directory every time, so nothing should carry + // over; clearing makes that a property of the suite rather than of the + // uuid generator. + ExecutionChainBuilder.resetPackageCache() + } + + private func builder( + _ procs: [FakeProc], + ttyNames: [dev_t: String] = ExecutionChainTests.ttyNames, + posture: FakePosture = FakePosture(), + sessionMetadata: AgentSessionMetadataReader = FakeSessionMetadata() + ) -> ExecutionChainBuilder { + return ExecutionChainBuilder( + provider: FakeProcessProvider(procs, ttyNames: ttyNames), + posture: posture, + sessionMetadata: sessionMetadata + ) + } + + /// iTerm2 -> zsh -> varlock: the plain terminal case. + /// + /// The app holds no controlling tty, the way a windowed app does not: it owns + /// the pty the shell below it is on. That is what makes the shell the session + /// the grant attaches to. + private var terminalTree: [FakeProc] { + return [ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, tty: 16, path: "/opt/homebrew/bin/varlock"), + ] + } + + func testTheChainReadsFromTheLauncherDownToTheCaller() { + let chain = builder(terminalTree).build(forPid: 300) + + XCTAssertEqual(chain.hops.map { $0.name }, ["iTerm2", "zsh", "varlock"]) + XCTAssertTrue(chain.hops[0].isLauncher) + XCTAssertEqual(chain.hops[0].bundlePath, "/Applications/iTerm.app") + // A plain single-bundle app: the outer walk finds nothing to walk out + // to, and the evidence line is the bundle itself. It used to be the + // directory the app sits in, which for anything in /Applications read + // "/Applications" and answered nothing; WHICH copy of an app this is, is + // the question the line exists for. + XCTAssertEqual(chain.hops[0].path, "/Applications/iTerm.app") + // The tty is stated once, on the session root, and nowhere else. + XCTAssertEqual(Self.terminalMentions(chain), ["Terminal ttys004"]) + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + } + + /// Every place the panel prints a tty id, in the order it draws them. + /// + /// The invariant this exists for: exactly one, on the row that owns it. A + /// controlling terminal is inherited, so a second mention is the same fact + /// twice, and a mention on the app that was launched is a fact that is not + /// even true of it. + private static func terminalMentions(_ chain: ExecutionChain) -> [String] { + return chain.hops.flatMap { hop -> [String] in + var drawn = [hop.name, hop.via, hop.invocation, hop.runTarget].compactMap { $0 } + if let root = hop.sessionRoot { drawn.append(root.descriptionLine) } + return drawn.filter { $0.contains("ttys") } + } + } + + func testATypedCommandHasNoActorAndSaysWhichSessionItIs() { + let chain = builder(terminalTree).build(forPid: 300) + + // Nothing is bold. The values are for the command, the command is + // varlock, and inventing a third party would be inventing information. + XCTAssertTrue(chain.hops.allSatisfy { !$0.isImportant }) + XCTAssertTrue(chain.hops.first { $0.name == "varlock" }?.isMinor ?? false) + // The session the grant would attach to is the shell on the tty, and it + // is named the way the menu bar names it. + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.label, "Terminal ttys004") + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.kind, .terminal) + XCTAssertNil(chain.agentSession) + // Exactly one, always: "This session" has one answer. + XCTAssertEqual(chain.hops.filter { $0.isSessionRoot }.count, 1) + XCTAssertEqual(chain.hops.map { $0.isInsideSession }, [false, false, true]) + } + + func testTheSessionRootIsTheHopTheGrantWouldAttachTo() { + let procs = [ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 150, ppid: 100, tty: 16, path: "/Users/dev/.local/bin/claude"), + FakeProc(pid: 200, ppid: 150, tty: 16, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, tty: 16, path: "/opt/homebrew/bin/varlock"), + ] + let chain = builder(procs).build(forPid: 300) + + // The same process the scoper keys the grant by, found the same way. + let scoped = SessionScoper(provider: FakeProcessProvider(procs, ttyNames: Self.ttyNames)) + XCTAssertEqual(chain.sessionRootHop?.pid, scoped.sessionAnchor(forPid: 300)?.pid) + XCTAssertEqual(chain.sessionRootHop?.name, "claude") + // The agent decorates that row rather than creating it. + XCTAssertEqual(chain.agentSession?.productName, "Claude Code") + // No shell is bold, and neither is varlock: nothing here qualifies. + XCTAssertTrue(chain.hops.allSatisfy { !$0.isImportant }) + XCTAssertFalse(chain.sessionRootHop?.isMinor ?? true) + } + + func testOnlyOneHopIsEmphasisedInAFullAgentChain() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc(pid: 250, ppid: 200, tty: 16, path: "/Users/dev/.local/bin/claude"), + FakeProc(pid: 260, ppid: 250, path: "/bin/bash", args: ["bash", "-c", "bun run agent.ts"]), + FakeProc(pid: 300, ppid: 260, path: "/Users/dev/.bun/bin/bun", args: ["bun", "run", "agent.ts"]), + FakeProc(pid: 400, ppid: 300, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 400) + + XCTAssertEqual(chain.hops.filter { $0.isImportant }.map { $0.name }, ["agent.ts"]) + XCTAssertEqual(chain.sessionRootHop?.agentSession?.productName, "Claude Code") + // The shells either side of the agent stay minor, so they fold away. + XCTAssertEqual(chain.collapsibleHops.map { $0.name }, ["zsh", "bash", "varlock"]) + // Nothing under the agent has a controlling terminal, so this session is + // anchored on the process tree and there is no tty to state. The panel + // used to state one anyway, borrowed off an ancestor and printed on the + // app, which named a terminal the grant is not even scoped to. + XCTAssertEqual(Self.terminalMentions(chain), []) + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.kind, .processTree) + } + + func testTheTtyIsSaidOnceByTheSessionRootUnderTmux() { + // tmux: the server is the top of the chain and holds no tty of its own, + // so the pane's shell is what "ttys005" actually names, and that shell is + // where the grant attaches. + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/opt/homebrew/bin/tmux"), + FakeProc(pid: 200, ppid: 100, tty: 17, path: "/bin/zsh", env: ["TMUX": "/tmp/tmux-501/default,91,0"]), + FakeProc( + pid: 300, + ppid: 200, + tty: 17, + path: "/opt/homebrew/bin/varlock", + env: ["TMUX": "/tmp/tmux-501/default,91,0"] + ), + ], ttyNames: [17: "ttys005"]).build(forPid: 300) + + XCTAssertEqual(chain.hops.map { $0.name }, ["tmux", "zsh", "varlock"]) + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + // The pane is its own session, and the multiplexer is named with it. + XCTAssertEqual(Self.terminalMentions(chain), ["Terminal ttys005 (tmux)"]) + // tmux is a program rather than a shell, so it is what is running here. + XCTAssertEqual(chain.hops.first { $0.isImportant }?.name, "tmux") + } + + func testAScriptIsTheActorRatherThanTheInterpreterRunningIt() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + tty: 16, + path: "/Users/dev/.bun/bin/bun", + args: ["bun", "run", "scripts/agent.ts"] + ), + FakeProc(pid: 400, ppid: 300, tty: 16, path: "/opt/homebrew/bin/varlock"), + ], posture: FakePosture(facts: [300: Self.signed])).build(forPid: 400) + + let actor = chain.hops.first { $0.isImportant } + XCTAssertEqual(actor?.name, "agent.ts") + XCTAssertEqual(actor?.via, "via bun") + // The interpreter's own signature is real and says nothing about the + // script it was handed, so the panel refuses to launder one into the other. + XCTAssertEqual(actor?.posture, .interpretedScript) + // The warning belongs to that hop, and is drawn under it rather than in + // a legend the reader would have to match back up to a row. + XCTAssertEqual( + actor?.advisory, + "a script run by bun: approval trusts this file, not the signed interpreter" + ) + XCTAssertNil(chain.hops.first { $0.name == "zsh" }?.advisory) + } + + func testTheCallersOwnCommandLineIsReadFromTheKernel() { + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh", args: ["-zsh"]), + FakeProc( + pid: 300, + ppid: 200, + path: "/opt/homebrew/bin/varlock", + args: ["/opt/homebrew/bin/varlock", "run", "--", "next", "dev"] + ), + ]).build(forPid: 300) + + // Named by what was typed, not by where the binary happened to live. + XCTAssertEqual(chain.hops.last?.invocation, "varlock run -- next dev") + // Only one hop is the process that connected, and only that one's + // command line is drawn. + XCTAssertEqual(chain.hops.map { $0.isRequester }, [false, true]) + } + + func testVarlockIsNamedPlainlyHoweverItWasStarted() { + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/Users/dev/.bun/bin/bun", + args: ["bunx", "varlock", "load"] + ), + ], posture: FakePosture(facts: [300: Self.signed])).build(forPid: 300) + + let hop = try? XCTUnwrap(chain.hops.last) + // "varlock via bun" is true and useless: bun is how varlock ships, not + // who is asking. So the row stays plain. + XCTAssertEqual(hop?.name, "varlock") + XCTAssertNil(hop?.via) + XCTAssertNil(hop?.advisory) + XCTAssertEqual(hop?.invocation, "varlock load") + + // But the row is NOT allowed to wear bun's signature. bun really is + // signed with the Hardened Runtime here, and every word of that is about + // bun: what this row names is a directory of JavaScript files that any + // process running as the user can rewrite. This assertion used to read + // `.signedHardened`, which is how `bunx varlock load` came to draw a + // green shield and the word "signed" on a row labelled varlock. + XCTAssertEqual(hop?.posture, .interpretedScript) + // The signature is still reported, attached to what it is a signature of + // and never separated from it. + XCTAssertEqual(hop?.interpreterName, "bun") + XCTAssertEqual(hop?.interpreterPosture, .signedHardened) + // And the row says in words which varlock this is. + XCTAssertEqual(hop?.runtimeForm, "varlock's JavaScript, run by bun, not the standalone binary") + XCTAssertTrue(hop?.runtimeFormIsCaution ?? false) + } + + func testACompiledVarlockSaysSoAndAnswersForItself() { + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/opt/homebrew/bin/varlock", + args: ["/opt/homebrew/bin/varlock", "load"] + ), + ], posture: FakePosture(facts: [300: Self.signed])).build(forPid: 300) + + let hop = chain.hops.last + // A self-contained binary is the one case where the kernel's answer is + // about the code that will run, so the row keeps it. + XCTAssertEqual(hop?.posture, .signedHardened) + XCTAssertNil(hop?.interpreterName) + XCTAssertEqual(hop?.runtimeForm, "the standalone varlock binary") + XCTAssertFalse(hop?.runtimeFormIsCaution ?? true) + } + + func testTheHostCommandIsFoundForAVarlockLoadedInsideSomethingElse() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, path: "/bin/zsh", args: ["-zsh"]), + FakeProc( + pid: 300, + ppid: 200, + path: "/usr/local/bin/node", + args: ["node", "/app/node_modules/.bin/next", "dev"] + ), + FakeProc( + pid: 400, + ppid: 300, + path: "/app/node_modules/.bin/varlock", + args: ["/app/node_modules/.bin/varlock", "load", "--format", "json-full"] + ), + ]).build(forPid: 400) + + // An auto-load runs the same CLI a person would, so varlock's own + // command line is not the one worth showing: the host's is. The host is + // the program, not the shell that started it. + XCTAssertEqual(chain.hostInvocation, "next dev") + XCTAssertEqual(chain.hostProgram?.name, "next") + // Output formatting says nothing about which secrets go where. + XCTAssertEqual(chain.hops.last?.invocation, "varlock load") + } + + func testAVarlockLineKeepsWhatChangesTheRequestAndDropsPresentation() { + let line = ExecutionChainBuilder.invocation(from: [ + "/opt/homebrew/bin/varlock", "load", + "--format", "json-full", "--compact", "--include-internal", + "--env", "production", "--path", ".env.production", + ]) + // Which environment and which file are the request; the rest is printing. + XCTAssertEqual(line, "varlock load --env production --path .env.production") + + // The `=` form takes its value with it, and an unknown flag is kept: + // staying quiet about something we do not recognise is the wrong default + // for a line that exists to be evidence. + XCTAssertEqual( + ExecutionChainBuilder.invocation(from: ["varlock", "load", "--format=json", "--brand-new-flag"]), + "varlock load --brand-new-flag" + ) + } + + func testAVarlockRunNamesTheCommandThatReceivesTheValues() { + let arguments = ["/opt/homebrew/bin/varlock", "run", "--format", "json", "--", "npm", "run", "build"] + XCTAssertEqual(ExecutionChainBuilder.invocation(from: arguments), "varlock run -- npm run build") + // The process that gets the values does not exist yet, so it is in no + // ancestry: argv is the only place it can be read from. + XCTAssertEqual(ExecutionChainBuilder.runTarget(from: arguments), "npm run build") + XCTAssertNil(ExecutionChainBuilder.runTarget(from: ["varlock", "load"])) + XCTAssertNil(ExecutionChainBuilder.runTarget(from: ["node", "server.js"])) + } + + func testTheHostThatAutoLoadedVarlockIsTheActor() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh", args: ["-zsh"]), + FakeProc( + pid: 300, + ppid: 200, + tty: 16, + path: "/usr/local/bin/node", + args: ["node", "/app/node_modules/.bin/next", "dev"] + ), + FakeProc( + pid: 400, + ppid: 300, + tty: 16, + path: "/app/node_modules/.bin/varlock", + args: ["/app/node_modules/.bin/varlock", "load"] + ), + ]).build(forPid: 400) + + // The values are for the dev server; varlock fetched them on its behalf. + XCTAssertEqual(chain.hops.filter { $0.isImportant }.map { $0.name }, ["next"]) + // And the session is still the terminal it was started from. + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.label, "Terminal ttys004") + } + + func testALongCommandLineKeepsTheSubcommandAndTheFirstArguments() { + let long = ExecutionChainBuilder.invocation(from: ["varlock", "run"] + (0..<40).map { "--flag-\($0)" }) + XCTAssertTrue(long?.hasPrefix("varlock run --flag-0") ?? false) + XCTAssertEqual(long?.count, ExecutionChainBuilder.maxVarlockInvocationLength) + XCTAssertTrue(long?.hasSuffix("\u{2026}") ?? false) + XCTAssertNil(ExecutionChainBuilder.invocation(from: [])) + } + + func testALongRunLineElidesTheMiddleAndKeepsTheTarget() { + let long = ExecutionChainBuilder.invocation( + from: ["varlock", "run", "--env", "staging"] + (0..<20).map { "--flag-\($0)" } + + ["--", "npm", "run", "build"] + ) + // The head says what was run and the tail says who receives the values. + // Truncating the tail would drop exactly the half worth reading. + XCTAssertTrue(long?.hasPrefix("varlock run --env staging") ?? false) + XCTAssertTrue(long?.hasSuffix("-- npm run build") ?? false) + XCTAssertTrue(long?.contains("\u{2026}") ?? false) + XCTAssertLessThanOrEqual(long?.count ?? 0, ExecutionChainBuilder.maxVarlockInvocationLength) + } + + func testEveryPostureSaysWhichOneItIs() { + // Each answer gets its own word and its own shape. A blank space used to + // stand for "we are not saying", which on a panel reads as "nothing to + // report": the opposite fact. + XCTAssertEqual(HopPosture.signedHardened.inlineLabel, "signed") + XCTAssertEqual(HopPosture.signedOnly.inlineLabel, "unhardened") + XCTAssertEqual(HopPosture.unsigned.inlineLabel, "unsigned") + XCTAssertEqual(HopPosture.interpretedScript.inlineLabel, "not verified") + XCTAssertEqual(HopPosture.unknown.inlineLabel, "unchecked") + + let shapes = Set(HopPosture.allAnswers.map(\.symbolName)) + XCTAssertEqual(shapes.count, HopPosture.allAnswers.count) + // Only one answer reads as good news, and only one as a caution. + XCTAssertEqual(HopPosture.allAnswers.filter(\.isVerified), [.signedHardened]) + XCTAssertEqual(HopPosture.allAnswers.filter(\.isCaution), [.interpretedScript]) + } + + func testEveryPostureSpellsOutWhatWasAndWasNotChecked() { + for posture in HopPosture.allAnswers { + let explanation = posture.explanation(subject: "\u{201C}varlock\u{201D}", interpreter: "bun") + XCTAssertTrue(explanation.contains("varlock"), "\(posture) never names its subject") + // The half people skip is the half that matters, so every one of + // these has to say what it did NOT establish. + XCTAssertTrue( + explanation.lowercased().contains("not checked") + || explanation.lowercased().contains("nothing about"), + "\(posture) does not say what was left unchecked" + ) + } + // The interpreted case names the interpreter, so the signature and the + // thing it is a signature of can never be read as the same claim. + XCTAssertTrue( + HopPosture.interpretedScript + .explanation(subject: "\u{201C}varlock\u{201D}", interpreter: "bun") + .contains("bun") + ) + } + + func testAnInterpreterWithNoScriptStaysItself() { + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, path: "/usr/local/bin/node", args: ["node", "--version"]), + ]).build(forPid: 300) + + XCTAssertEqual(chain.hops.map { $0.name }, ["zsh", "node"]) + XCTAssertNil(chain.hops[1].via) + } + + func testVarlockRunningAsAScriptIsStillNamedVarlock() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/usr/local/bin/node", + args: ["node", "/project/node_modules/.bin/varlock", "run"] + ), + ]).build(forPid: 300) + + // node running varlock's own CLI is varlock, not a third-party script, so + // it is not called out as somebody else's code and keeps its own name. + XCTAssertEqual(chain.hops.last?.name, "varlock") + // The generic "a script run by node" advisory is still suppressed: the + // varlock row has its own, more specific line instead. + XCTAssertNil(chain.hops.last?.advisory) + XCTAssertEqual( + chain.hops.last?.runtimeForm, + "varlock's JavaScript, run by node, not the standalone binary" + ) + // And the shell above it is still not what is running. + XCTAssertFalse(chain.hops.first { $0.name == "zsh" }?.isImportant ?? true) + } + + func testSignatureIsReportedOnlyWhenItWasActuallyRead() { + let chain = builder( + terminalTree, + posture: FakePosture(facts: [ + 200: Self.signed, + 300: PeerPostureFacts( + isTraced: false, + hasHardenedRuntime: false, + signatureValid: true, + isReadable: true + ), + ]) + ).build(forPid: 300) + + XCTAssertEqual(chain.hops[1].posture, .signedHardened) + // Signed but not hardened is not the same claim, and an unreadable + // process is no claim at all. + XCTAssertEqual(chain.hops[2].posture, .signedOnly) + XCTAssertEqual(chain.hops[0].posture, .unknown) + + // A readable status word with no valid signature is a fourth answer, and + // saying "unhardened" for it would be describing the wrong failure. + let unsigned = builder( + terminalTree, + posture: FakePosture(facts: [ + 300: PeerPostureFacts( + isTraced: false, + hasHardenedRuntime: false, + signatureValid: false, + isReadable: true + ), + ]) + ).build(forPid: 300) + XCTAssertEqual(unsigned.hops[2].posture, .unsigned) + } + + func testAShortChainShowsEverythingAndALongOneFoldsTheBoringHops() { + let short = builder(terminalTree).build(forPid: 300) + XCTAssertFalse(short.collapsesWhenResting) + XCTAssertEqual(short.restingHops.count, 3) + + let long = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, path: "/Users/dev/.bun/bin/bun", args: ["bun", "agent.ts"]), + FakeProc(pid: 400, ppid: 300, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 400) + + XCTAssertTrue(long.collapsesWhenResting) + // The launcher and the actor always stay; the plumbing folds away. + XCTAssertEqual(long.restingHops.map { $0.name }, ["iTerm2", "agent.ts"]) + XCTAssertEqual(long.expanderLabel, "2 more steps (zsh, varlock)") + } + + func testAnAgentSessionIsNamedByItsProductAndWhenItStarted() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc( + pid: 150, + ppid: 100, + startTime: 1_700_000_000, + path: "/Users/dev/.local/bin/claude", + args: ["claude"] + ), + FakeProc(pid: 200, ppid: 150, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 300) + + XCTAssertEqual(chain.agentSession?.productName, "Claude Code") + XCTAssertEqual(chain.sessionRootHop?.pid, 150) + // No record on disk, so the process start is the best answer there is. + XCTAssertEqual(chain.agentSession?.startTime, 1_700_000_000) + XCTAssertNil(chain.agentSession?.title) + } + + func testTheSessionIsAHopWhereItActuallySitsInTheAncestry() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 150, ppid: 100, startTime: 1_700_000_000, path: "/Users/dev/.local/bin/claude"), + FakeProc(pid: 200, ppid: 150, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 300) + + XCTAssertEqual(chain.hops.map { $0.isSessionRoot }, [false, true, false, false]) + // Everything started by the agent reads as inside its session; the app + // that launched the agent does not. + XCTAssertEqual(chain.hops.map { $0.isInsideSession }, [false, false, true, true]) + // The agent is not also the actor, the shell it started is not one, and + // varlock is never one: this request has nothing to emphasise. + XCTAssertTrue(chain.hops.allSatisfy { !$0.isImportant }) + } + + func testTheSessionHopIsNeverFoldedAway() { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 150, ppid: 100, path: "/Users/dev/.local/bin/claude"), + FakeProc(pid: 200, ppid: 150, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, path: "/Users/dev/.bun/bin/bun", args: ["bun", "agent.ts"]), + FakeProc(pid: 400, ppid: 300, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 400) + + XCTAssertTrue(chain.collapsesWhenResting) + XCTAssertEqual(chain.restingHops.map { $0.name }, ["iTerm2", "claude", "agent.ts"]) + XCTAssertEqual(chain.expanderLabel, "2 more steps (zsh, varlock)") + } + + func testTheSessionTitleComesFromTheAgentsOwnRecord() { + let chain = builder( + [ + FakeProc(pid: 150, ppid: 1, startTime: 1_700_000_000, path: "/Users/dev/.local/bin/claude"), + FakeProc(pid: 300, ppid: 150, path: "/opt/homebrew/bin/varlock"), + ], + sessionMetadata: FakeSessionMetadata(records: [ + 150: AgentSessionMetadata(title: "vault panel redesign", startTime: 1_700_000_042), + ]) + ).build(forPid: 300) + + XCTAssertEqual(chain.agentSession?.title, "vault panel redesign") + // The agent's own record of when the session began beats the process + // start, since a session can outlive the process that opened it. + XCTAssertEqual(chain.agentSession?.startTime, 1_700_000_042) + } + + func testAnExportedMarkerDoesNotPutTheAgentsNameOnSomeOtherProcess() { + // The agent itself is out of reach (too far up, or not on the path we + // walked) and what it exported into the shell is still there. That marker + // travels to every descendant, so it says the request came from inside a + // session and nothing about which process this is: `next dev` started by + // an agent carries it too, and is not Claude Code. + let chain = builder([ + FakeProc(pid: 200, ppid: 1, startTime: 1_700_000_500, path: "/bin/zsh", env: ["CLAUDECODE": "1"]), + FakeProc(pid: 300, ppid: 200, path: "/opt/homebrew/bin/varlock", env: ["CLAUDECODE": "1"]), + ]).build(forPid: 300) + + XCTAssertNil(chain.agentSession) + // The session root is still drawn, because a grant still attaches to + // something: the process the scoper anchored on, named as itself. + XCTAssertEqual(chain.sessionRootHop?.pid, 200) + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.label, "Process 200") + } + + func testAnEmptyMarkerIsNotASession() { + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh", env: ["CLAUDECODE": "0"]), + FakeProc(pid: 300, ppid: 200, path: "/opt/homebrew/bin/varlock", env: ["CLAUDECODE": ""]), + ]).build(forPid: 300) + + XCTAssertNil(chain.agentSession) + } + + // MARK: - Launchers nested inside a bigger app + + /// VS Code's integrated terminal, which is what most of this is for. + /// + /// Electron editors spawn the shell from a helper bundle buried inside the + /// real app, so the process path names something nobody launched. + private var vsCodeTree: [FakeProc] { + return [ + FakeProc( + pid: 100, + ppid: 1, + path: "/Applications/Visual Studio Code.app/Contents/Frameworks" + + "/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)" + ), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + tty: 16, + path: "/opt/homebrew/bin/varlock", + args: ["/opt/homebrew/bin/varlock", "load"] + ), + ] + } + + func testANestedHelperIsDrawnAsTheAppThatWasLaunched() { + let chain = builder(vsCodeTree).build(forPid: 300) + let launcher = chain.hops[0] + + // The name and the icon come from the outermost bundle. "Visual Studio + // Code" beats the plist's own "Code" only because it is the fuller form + // of the same name, which is what Finder and the Dock show. + XCTAssertEqual(launcher.name, "Visual Studio Code") + XCTAssertEqual(launcher.bundlePath, "/Applications/Visual Studio Code.app") + // The helper is still said, once the chain is opened: honest, just not + // the headline. + XCTAssertEqual( + launcher.path, + "/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app" + ) + XCTAssertTrue(launcher.isLauncher) + // A helper is a windowed app, not a command: it never becomes the actor. + XCTAssertTrue(chain.hops.allSatisfy { !$0.isImportant }) + } + + func testTheTtyIsSaidOnceInAVSCodeTerminal() { + let chain = builder(vsCodeTree).build(forPid: 300) + + XCTAssertEqual(chain.hops.map { $0.name }, ["Visual Studio Code", "zsh", "varlock"]) + // Was twice: once appended to the launcher's name, once in the session + // root's own label. The launcher holds no controlling tty of its own. + XCTAssertEqual(Self.terminalMentions(chain), ["Terminal ttys004"]) + XCTAssertEqual(chain.sessionRootHop?.name, "zsh") + XCTAssertEqual(chain.sessionRootHop?.sessionRoot?.terminal, "Terminal ttys004") + } + + func testEveryFlavourOfNestedHelperResolvesToItsOuterApp() { + let cases: [(path: String, bundle: String, name: String)] = [ + ( + "/Applications/Cursor.app/Contents/Frameworks/Cursor Helper (Renderer).app" + + "/Contents/MacOS/Cursor Helper (Renderer)", + "/Applications/Cursor.app", + "Cursor" + ), + ( + "/Users/dev/build/MyEditor.app/Contents/Frameworks/Electron Helper.app" + + "/Contents/MacOS/Electron Helper", + "/Users/dev/build/MyEditor.app", + "MyEditor" + ), + ] + for testCase in cases { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: testCase.path), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, tty: 16, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 300) + + XCTAssertEqual(chain.hops[0].bundlePath, testCase.bundle, testCase.path) + // The outer app may not be installed on the machine running this, so + // the name falls back to the bundle's own file name, which is the + // outer one either way. + XCTAssertEqual(chain.hops[0].name, testCase.name, testCase.path) + XCTAssertEqual(Self.terminalMentions(chain), ["Terminal ttys004"], testCase.path) + } + } + + func testAPlainAppIsUntouchedByTheWalkOutwards() { + // One bundle in the path, so there is nothing to walk out to: the name + // and the icon are read from exactly the bundle the process is in. + for path in [ + "/Applications/iTerm.app/Contents/MacOS/iTerm2", + "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal", + ] { + let chain = builder([ + FakeProc(pid: 100, ppid: 1, path: path), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 200, tty: 16, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 300) + + let expected = String(path[path.startIndex.. (directory: String, path: String) { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("chain-script-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent(name) + FileManager.default.createFile(atPath: file.path, contents: Data("// script".utf8)) + // Resolved through the symlinks, because /var and /tmp are symlinks on + // macOS and a package manager's `node_modules/.bin` entry is one too, so + // the resolver follows them and a test has to compare what it produces. + let resolved = (directory.path as NSString).resolvingSymlinksInPath + return (resolved, (file.path as NSString).resolvingSymlinksInPath) + } + + private func chainRunning(_ argument: String, in directory: String?, script: String) -> ExecutionChain { + return builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/Users/dev/.bun/bin/bun", + args: ["bun", "run", argument], + cwd: directory + ), + FakeProc(pid: 400, ppid: 300, path: "/opt/homebrew/bin/varlock"), + ]).build(forPid: 400) + } + + func testAScriptGivenByAnAbsolutePathIsResolvedToItsFile() throws { + let scratch = try scratchScript(named: "agent.ts") + let chain = chainRunning(scratch.path, in: nil, script: scratch.path) + + let actor = chain.hops.first { $0.isImportant } + XCTAssertEqual(actor?.name, "agent.ts") + // The file itself, so the panel can ask the system what it looks like. + // Asking about the extension instead would be asking an ambiguous + // question: ".ts" is registered for MPEG transport streams too. + XCTAssertEqual(actor?.scriptPath, scratch.path) + } + + func testARelativeScriptIsResolvedAgainstTheProcessesOwnDirectory() throws { + let scratch = try scratchScript(named: "agent.ts") + let chain = chainRunning("./agent.ts", in: scratch.directory, script: scratch.path) + + XCTAssertEqual(chain.hops.first { $0.isImportant }?.scriptPath, scratch.path) + } + + func testAScriptWithNoReadableDirectoryHasNoPathRatherThanAGuess() throws { + let scratch = try scratchScript(named: "agent.ts") + // The kernel would not say where this process was started, so a relative + // argument names nothing we can point at. The panel draws a plain page. + let chain = chainRunning("agent.ts", in: nil, script: scratch.path) + + let actor = chain.hops.first { $0.isImportant } + XCTAssertEqual(actor?.name, "agent.ts") + XCTAssertNil(actor?.scriptPath) + } + + func testAScriptArgumentThatIsNotAFileResolvesToNothing() { + let chain = chainRunning("/no/such/place/agent.ts", in: "/tmp", script: "") + XCTAssertNil(chain.hops.first { $0.isImportant }?.scriptPath) + } + + func testVarlocksOwnCliIsNeverTreatedAsSomebodysScript() throws { + let scratch = try scratchScript(named: "varlock") + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/Users/dev/.bun/bin/bun", + args: ["bunx", "varlock", "load"], + cwd: scratch.directory + ), + ]).build(forPid: 300) + + // It is varlock, drawn with varlock's own mark, and not a third party's + // file that happens to be sitting in the working directory. + XCTAssertEqual(chain.hops.last?.name, "varlock") + XCTAssertNil(chain.hops.last?.via) + // The file IS resolved, though. It used to be skipped on the reasoning + // that varlock does not need introducing to itself, which left the panel + // unable to answer "which varlock is this" for a node_modules copy: the + // whole question in the interpreted case. + XCTAssertEqual(chain.hops.last?.scriptPath, scratch.path) + } + + /// An installed npm package on disk, entered the way one really is: + /// `/node_modules//bin/cli.js` with the package's manifest + /// above it. `node_modules/.bin/` is a symlink to that file, so what a + /// runner hands the interpreter is this path, whose file name is `cli.js` + /// and says nothing about which package it came from. + private func installedPackage( + manifestName: String, + version: String = "1.17.1", + directory: String = "varlock" + ) throws -> String { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("chain-package-\(UUID().uuidString)") + let packageRoot = root.appendingPathComponent("node_modules/\(directory)") + try FileManager.default.createDirectory( + at: packageRoot.appendingPathComponent("bin"), + withIntermediateDirectories: true + ) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let entry = packageRoot.appendingPathComponent("bin/cli.js") + try Data("#!/usr/bin/env node\n".utf8).write(to: entry) + try JSONSerialization + .data(withJSONObject: ["name": manifestName, "version": version]) + .write(to: packageRoot.appendingPathComponent("package.json")) + // /var and /tmp are symlinks on macOS and the resolver follows them, so + // a test has to compare against what it will produce. + return (entry.path as NSString).resolvingSymlinksInPath + } + + /// `bunx varlock load` against an installed copy: bun with the resolved + /// entry file in argv. + private func bunxVarlockTree(entry: String) -> [FakeProc] { + return [ + FakeProc(pid: 100, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + FakeProc(pid: 200, ppid: 100, tty: 16, path: "/bin/zsh", args: ["-zsh"]), + FakeProc( + pid: 300, + ppid: 200, + tty: 16, + path: "/Users/dev/.bun/bin/bun", + args: ["bun", entry, "load"] + ), + ] + } + + func testVarlocksVersionIsReadFromThePackageItCameOutOf() throws { + let entry = try installedPackage(manifestName: "varlock", version: "1.17.1-dev") + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/Users/dev/.bun/bin/bun", + args: ["bun", entry, "load"] + ), + ]).build(forPid: 300) + + // Read off disk by the daemon, so it is stated flatly. A build-type + // suffix survives on purpose: a dev build is not the published artifact. + XCTAssertEqual(chain.hops.last?.release, HopRelease(version: "1.17.1-dev", source: .readFromDisk)) + XCTAssertTrue(chain.hops.last?.release?.isPrerelease ?? false) + XCTAssertEqual(chain.hops.last?.release?.displayValue, "1.17.1-dev") + // The same manifest that gives the version gives the name. These two + // used to be answered from different evidence, and the row read "cli.js" + // with varlock's own version printed underneath it. + XCTAssertEqual(chain.hops.last?.name, "varlock") + // A version the client merely asserted always says who asserted it. + XCTAssertEqual( + HopRelease(version: "1.17.1", source: .clientReported).displayValue, + "1.17.1 (reported by the caller)" + ) + } + + func testAnInstalledVarlockIsRecognisedByThePackageItCameOutOf() throws { + let entry = try installedPackage(manifestName: "varlock") + let chain = builder( + bunxVarlockTree(entry: entry), + posture: FakePosture(facts: [300: Self.signed]) + ).build(forPid: 300) + + let hop = try XCTUnwrap(chain.hops.last) + // `bunx varlock load` drew a row called "cli.js" and treated varlock as + // somebody's stray node script. The file name is the one part of that + // path that says nothing; the package it sits in says everything. + XCTAssertEqual(hop.name, "varlock") + XCTAssertTrue(hop.isVarlock) + // Which is what puts varlock's own mark on the row rather than a + // document icon, and what suppresses "via bun": bun is how varlock ships. + XCTAssertNil(hop.via) + // Normalised by varlock's own rule, so the line reads as the act a + // person performed rather than as the file a runner resolved. + XCTAssertEqual(hop.invocation, "varlock load") + // varlock is never the actor. Nothing in this chain is bold, because the + // values are for the command and the command is varlock itself. + XCTAssertTrue(chain.hops.allSatisfy { !$0.isImportant }) + XCTAssertEqual(hop.release, HopRelease(version: "1.17.1", source: .readFromDisk)) + // And the row says which varlock this is, in words. + XCTAssertEqual(hop.runtimeForm, "varlock's JavaScript, run by bun, not the standalone binary") + XCTAssertTrue(hop.runtimeFormIsCaution) + } + + func testRecognisingVarlockRestoresNoClaimAboutTheCodeItself() throws { + let entry = try installedPackage(manifestName: "varlock") + let chain = builder( + bunxVarlockTree(entry: entry), + posture: FakePosture(facts: [300: Self.signed]) + ).build(forPid: 300) + + let hop = try XCTUnwrap(chain.hops.last) + // Knowing WHICH package these files came out of says nothing about + // whether anyone signed them. They are ordinary JavaScript any process + // running as the user can rewrite, and calling the row varlock must + // never be a route back to a green shield on unsigned code. + XCTAssertEqual(hop.posture, .interpretedScript) + XCTAssertEqual(hop.interpreterName, "bun") + XCTAssertEqual(hop.interpreterPosture, .signedHardened) + + let evidence = hop.evidence + XCTAssertEqual(evidence.map(\.label), ["program", "interpreter", "version"]) + // The signature is stated beside the name of what it is a signature of, + // and nowhere else. The program line carries no posture at all. + XCTAssertEqual(evidence[0].value, entry) + XCTAssertNil(evidence[0].posture) + XCTAssertEqual(evidence[1].posture, .signedHardened) + XCTAssertEqual(evidence[1].postureSubject, "\u{201C}bun\u{201D}") + } + + func testAScriptFromSomebodyElsesPackageIsNotVarlock() throws { + // The same shape exactly, down to the `bin/cli.js`, with a manifest that + // says something else. Nothing about the path is evidence; the name in + // the manifest is the whole test. + let entry = try installedPackage(manifestName: "helpful-tools", directory: "helpful-tools") + let chain = builder( + bunxVarlockTree(entry: entry), + posture: FakePosture(facts: [300: Self.signed]) + ).build(forPid: 300) + + let hop = try XCTUnwrap(chain.hops.last) + XCTAssertEqual(hop.name, "cli.js") + XCTAssertFalse(hop.isVarlock) + XCTAssertEqual(hop.via, "via bun") + // A third party's script IS the actor: it is what the values are for. + XCTAssertTrue(hop.isImportant) + XCTAssertEqual(hop.invocation, "cli.js load") + XCTAssertEqual( + hop.advisory, + "a script run by bun: approval trusts this file, not the signed interpreter" + ) + // Neither varlock's version line nor varlock's runtime line is drawn for + // somebody else's package. + XCTAssertNil(hop.release) + XCTAssertNil(hop.runtimeForm) + XCTAssertEqual(hop.posture, .interpretedScript) + } + + func testAThirdPartyScriptWithNoPackageAroundItIsUnchanged() throws { + let scratch = try scratchScript(named: "agent.ts") + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/usr/local/bin/node", + args: ["node", scratch.path] + ), + ], posture: FakePosture(facts: [300: Self.signed])).build(forPid: 300) + + let hop = try XCTUnwrap(chain.hops.last) + // No manifest anywhere above it, so the walk finds nothing and the hop + // is what it always was: the bold actor, named after its own file. + XCTAssertEqual(hop.name, "agent.ts") + XCTAssertTrue(hop.isImportant) + XCTAssertEqual(hop.via, "via node") + XCTAssertEqual(hop.posture, .interpretedScript) + XCTAssertNil(hop.release) + XCTAssertNil(hop.runtimeForm) + } + + func testTheCompiledBinaryHasNoPackageVersionToRead() { + let chain = builder(terminalTree).build(forPid: 300) + // Nothing is invented for it. The panel falls back to what the client + // said and labels it, rather than the chain making something up. + XCTAssertNil(chain.hops.last?.release) + // A `varlock load` typed against the compiled binary is untouched by any + // of this: no script to resolve, no package to read, and the name was + // never in doubt. + XCTAssertEqual(chain.hops.last?.name, "varlock") + XCTAssertTrue(chain.hops.last?.isVarlock ?? false) + XCTAssertNil(chain.hops.last?.interpreterName) + XCTAssertEqual(chain.hops.last?.runtimeForm, "the standalone varlock binary") + } + + func testEvidenceNamesTheProgramTheInterpreterAndTheVersion() throws { + let scratch = try scratchScript(named: "varlock") + let chain = builder([ + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc( + pid: 300, + ppid: 200, + path: "/Users/dev/.bun/bin/bun", + args: ["bun", scratch.path, "load"] + ), + ], posture: FakePosture(facts: [300: Self.signed])).build(forPid: 300) + + let evidence = try XCTUnwrap(chain.hops.last).evidence + XCTAssertEqual(evidence.map(\.label), ["program", "interpreter"]) + XCTAssertEqual(evidence[0].value, scratch.path) + XCTAssertTrue(evidence[0].isPath) + // The signature rides on the interpreter's own line and nowhere else. + XCTAssertNil(evidence[0].posture) + XCTAssertEqual(evidence[1].value, "/Users/dev/.bun/bin/bun") + XCTAssertEqual(evidence[1].posture, .signedHardened) + XCTAssertEqual(evidence[1].postureSubject, "\u{201C}bun\u{201D}") + } + + func testAProcessTheDaemonCannotReadDegradesToAnEmptyChain() { + let chain = builder([]).build(forPid: 999) + XCTAssertTrue(chain.isEmpty) + XCTAssertNil(chain.agentSession) + XCTAssertNil(chain.expanderLabel) + } + + func testAWalkThatRunsOutOfTimeStopsWhereItGot() { + // A clock that jumps past the deadline on its first check: the chain + // comes back short rather than the panel coming back late. + var ticks = 0 + let slow = ExecutionChainBuilder( + provider: FakeProcessProvider(terminalTree, ttyNames: [:]), + posture: FakePosture(), + clock: { + ticks += 1 + return Date(timeIntervalSince1970: ticks == 1 ? 0 : 10) + } + ) + let chain = slow.build(forPid: 300) + XCTAssertEqual(chain.hops.map { $0.name }, ["varlock"]) + XCTAssertNil(chain.agentSession) + } +} diff --git a/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/FakeProcessProvider.swift b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/FakeProcessProvider.swift index 51f1c88f1..99b1d3ef9 100644 --- a/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/FakeProcessProvider.swift +++ b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/FakeProcessProvider.swift @@ -11,6 +11,9 @@ struct FakeProc { var path: String? var args: [String] = [] var env: [String: String] = [:] + /// Current directory, as `proc_pidinfo` would report it; nil means "could + /// not be read", which is what an unreadable process answers. + var cwd: String? /// Session leader pid as `getsid` would report; `0` means unknown. var sid: pid_t = 0 } @@ -45,6 +48,10 @@ final class FakeProcessProvider: ProcessProvider { return procs[pid]?.path } + func workingDirectory(for pid: pid_t) -> String? { + return procs[pid]?.cwd + } + func ttyName(forDevice dev: dev_t) -> String? { return ttyNames[dev] } diff --git a/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/RequesterDescriptionTests.swift b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/RequesterDescriptionTests.swift new file mode 100644 index 000000000..7d9405d77 --- /dev/null +++ b/packages/encryption-binary-swift/swift/Tests/SessionScopingTests/RequesterDescriptionTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import SessionScoping + +/// The lines the approval panel treats as trustworthy. +/// +/// They are only worth trusting if they come off the process tree, so these run +/// the describer against synthetic trees rather than whatever happens to be +/// running on the test machine. +final class RequesterDescriptionTests: XCTestCase { + + private func describe(_ procs: [FakeProc], ttyNames: [dev_t: String] = [:], pid: pid_t) -> RequesterDescription { + return RequesterDescriber(provider: FakeProcessProvider(procs, ttyNames: ttyNames)).describe(forPid: pid) + } + + func testChainReadsFromTheCallerOutward() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, tty: 16, path: "/usr/local/bin/node"), + FakeProc(pid: 200, ppid: 300, tty: 16, path: "/opt/homebrew/bin/claude"), + FakeProc(pid: 300, ppid: 1, tty: 16, path: "/bin/zsh"), + ], ttyNames: [16: "ttys004"], pid: 100) + + XCTAssertEqual(description.processChain, ["node", "claude", "zsh"]) + XCTAssertEqual(description.chainSummary, "node ← claude ← zsh") + XCTAssertEqual(description.terminalName, "ttys004") + XCTAssertEqual(description.sessionSummary, "Terminal ttys004") + XCTAssertEqual(description.panelLines, ["Requested by node ← claude ← zsh", "Terminal ttys004"]) + } + + func testTheRestingLineNamesTheNearestProcessAndTheTerminal() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, tty: 16, path: "/usr/local/bin/node"), + FakeProc(pid: 200, ppid: 300, tty: 16, path: "/opt/homebrew/bin/claude"), + FakeProc(pid: 300, ppid: 1, tty: 16, path: "/bin/zsh"), + ], ttyNames: [16: "ttys004"], pid: 100) + + // The panel shows this one line at rest, so it has to answer "is this me?" + // without making anyone read an ancestry chain. + XCTAssertEqual(description.summaryLine, "Requested by node in ttys004") + // The chain is still there, one disclosure click away. + XCTAssertEqual(description.detailLines, ["Process: node ← claude ← zsh", "Terminal ttys004"]) + } + + func testTheRestingLineStillWorksWithNoTerminal() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, path: "/usr/local/bin/node"), + FakeProc(pid: 200, ppid: 1, path: "/Applications/Cursor.app/Contents/MacOS/Cursor"), + ], pid: 100) + + XCTAssertEqual(description.summaryLine, "Requested by node") + XCTAssertEqual(description.detailLines.last, "No terminal (background process)") + } + + func testTheRestingLineSaysSomethingEvenWithNothingToReadOff() { + let description = describe([], pid: 999) + XCTAssertEqual(description.summaryLine, "Requested by unknown process") + } + + func testNoTerminalSaysSoRatherThanGuessing() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, path: "/usr/local/bin/node"), + FakeProc(pid: 200, ppid: 1, path: "/Applications/Cursor.app/Contents/MacOS/Cursor"), + ], pid: 100) + + XCTAssertNil(description.terminalName) + XCTAssertEqual(description.sessionSummary, "No terminal (background process)") + XCTAssertEqual(description.processChain, ["node", "Cursor"]) + } + + func testChainStopsBeforeLaunchd() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, path: "/bin/varlock"), + FakeProc(pid: 200, ppid: 1, path: "/bin/zsh"), + FakeProc(pid: 1, ppid: 0, path: "/sbin/launchd"), + ], pid: 100) + XCTAssertEqual(description.processChain, ["varlock", "zsh"]) + } + + func testChainIsBounded() { + var procs: [FakeProc] = [] + for index in 0..<20 { + procs.append(FakeProc(pid: pid_t(100 + index), ppid: pid_t(101 + index), path: "/bin/proc\(index)")) + } + let description = describe(procs, pid: 100) + XCTAssertEqual(description.processChain.count, RequesterDescriber.maxChainLength) + } + + func testRepeatedWrapperNamesAreCollapsed() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, path: "/bin/zsh"), + FakeProc(pid: 200, ppid: 300, path: "/bin/zsh"), + FakeProc(pid: 300, ppid: 1, path: "/Applications/iTerm.app/Contents/MacOS/iTerm2"), + ], pid: 100) + XCTAssertEqual(description.processChain, ["zsh", "iTerm2"]) + } + + func testUnknownProcessStillProducesUsableLines() { + let description = describe([], pid: 999) + XCTAssertEqual(description.chainSummary, "unknown process") + XCTAssertEqual(description.panelLines.count, 2) + } + + func testTerminalIsTakenFromTheNearestProcessThatHasOne() { + let description = describe([ + FakeProc(pid: 100, ppid: 200, path: "/bin/node"), + FakeProc(pid: 200, ppid: 1, tty: 21, path: "/bin/zsh"), + ], ttyNames: [21: "ttys009"], pid: 100) + XCTAssertEqual(description.terminalName, "ttys009") + } +} diff --git a/packages/varlock-website/src/content/docs/guides/caching.mdx b/packages/varlock-website/src/content/docs/guides/caching.mdx index 465cc4c46..6b808caa4 100644 --- a/packages/varlock-website/src/content/docs/guides/caching.mdx +++ b/packages/varlock-website/src/content/docs/guides/caching.mdx @@ -67,6 +67,7 @@ A few things to keep in mind: - Only entry **values** are encrypted. Cache keys are stored in plaintext and include file paths, item names, and resolver source text. - The disk cache is per-OS-user and shared across all projects. This is intentional - projects often share config - but it means any env file you load can read and write it, so treat untrusted repos accordingly. - With the file-based encryption fallback, the decryption key sits on the same disk as the cache, so encryption is obfuscation-only (see warning above). +- The cache shares its encryption key with your `varlock("local:...")` env values, so one approval covers both. Reading a cached value can be what raises the [unlock panel](/guides/local-encryption/#the-unlock-panel), and when it does the panel lists the cache alongside the env files under that key, with how many cached values it holds and which plugins and files filled it. Which of them the run reaches first makes no difference: the panel lists both either way, because one approval opens both. ## Concurrent runs diff --git a/packages/varlock-website/src/content/docs/guides/local-encryption.mdx b/packages/varlock-website/src/content/docs/guides/local-encryption.mdx index 1c5851b5e..a7330959f 100644 --- a/packages/varlock-website/src/content/docs/guides/local-encryption.mdx +++ b/packages/varlock-website/src/content/docs/guides/local-encryption.mdx @@ -73,12 +73,177 @@ varlock reveal API_KEY # securely reveal specific item varlock reveal API_KEY --copy # copy to clipboard ``` -Use [`varlock lock`](/reference/cli/encryption/#lock) to invalidate biometric session cache when stepping away: +Use [`varlock sessions`](/reference/cli/encryption/#sessions) to see what is currently unlocked, and [`varlock lock`](/reference/cli/encryption/#lock) to end a session when stepping away: ```bash -varlock lock +varlock sessions # what is unlocked right now +varlock lock --current # end this terminal's session +varlock lock # end every session on the machine ``` +## Unlock sessions + +On a machine with a presence check (Touch ID, Windows Hello, polkit/PAM), values are encrypted to an **identity key** rather than straight to the device key. The identity key sits in between: + +``` +device key -> identity key -> your values +``` + +The identity's private key is never stored in the clear. It is wrapped to your device key, so opening it goes through the same hardware gate as before. What changes is how often you are asked. The daemon unwraps the identity once, holds it for the session, and every later decrypt in that session runs with no prompt. One approval covers a whole env file, and it keeps covering it until something ends the session. + +This replaces the old behavior, where the enclave's own reuse window meant a re-prompt every five minutes. + +Encryption is unaffected: it only needs the identity's public key, so writing a value never prompts, never needs the daemon, and works on headless hosts. + +### The unlock panel + +When an unlock is needed, the daemon draws a panel before anything is decrypted. The daemon draws it rather than the CLI because the daemon is the process that verified who connected, so it is the only one that can say truthfully who is asking. It shows: + +- **Who is asking**, read from the connecting process: the process chain (`node ← claude ← zsh`) and the terminal it is attached to. A caller cannot dress itself up as something else, because these lines are derived rather than sent. +- **What would be unlocked**: one row per key, with a count of how many values it covers. Open a row to see the sources behind it, listed together because one approval on a key opens all of them: each `.env` file with the value names it defined, and varlock's [value cache](/guides/caching/) with how many cached values it holds and which plugins and files filled it. Each source carries a badge with how many values it contributes. What is listed is everything that one approval will open during this run, not only the value that happened to need the key first, so the panel reads the same whether an env file or a cached value triggered it. A row whose caller reported nothing says `contents not reported` rather than showing an empty list. +- **For how long**: the scope. +- **How much it covers**: one checkbox. See [Breadth](#breadth) below. +- Optionally the project name and path, shown dimmed underneath. That part is sent by the CLI, so it is treated as decoration: it changes the wording, never the decision. The same goes for everything inside an opened row: the daemon cannot know what an env value is called or what filled a cache, so it labels that detail as reported by the client. + +Only after you approve does the system's Touch ID sheet appear. A second unlock in the same session asks only about keys that are new, and asks nothing at all when everything requested is already covered. + +Two answers other than yes: + +- **Declined**: you were asked and said no. Varlock reports that rather than a decryption failure. +- **No UI**: there is no graphical session to ask on, such as a plain SSH connection. The daemon refuses rather than skipping the question. For a host that genuinely has nobody to ask, create a key with no presence gate: `varlock-local-encrypt generate-key --key-id --no-auth`. + +### Breadth + +Under the scope control sits one checkbox, **Auto-unlock all items in this vault**, ticked by default: + +| State | Covers | +|-------|--------| +| ticked (the default) | anything the vaults on the panel can decrypt | +| unticked | exactly the encrypted values listed on the panel | + +It is always in the same place, whatever the request names. One checkbox governs every key in the request. + +Picking `once` hides the checkbox and grants narrow. "Once" already means "just this, right now", and the combination worth keeping under it is the narrow one: if the batch turns out to contain something the panel did not list, being asked again is the right outcome rather than a wrong one. The sentence under the controls still says what the grant covers, so nothing about this is hidden. It also does not teach varlock anything about breadth: `once` is an answer about time, so picking it never records a breadth narrowing, and the checkbox comes back at its own default (or your remembered value) the next time you pick a longer scope. + +Unticking it is enforced by the daemon, not filtered by the CLI. The daemon binds the grant to the SHA-256 digests of the exact ciphertexts it was handed, computed on its own side. A later decrypt carrying anything outside that set is refused and raises a fresh panel, the same way a key nobody has unlocked yet does. Names, files and counts sent by the CLI are display only, and none of them can widen what a grant covers. + +Outside `once` the two axes are independent, and the sentence under the controls says the combination you have picked. Read that sentence rather than the value list: while the box is ticked, the list is what the grant covers right now, not what defines it, which is why the sentence says "not just the 12 listed above". Unticked, the list is the definition and the sentence says "only". + +The [value cache](/guides/caching/) is never narrowed, whichever way you answer, and the panel says so next to the choice. Cache entries are written by varlock itself and rewritten whenever a cached value is renewed, so an approval bound to their ciphertexts would refuse the next read of a value that only changed because its TTL came round, and you would answer a panel per refresh. So the cache is always covered as a whole. The daemon checks a cache read against varlock's own cache file at a path it computes itself, rather than believing a caller that says a payload came from the cache. + +#### The vault boundary + +The checkbox has a ceiling that is not a control. A ticked approval covers other keys inside the vaults the panel showed, and never reaches a vault that was not on it: a key in an unshown vault raises a fresh panel however broad the approval was. Today every key sits in one implicit local vault, so this rarely shows itself, but the boundary is the vault rather than the key so that it still holds when there are several. + +#### What is preselected + +The broad default (anything on this key, for this session) is what an ordinary request opens on. Requests that look less ordinary start narrower: + +| The request | Opens on | +|-------------|----------| +| a key approved here before, in its own project, with somebody watching | ticked, this session | +| a first approval of this key here, an agent session, or somebody else's script driving varlock | unticked, this session | +| nobody watching the session, a session working outside the project it is unlocking, or code with no signature macOS accepts | once, which is narrow and draws no checkbox | + +varlock's own JavaScript run by `node` or `bun` is not treated as somebody else's script: that is how most installs run. + +If you tighten an approval, varlock remembers it and preselects the tighter option next time, saying on the panel that it did. It is stored per project and key under your user varlock directory, never in project files. It only ever remembers tightening: the ticked box is already the default, so there is nothing to remember about leaving it ticked, and ticking it again is what forgets a previous narrowing. Because a remembered choice can only narrow, a stale one costs you an extra panel and can never hand anything over. + +To clear it without waiting to be asked again: + +```bash +varlock lock --forget-preferences # this project's remembered choices +varlock lock --forget-all-preferences # every project's +``` + +### Scopes + +The panel offers a scope, which decides how long one approval lasts: + +| Scope | Lasts | +|-------|-------| +| `once` | a single batch of decrypts | +| `session` | until the session ends (the default) | +| `duration` | a window: 10 minutes, 1 hour, or one you set yourself | + +The panel draws these as one row, ordered from least to most permissive: `Once`, `10min`, `1hr`, `Custom`, `This session`. Picking `Custom` reveals a field and a `min` / `hr` toggle under the row, and selecting that rung again puts the caret back in the field. The rung always reads `Custom`, whatever number is set on it: it sits at a fixed place in an ordered row, so wearing a free value would break the order the row is there to show. The number is on screen in the field, which is visible exactly when that rung is selected, and the sentence under the controls states it in words. Switching the unit converts the value rather than rereading the number, so 90 minutes becomes 1 hour and not 90 of them. + +The field never blocks the panel. An empty, zero or unparseable value reads as the shortest legal window, and anything past the cap reads as the cap, so there is no error state to get stuck in while the sensor is armed. Return commits the number rather than approving, and Escape refuses the panel as it does everywhere else on it. What an approval carries is always what the field is showing at that instant, including a number you are part way through typing. + +A custom window shorter than the default is a tightening like any other, so it is remembered and preselected the next time you are asked, with the field primed to it. + +Everything is capped at 12 hours from the session's first unlock, whatever the scope. That cap is not configurable, and the custom field cannot be set past it. + +A key created with `varlock-local-encrypt generate-key --auth-every-time` never receives a lasting grant. It is offered `once` and nothing else, so every batch asks again. Use it for the handful of values where a session is too loose. + +### What ends a session + +Beyond the TTL and the 12 hour cap, a lock policy decides which system events erase a session. Set the machine default in your user-level varlock config (`~/.config/varlock/config.json`, or `~/.varlock/config.json` on older installs): + +```json title="~/.config/varlock/config.json" +{ "sessions": { "lockOn": "sleep" } } +``` + +| Value | Erased by | +|-------|-----------| +| `screenLock` | screen lock and sleep | +| `sleep` | sleep only, surviving the screen locking (the default) | +| `none` | nothing but TTL expiry, the 12 hour cap, or an explicit lock | + +The daemon reads this file fresh at every unlock, so an edit applies to the next unlock with no restart. It is read only from the user-level config, never from project config: a project must not get to weaken how long your machine holds keys. On macOS you can also set it from the menu bar under "Lock Sessions On", which writes the same field. + +Only `willSleepNotification` counts as sleep. Display sleep and fast user switching count as screen lock, so a display that dims after a couple of idle minutes does not read as the machine sleeping. + +Sessions never survive a daemon restart, on purpose. Nothing about them is written to disk, so a reboot means the next use costs one more approval. + +### Locking + +| Surface | What it ends | +|---------|--------------| +| `varlock lock` | every session on the machine | +| `varlock lock --current` | only the session you run it in | +| `varlock lock --session ` | one session, named from `varlock sessions` | +| `varlock lock --forget-preferences` | no session; forgets this project's remembered panel choices | +| `varlock lock --forget-all-preferences` | no session; forgets every project's | +| Menu bar → Lock All (macOS) | every session, and every cached biometric context | +| Menu bar → Lock This Session (macOS) | that one session | +| Menu bar → Quit Daemon (macOS) | stops the daemon, which erases everything | + +`--current` takes no id. The daemon works out which session is calling from the connection itself, which is also why there is no way to name your way into someone else's. + +The macOS menu bar shows a closed lock when nothing is held and an open one while any session is unlocked, with a submenu per session listing its keys, scopes, and remaining time. Per-session policies are shown there but not editable: a session's policy was settled when you approved it, and re-unlocking is how it changes. + +### The authorization log + +Before any v2 decrypt releases plaintext, the daemon appends a record to `/audit/authorizations.jsonl` (mode 0600, in a 0700 directory), flushes it, and reads it back. If that fails, the decrypt is refused and no plaintext is produced. Unlocks are recorded the same way. + +```json +{"event":"decrypt-v2","identityId":"default","keyIds":["varlock-default"],"payloadCount":12,"requester":"node ← claude ← zsh (ttys004)","scope":"session","sessionId":"tty:ttys004:1756...","ts":"2026-08-30T15:42:22.881Z"} +``` + +Records hold identifiers, counts, and a description of the calling process. No plaintext, no ciphertext, and no key material goes in, which is what makes the file safe to read and to share. + +Invalidations are recorded too, but best effort: refusing to erase key material because a log line would not write is the wrong way round, so those go to stderr and the erase proceeds. + +### Migrating existing values + +Existing encrypted values keep working with no action. They are device-encrypted, they still decrypt, and nothing breaks. They just do not get the session behavior, because they are not tied to an identity key. + +They get a panel of their own, without the scope row or the coverage checkbox: neither has anything to control on that path, since there is no session behind it. It states what approving really grants instead. macOS is asked to reuse one scan for five minutes there, so the panel says "Allowed for up to 5 minutes" rather than offering a choice varlock cannot honor. Until you upgrade a project, a load that touches both formats draws both panels. + +To move them over, use [`varlock encrypt --upgrade`](/reference/cli/encryption/#encrypt): + +```bash +varlock encrypt --upgrade --dry-run # report what would change +varlock encrypt --upgrade # re-encrypt in place +``` + +With no `--file` this covers every env file in your graph. It decrypts each value and re-encrypts it to your identity key, rewriting only the entries that change. + +:::note[WSL] +WSL reaches the Windows daemon by running the helper `.exe` once per call, and each of those runs is its own session, so there is nowhere for an unlock to be held. Values written from WSL stay device-encrypted, which its daemon reads normally, and `--upgrade` declines there rather than producing values it could not read back. Run the upgrade from native Windows if you want the session behavior. +::: + ## Backend selection overview Varlock chooses the best available backend automatically: @@ -135,6 +300,16 @@ Once configured, varlock's normal decrypt flow requires user presence, so everyd Treat this gate as a **consent prompt**, not an at-rest boundary. On Linux the TPM unseal isn't bound to polkit, so (as noted above) other code already running as your user can still unseal directly. It means "a human approved this load," not "only a human can ever decrypt." +### What an unlock session does and does not protect + +Sessions are about how often you are asked, not about how well the secret is held. Some specifics worth knowing before you set `lockOn` to `none` and a 12 hour cap: + +- **A session TTL is a re-authentication cadence, not at-rest protection.** While a session is open, the daemon is holding an unwrapped key in memory precisely so it does not have to ask again. A shorter TTL means you re-authenticate sooner; it does not make the values harder to read while the session is live. +- **Anything running as your user can ask the daemon.** The daemon checks who is connecting and shows it on the panel, but that is an integrity check on the request, not a boundary against your own account. Code running as you can wait for a session you opened and use it, and root or a debugger attached to the daemon can read the key straight out of memory. Local encryption raises the cost of a stolen disk or a committed env file, not of an attacker already executing as you. +- **Session keys live in RAM, which sleep can write to disk.** Nothing about a session is persisted, but macOS writes memory to a sleepimage when it hibernates. FileVault is what encrypts that file, so on a machine without FileVault a `lockOn: none` session that survives sleep is a key sitting in an unencrypted file on disk. The default (`sleep`) erases sessions before that can happen, which is why it is the default. +- **The peer posture checks mostly report, for now.** The macOS daemon asks the kernel whether the connecting process has a debugger attached and whether it runs with the Hardened Runtime. A signed release daemon rejects a debugged peer, but the Hardened Runtime check only reports, because plenty of legitimate callers are not hardened: the standalone `varlock` binary is ad-hoc signed by its compiler, as are Homebrew's node and bun. It becomes a rejection once the release pipeline signs the CLI with `--options runtime`. Until then, if every client on your machine is an official build, you can turn it on yourself with `{ "sessions": { "peerPosture": "strict" } }` in your user config. +- **The 12 hour cap is not negotiable.** Whatever scope is approved and whatever `lockOn` says, no grant outlives 12 hours from the session's first unlock. Deadlines are tracked on both the wall clock and a monotonic clock, so moving the system clock backwards cannot extend a session, and the cap keeps counting while the machine sleeps. + ## Platform details & setup ### macOS diff --git a/packages/varlock-website/src/content/docs/reference/cli/encryption.mdx b/packages/varlock-website/src/content/docs/reference/cli/encryption.mdx index 7a9eb4f6e..f7a43b932 100644 --- a/packages/varlock-website/src/content/docs/reference/cli/encryption.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli/encryption.mdx @@ -1,6 +1,6 @@ --- title: Encryption commands -description: CLI reference for encrypt, reveal, lock, audit, and generate-key +description: CLI reference for encrypt, reveal, sessions, lock, audit, and generate-key --- import ExecCommandWidget from "@/components/ExecCommandWidget.astro"; @@ -22,7 +22,9 @@ varlock encrypt [options] ``` **Options:** -- `--file`: Path to a `.env` file; encrypts all sensitive plaintext values in-place +- `--file`: Path to a `.env` file; encrypts all sensitive plaintext values in-place +- `--upgrade`: Also re-encrypt already-encrypted values to the current encryption target +- `--dry-run`: With `--upgrade`, report what would change without writing anything **Examples:** ```bash @@ -35,8 +37,24 @@ varlock encrypt < secret.txt # Encrypt all sensitive plaintext values in a .env file varlock encrypt --file .env.local + +# Migrate already-encrypted values onto the current target +varlock encrypt --upgrade --dry-run +varlock encrypt --upgrade ``` +#### `--upgrade` + +Re-encrypts values that are **already** encrypted, moving them to whatever varlock currently encrypts to. In practice that means migrating older device-encrypted values onto your identity key, which is what lets a single approval cover a whole [unlock session](/guides/local-encryption/#unlock-sessions) instead of prompting repeatedly. + +With no `--file` it covers every env file in the loaded graph. Only entries that actually change are rewritten; plaintext values, `varlock(prompt)` placeholders, and values already at the target are left byte for byte as they were. Pair it with `--dry-run` first to see the list. + +Existing values keep working whether or not you run this, so it is a migration you can take when it suits you. + +:::note[Not available on WSL] +WSL runs the Windows helper once per call, so there is no session for an unlock to live in. `--upgrade` declines there rather than writing values it could not read back. Run it from native Windows instead. +::: + In single-value mode, you'll either be prompted to enter a value (hidden input) or the value will be read from stdin when piped. The encrypted output is printed for you to copy into your `.env.local` file: ``` SOME_SENSITIVE_KEY=varlock("local:") @@ -98,18 +116,71 @@ Non-sensitive values are not shown by `varlock reveal`. Use [`varlock printenv`]
+## `varlock sessions` ||sessions|| + +Lists the [unlock sessions](/guides/local-encryption/#unlock-sessions) the encryption daemon is currently holding. + +```bash +varlock sessions [options] +``` + +**Options:** +- `--json`: Print the raw session records as JSON + +Each row is one grant: the session it belongs to, the key it covers, when it was unlocked, how long it has left, how many decrypts it has served, and which system event will end it. + +```text title="$ varlock sessions" +SESSION KEY UNLOCKED EXPIRES IN USES LOCKS ON +tty:ttys004:1756... varlock-default 10:42:11 AM 7h 18m 12 sleep +``` + +Nothing here is secret. The daemon reports identifiers and counts, never key material, so the output is safe to paste into an issue. + +With no daemon running there are no sessions to hold, so this prints an empty list rather than an error. + +
+ +
+ ## `varlock lock` ||lock|| -Locks the encryption daemon, requiring biometric authentication (e.g., Touch ID) for the next decrypt operation. This invalidates the current biometric session cache. +Ends unlock sessions, so the next decrypt has to be approved again. ```bash +varlock lock [options] +``` + +**Options:** +- `--session `: Lock one session by id, as listed by `varlock sessions` +- `--current`: Lock only the session you run the command in +- `--forget-preferences`: Forget the unlock panel choices remembered for this project +- `--forget-all-preferences`: Forget them for every project on this Mac + +With no options this locks every session on the machine, which is what you want when stepping away. + +**Examples:** +```bash +# Lock everything varlock lock + +# Lock only this terminal's session, leaving others running +varlock lock --current + +# Lock one specific session +varlock lock --session "tty:ttys004:1756..." + +# Forget the tighter choices the panel remembers for this project +varlock lock --forget-preferences ``` +The panel remembers it when you untick the breadth checkbox, so it does not spring back to the broader default the next time you are asked. See [Breadth](/guides/local-encryption/#breadth). Choosing the broad option on the panel forgets it too; the flags above are for clearing it without waiting to be asked. They act on the preferences file in your user varlock directory, so they work whether or not a daemon is running, and they can be combined with the locking options or used on their own. + +`--current` takes no id on purpose. The daemon derives the session from the connection itself, which is what makes it impossible to name your way into someone else's. + This command only has an effect when using a biometric-enabled encryption backend (macOS Secure Enclave, Windows Hello, or Linux with polkit/PAM biometric setup). On other backends, it will display a message and exit. :::tip -Use `varlock lock` when stepping away from your machine to ensure the next person to decrypt a secret must authenticate biometrically. +On macOS the same thing is available from the menu bar: "Lock All" for everything, or "Lock This Session" inside a session's submenu. :::
diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 05bb4a987..e95842111 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -27,6 +27,7 @@ import { commandSpec as runCommandSpec } from './commands/run.command-spec'; import { commandSpec as printenvCommandSpec } from './commands/printenv.command-spec'; import { commandSpec as encryptCommandSpec } from './commands/encrypt.command-spec'; import { commandSpec as lockCommandSpec } from './commands/lock.command-spec'; +import { commandSpec as sessionsCommandSpec } from './commands/sessions.command-spec'; import { commandSpec as revealCommandSpec } from './commands/reveal.command-spec'; // import { commandSpec as doctorCommandSpec } from './commands/doctor.command-spec'; import { commandSpec as helpCommandSpec } from './commands/help.command-spec'; @@ -48,8 +49,12 @@ import { commandSpec as proxyCommandSpec } from './commands/proxy.command-spec'; // must happen before anything writes to stdio handleBrokenPipe(); +// Read defensively: the define does not exist when this file is run straight +// from source, which is how the CLI is run while working in this repo, and a +// bare read throws before the command has a chance to do anything. +const buildType: string = typeof __VARLOCK_BUILD_TYPE__ === 'undefined' ? 'dev' : __VARLOCK_BUILD_TYPE__; let versionId = packageJson.version; -if (__VARLOCK_BUILD_TYPE__ !== 'release') versionId += `-${__VARLOCK_BUILD_TYPE__}`; +if (buildType !== 'release') versionId += `-${buildType}`; const subCommands = new Map(); subCommands.set('init', lazy(async () => (await import('./commands/init.command')).commandFn, initCommandSpec)); @@ -58,6 +63,7 @@ subCommands.set('run', lazy(async () => (await import('./commands/run.command')) subCommands.set('printenv', lazy(async () => (await import('./commands/printenv.command')).commandFn, printenvCommandSpec)); subCommands.set('encrypt', lazy(async () => (await import('./commands/encrypt.command')).commandFn, encryptCommandSpec)); subCommands.set('lock', lazy(async () => (await import('./commands/lock.command')).commandFn, lockCommandSpec)); +subCommands.set('sessions', lazy(async () => (await import('./commands/sessions.command')).commandFn, sessionsCommandSpec)); subCommands.set('reveal', lazy(async () => (await import('./commands/reveal.command')).commandFn, revealCommandSpec)); // subCommands.set('doctor', lazy(async () => (await import('./commands/doctor.command')).commandFn, doctorCommandSpec)); subCommands.set('explain', lazy(async () => (await import('./commands/explain.command')).commandFn, explainCommandSpec)); diff --git a/packages/varlock/src/cli/commands/encrypt.command-spec.ts b/packages/varlock/src/cli/commands/encrypt.command-spec.ts index 2672d8786..d65bfc422 100644 --- a/packages/varlock/src/cli/commands/encrypt.command-spec.ts +++ b/packages/varlock/src/cli/commands/encrypt.command-spec.ts @@ -17,6 +17,14 @@ export const commandSpec = define({ type: 'string', description: 'Path to a .env file — encrypts all sensitive plaintext values in-place', }, + upgrade: { + type: 'boolean', + description: 'Also re-encrypt already-encrypted values to the current encryption target', + }, + 'dry-run': { + type: 'boolean', + description: 'With --upgrade, report what would change without writing anything', + }, }, examples: ` Encrypts a value using device-local encryption (Secure Enclave / TPM / file-based), @@ -25,9 +33,17 @@ producing a varlock("local:...") reference that is safe to commit. Single-value mode reads from stdin (or prompts interactively) so secrets stay out of shell history. --file mode encrypts all @sensitive plaintext values in a .env file in place. +--upgrade re-encrypts values that are already encrypted, moving them to the current +encryption target. Use it to migrate older device-encrypted values onto an identity key, +which is what lets one unlock cover a whole session instead of prompting repeatedly. +Existing values keep working either way, so this is a migration you can take when you +want it. With no --file it covers every env file in the graph. + Examples: echo "$MY_SECRET" | varlock encrypt # Encrypt a value from stdin (non-interactive, agent-friendly) varlock encrypt # Prompt interactively for a value varlock encrypt --file .env.local # Encrypt @sensitive plaintext values in a file in-place + varlock encrypt --upgrade --dry-run # Report which values would be re-encrypted + varlock encrypt --upgrade # Re-encrypt them to the current target `.trim(), }); diff --git a/packages/varlock/src/cli/commands/encrypt.command.ts b/packages/varlock/src/cli/commands/encrypt.command.ts index def7b602d..5756a172e 100644 --- a/packages/varlock/src/cli/commands/encrypt.command.ts +++ b/packages/varlock/src/cli/commands/encrypt.command.ts @@ -14,6 +14,10 @@ import { CliExitError } from '../helpers/exit-error'; import { multiselect, password } from '../helpers/prompts'; import { gracefulExit } from 'exit-hook'; import * as localEncrypt from '../../lib/local-encrypt'; +import { buildVarlockReference, LOCAL_SCHEME } from '../../lib/local-encrypt/reference'; +import { + canReEncryptLocally, currentLocalTarget, deviceEncryptedSource, reEncryptFile, +} from '../../lib/local-encrypt/re-encrypt'; import { writeBackValue } from '../../lib/local-encrypt/write-back'; import { commandSpec } from './encrypt.command-spec'; @@ -91,7 +95,7 @@ async function encryptFile(keyId: string, filePath: string) { let encryptedCount = 0; for (const item of filteredItems) { const ciphertext = await localEncrypt.encryptValue(item.value, keyId); - const result = writeBackValue(item.key, `varlock("local:${ciphertext}")`, resolvedPath); + const result = writeBackValue(item.key, buildVarlockReference(LOCAL_SCHEME, ciphertext), resolvedPath); if (result.updated) { encryptedCount++; @@ -102,8 +106,76 @@ async function encryptFile(keyId: string, filePath: string) { console.log(`\nEncrypted ${encryptedCount} value${encryptedCount !== 1 ? 's' : ''} in ${filePath}`); } +/** Every env file the graph actually reads from, in load order */ +async function getGraphEnvFilePaths(): Promise> { + const envGraph = await loadVarlockEnvGraph(); + return envGraph.sortedDataSources + .filter((s): s is FileBasedDataSource => s instanceof FileBasedDataSource) + .map((s) => s.fullPath) + .filter((p) => fs.existsSync(p) && fs.statSync(p).isFile()); +} + +/** + * --upgrade: re-encrypt values that are already encrypted, moving them to the + * current encryption target (the identity key on the file backend). + * + * `encrypt` is meant to become the single re-encryption verb: a future + * `--to ` will move values between targets, and key rotation and cloud + * migration will drive the same core. So the pass is described here as a source + * and a target and handed to reEncryptFile, rather than the flag knowing + * anything about v1 or v2. + */ +async function upgradeFiles(keyId: string, opts: { file?: string; dryRun: boolean }) { + const canReEncrypt = canReEncryptLocally(); + if (!canReEncrypt.ok) { + console.log(`\n${canReEncrypt.reason}`); + console.log('No values were re-encrypted.'); + return; + } + await localEncrypt.ensureEncryptionReady(keyId); + + let filePaths: Array; + if (opts.file) { + const resolvedPath = path.resolve(opts.file); + if (!fs.existsSync(resolvedPath)) throw new CliExitError(`File not found: ${resolvedPath}`); + filePaths = [resolvedPath]; + } else { + filePaths = await getGraphEnvFilePaths(); + } + + const source = deviceEncryptedSource(keyId); + const target = currentLocalTarget(keyId); + + let totalUpgraded = 0; + for (const envFilePath of filePaths) { + // sequential on purpose: each rewrite reads and writes the same file + const result = await reEncryptFile(envFilePath, { source, target, dryRun: opts.dryRun }); + if (result.reEncrypted.length === 0) continue; + + totalUpgraded += result.reEncrypted.length; + // a file outside the cwd reads better as its full path than as ../../.. + const relativePath = path.relative(process.cwd(), envFilePath); + console.log(`\n${relativePath.startsWith('..') ? envFilePath : relativePath}`); + for (const key of result.reEncrypted) { + console.log(` ${opts.dryRun ? 'Would re-encrypt' : 'Re-encrypted'}: ${key}`); + } + for (const skip of result.skipped.filter((s) => s.reason === 'write-back-failed')) { + console.log(` Could not update: ${skip.key}`); + } + } + + if (totalUpgraded === 0) { + console.log('\nNo values needed re-encrypting.'); + return; + } + console.log( + `\n${opts.dryRun ? 'Would re-encrypt' : 'Re-encrypted'} ${totalUpgraded}` + + ` value${totalUpgraded !== 1 ? 's' : ''} to the current encryption target.`, + ); +} + export const commandFn: TypedGunshiCommandFn = async (ctx) => { - const keyId = String(ctx.values['key-id'] || 'varlock-default'); + const keyId = String(ctx.values['key-id'] || localEncrypt.DEFAULT_KEY_ID); const backend = localEncrypt.getBackendInfo(); try { @@ -132,10 +204,22 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } const filePath = ctx.values.file; + const upgrade = Boolean(ctx.values.upgrade); + const dryRun = Boolean(ctx.values['dry-run']); // --file mode: encrypt all sensitive plaintext values in a .env file if (filePath) { - await encryptFile(keyId, filePath); + // --dry-run only describes the --upgrade pass. The interactive plaintext + // encryption has nothing to preview, so it is skipped rather than run for real. + if (!(upgrade && dryRun)) await encryptFile(keyId, filePath); + if (upgrade) await upgradeFiles(keyId, { file: filePath, dryRun }); + return; + } + + // --upgrade with no --file covers every env file in the graph. There is no + // single value to read in that case, so the stdin/prompt mode below is skipped. + if (upgrade) { + await upgradeFiles(keyId, { dryRun }); return; } @@ -186,5 +270,5 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } console.log('\nCopy this into your .env.local file and rename the key appropriately:\n'); - console.log(`SOME_SENSITIVE_KEY=varlock("local:${ciphertext}")`); + console.log(`SOME_SENSITIVE_KEY=${buildVarlockReference(LOCAL_SCHEME, ciphertext)}`); }; diff --git a/packages/varlock/src/cli/commands/lock.command-spec.ts b/packages/varlock/src/cli/commands/lock.command-spec.ts index 3f26fab28..368f17731 100644 --- a/packages/varlock/src/cli/commands/lock.command-spec.ts +++ b/packages/varlock/src/cli/commands/lock.command-spec.ts @@ -2,5 +2,43 @@ import { define } from 'gunshi'; export const commandSpec = define({ name: 'lock', - description: 'Lock the encryption daemon, requiring biometric for next decrypt', + description: 'Lock unlock sessions, requiring approval again for the next decrypt', + args: { + session: { + type: 'string', + description: 'Lock one session by id (see `varlock sessions`)', + }, + current: { + type: 'boolean', + description: "Lock only this terminal's own session", + }, + 'forget-preferences': { + type: 'boolean', + description: 'Also forget the unlock choices remembered for this project', + }, + 'forget-all-preferences': { + type: 'boolean', + description: 'Forget the unlock choices remembered for every project on this Mac', + }, + }, + examples: ` +With no options this locks every session on the machine, which is what you want when +stepping away. The narrower forms end one session and leave the others alone. + +--current does not take an id: the daemon works out which session is calling from the +connection itself, so there is no way to name your way into someone else's. + +Examples: + varlock lock # Lock every session + varlock lock --current # Lock only this terminal's session + varlock lock --session # Lock one session by id + varlock sessions # See what is currently unlocked + +The unlock panel remembers it when you tighten an approval, so it does not spring back +to the broad default next time. Choosing the broad option again forgets that, and so +does this: + + varlock lock --forget-preferences # Forget this project's remembered choices + varlock lock --forget-all-preferences # Forget every project's +`.trim(), }); diff --git a/packages/varlock/src/cli/commands/lock.command.ts b/packages/varlock/src/cli/commands/lock.command.ts index bad5b02b3..24f261b69 100644 --- a/packages/varlock/src/cli/commands/lock.command.ts +++ b/packages/varlock/src/cli/commands/lock.command.ts @@ -2,11 +2,33 @@ import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import * as localEncrypt from '../../lib/local-encrypt'; +import { CliExitError } from '../helpers/exit-error'; import { commandSpec } from './lock.command-spec'; +// Imported straight from its own module rather than through the library's +// index: forgetting a preference is a CLI errand, and re-exporting it would +// pull the file into every process that loads varlock as a library. +import { forgetUnlockPreferences } from '../../lib/local-encrypt/unlock-preferences'; export { commandSpec }; -export const commandFn: TypedGunshiCommandFn = async () => { +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + // Forgetting is a file edit in the user's own varlock directory, not a daemon + // op, so it works whether or not anything is running and whether or not this + // machine has a backend that can lock at all. + const forgetAll = Boolean(ctx.values['forget-all-preferences']); + const forgetHere = Boolean(ctx.values['forget-preferences']); + if (forgetAll || forgetHere) { + const forgotten = forgetUnlockPreferences( + forgetAll ? undefined : { projectPath: process.cwd() }, + ); + const where = forgetAll ? 'this Mac' : 'this project'; + console.log( + forgotten > 0 + ? `Forgot ${forgotten} remembered unlock choice${forgotten !== 1 ? 's' : ''} for ${where}.` + : `No remembered unlock choices for ${where}.`, + ); + } + const backend = localEncrypt.getBackendInfo(); if (!backend.biometricAvailable) { @@ -14,9 +36,39 @@ export const commandFn: TypedGunshiCommandFn = async () => { return; } + const namedSession = ctx.values.session; + const current = Boolean(ctx.values.current); + + if (namedSession && current) { + throw new CliExitError('Pass either --session or --current, not both', { + suggestion: '--current locks the session you are running in; --session locks one you name.', + }); + } + + let sessionId = namedSession; + if (current) { + // --current takes no id on purpose. The daemon derives the session from the + // connection, so asking it who we are is the only way to name our own + // session, and there is no way to name anyone else's. + sessionId = await localEncrypt.getCurrentSessionId(); + if (!sessionId) { + console.log('No unlock session is open for this terminal, so there is nothing to lock.'); + return; + } + } + try { - await localEncrypt.lockSession(); - console.log('Encryption session locked. Biometric authentication will be required for next decrypt.'); + const invalidated = await localEncrypt.lockSession(sessionId ? { sessionId } : undefined); + + if (sessionId) { + console.log( + invalidated > 0 + ? `Locked ${invalidated} session grant${invalidated !== 1 ? 's' : ''} for ${sessionId}.` + : `No unlock session grants were open for ${sessionId}.`, + ); + return; + } + console.log('Encryption session locked. Approval will be required for the next decrypt.'); } catch { console.log('No encryption daemon is running — nothing to lock.'); } diff --git a/packages/varlock/src/cli/commands/sessions.command-spec.ts b/packages/varlock/src/cli/commands/sessions.command-spec.ts new file mode 100644 index 000000000..b48c3f59a --- /dev/null +++ b/packages/varlock/src/cli/commands/sessions.command-spec.ts @@ -0,0 +1,27 @@ +import { define } from 'gunshi'; + +export const commandSpec = define({ + name: 'sessions', + description: 'List the unlock sessions the encryption daemon is currently holding', + args: { + json: { + type: 'boolean', + description: 'Print the raw session records as JSON', + }, + }, + examples: ` +Shows every live unlock session on this machine: which key each one covers, when it +was unlocked, when it expires, how many decrypts it has served, and what will end it. + +Sessions are created when you approve an unlock, and one covers every value that key +protects until it expires or something locks it. Nothing here is a secret: the daemon +never reports key material. + +Examples: + varlock sessions # Table of live sessions + varlock sessions --json # Same data as JSON, for scripts + varlock lock --current # End the session for this terminal + varlock lock --session # End one specific session + varlock lock # End all of them +`.trim(), +}); diff --git a/packages/varlock/src/cli/commands/sessions.command.ts b/packages/varlock/src/cli/commands/sessions.command.ts new file mode 100644 index 000000000..f7f4916d3 --- /dev/null +++ b/packages/varlock/src/cli/commands/sessions.command.ts @@ -0,0 +1,76 @@ +import ansis from 'ansis'; + +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; +import * as localEncrypt from '../../lib/local-encrypt'; +import type { SessionGrantInfo } from '../../lib/local-encrypt'; +import { commandSpec } from './sessions.command-spec'; + +export { commandSpec }; + +/** "4m", "2h 10m", "expired": enough to decide whether to bother locking */ +function formatRemaining(ms: number): string { + if (ms <= 0) return 'expired'; + const minutes = Math.floor(ms / 60_000); + if (minutes < 1) return '<1m'; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function formatTime(epochMs: number): string { + return new Date(epochMs).toLocaleTimeString(); +} + +function renderTable(sessions: Array) { + const rows = sessions.map((session) => [ + session.sessionId, + session.keyId, + formatTime(session.sessionUnlockedAt), + formatRemaining(session.expiresInMs), + String(session.useCount), + session.lockOn, + ]); + const headers = ['SESSION', 'KEY', 'UNLOCKED', 'EXPIRES IN', 'USES', 'LOCKS ON']; + + const widths = headers.map((header, column) => Math.max( + header.length, + ...rows.map((row) => row[column].length), + )); + const line = (cells: Array) => cells + .map((cell, column) => cell.padEnd(widths[column])) + .join(' ') + .trimEnd(); + + console.log(ansis.gray(line(headers))); + for (const row of rows) console.log(line(row)); +} + +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + const asJson = Boolean(ctx.values.json); + const backend = localEncrypt.getBackendInfo(); + + if (!backend.biometricAvailable) { + if (asJson) { + console.log(JSON.stringify({ sessions: [] }, null, 2)); + return; + } + console.log(`The ${backend.type} backend does not hold unlock sessions.`); + return; + } + + const sessions = await localEncrypt.listSessions(); + + if (asJson) { + console.log(JSON.stringify({ sessions }, null, 2)); + return; + } + + if (sessions.length === 0) { + console.log('No unlock sessions are open.'); + return; + } + + renderTable(sessions); + console.log(''); + console.log(ansis.gray('Run `varlock lock --current` to end this terminal\'s session, or `varlock lock` to end all.')); +}; diff --git a/packages/varlock/src/cli/helpers/telemetry.ts b/packages/varlock/src/cli/helpers/telemetry.ts index c96c3136b..c9687cf20 100644 --- a/packages/varlock/src/cli/helpers/telemetry.ts +++ b/packages/varlock/src/cli/helpers/telemetry.ts @@ -197,9 +197,15 @@ export function checkIsOptedOutViaEnv(env: NodeJS.ProcessEnv = process.env) { ); } +// Substituted at build time, so it does not exist when a source file is run +// directly without the defines, which is how the CLI is run while working in +// this repo. Read bare it throws a ReferenceError before the command can do +// anything at all, so an unbuilt source tree is treated as the dev build it is. +const buildType: string = typeof __VARLOCK_BUILD_TYPE__ === 'undefined' ? 'dev' : __VARLOCK_BUILD_TYPE__; + function checkIsOptedOut() { // Check if this is a dev build, rather than a published npm package or standalone binary - if (__VARLOCK_BUILD_TYPE__ === 'dev') { + if (buildType === 'dev') { debug('telemetry opted out - dev build'); return true; } @@ -478,7 +484,7 @@ function getTelemetryMeta() { let versionIdentifier = packageJson.version; // TODO: for preview builds, it would be nice to track which preview it is (PR number or commit hash) - if (__VARLOCK_BUILD_TYPE__ !== 'release') versionIdentifier += `-${__VARLOCK_BUILD_TYPE__}`; + if (buildType !== 'release') versionIdentifier += `-${buildType}`; cachedTelemetryMetadata = { anonymous_project_id: getAnonymousProjectId(), diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 2e0a86206..0c92effe0 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -9,7 +9,9 @@ import { import { type KeyFilter } from './key-filter'; import { computeFilteredKeys, type ParsedItemFilter } from './item-filter'; -import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver'; +import { + BaseResolvers, createResolver, type Resolver, type ResolverChildClass, +} from './resolver'; import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types'; import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils'; import { ResolutionError, SchemaError } from './errors'; @@ -39,10 +41,21 @@ import { } from '../../proxy/types'; import { parseDuration } from '../../lib/duration'; import { hashEnvSourceContents } from '../../lib/env-source-fingerprint'; +import { + clearDeclaredCacheInventories, declareEncryptedFileValues, + type DeclaredEncryptedValue, +} from '../../lib/local-encrypt/unlock-inventory'; const processExists = !!globalThis.process; const originalProcessEnv = { ...processExists && process.env }; +/** A resolver and everything nested inside its arguments */ +function* walkResolvers(root: Resolver | undefined): Generator { + if (!root) return; + yield root; + for (const child of root.childResolvers) yield* walkResolvers(child); +} + export type SerializedEnvGraphErrors = { /** Per-item validation errors, keyed by config item key */ configItems?: Record; @@ -628,6 +641,11 @@ export class EnvGraph { if (hasErrors) return; + // Everything encrypted is now known, and nothing has been resolved yet, so + // this is the last moment before an unlock can happen and the first at + // which the whole picture exists. + this.declareUnlockInventory(); + // check for cycles in resolver dependencies const cycles = findGraphCycles(this.graphAdjacencyList); for (const cycleItemKeys of cycles) { @@ -681,6 +699,42 @@ export class EnvGraph { await Promise.all(this.getRootDecFns('proxy').map(async (d) => d.resolve())); } + /** + * Tell the unlock panel everything one approval will open in this run. + * + * An unlock is a session grant, so the first panel is the only one the user + * sees: whatever asks afterwards rides the same grant silently. A panel built + * from the batch that happened to ask first describes a fraction of that, and + * the user approves on partial information. So the whole picture is declared + * here instead, while it is all in hand and before anything has resolved: the + * encrypted values this graph will open, and the value cache when this run + * has one that shares their key. + * + * Only the resolvers that will actually run are read (`valueResolver`, not + * every definition), so a value shadowed by a later file is not listed as + * something the grant hands over. + */ + private declareUnlockInventory() { + clearDeclaredCacheInventories(); + this._cacheStore?.declareUnlockInventory?.(); + + const declared: Array = []; + for (const itemKey in this.configSchema) { + const item = this.configSchema[itemKey]; + let resolvers: Array; + try { + resolvers = [...walkResolvers(item.valueResolver)]; + } catch { + continue; // a definition too broken to describe is one the panel skips + } + for (const resolver of resolvers) { + const entry = resolver._unlockInventoryEntry; + if (entry) declared.push({ ...entry, valueName: entry.valueName ?? itemKey }); + } + } + declareEncryptedFileValues(declared); + } + get graphAdjacencyList() { const adjList: GraphAdjacencyList = {}; for (const itemKey in this.configSchema) { diff --git a/packages/varlock/src/env-graph/lib/loader.ts b/packages/varlock/src/env-graph/lib/loader.ts index 87a9c67da..40fe8d682 100644 --- a/packages/varlock/src/env-graph/lib/loader.ts +++ b/packages/varlock/src/env-graph/lib/loader.ts @@ -58,6 +58,13 @@ export async function loadEnvGraph(opts?: { // initialize cache store (encryption key is ensured lazily on first write) // auto policy: native-backend disk > env-key disk > in-process memory + // + // The file backend stays memory-backed even once an identity key exists. An + // identity makes cache entries portable, but it does not make them safer at + // rest here: the identity is wrapped to a device key that is itself a + // plaintext file sitting beside the cache. Which key a disk cache would use + // is decided by CacheStore's codec, so this policy only decides whether the + // cache reaches disk at all. if (!opts?.skipCache) { const backend = localEncrypt.getBackendInfo(); const isCi = graph.ciEnvInfo.isCI; diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 8de38080e..daf87192f 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -20,6 +20,7 @@ import { type GeneratedTotp, type OtpAlgorithm, type OtpSecretEncoding, } from '../../lib/otp'; import { assertValidCacheKey, hasInvalidCacheKeyChars, MAX_CACHE_KEY_LENGTH } from '../../lib/cache/cache-store'; +import type { DeclaredEncryptedValue } from '../../lib/local-encrypt/unlock-inventory'; import type { EnvGraphDataSource } from './data-source'; import { DecoratorInstance } from './decorators'; import { getErrorLocation } from './error-location'; @@ -74,6 +75,15 @@ export class Resolver { _parsedNode?: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | ParsedEnvSpecFunctionArgs | ParsedEnvSpecObjectLiteral | ParsedEnvSpecArrayLiteral; _errors: Array = []; + /** + * What this resolver contributes to the unlock panel's inventory, set during + * process() by the resolvers that open encrypted values. + * + * Read once, after the whole graph is processed and before anything resolves, + * so the first unlock can describe every value it covers rather than only the + * batch that reached it first. Display only, and never bound into anything. + */ + _unlockInventoryEntry?: DeclaredEncryptedValue; private _depsObj: Record = {}; get childResolvers(): Array { diff --git a/packages/varlock/src/env-graph/test/cache-resolver.test.ts b/packages/varlock/src/env-graph/test/cache-resolver.test.ts index 1461e470a..5578f9d8a 100644 --- a/packages/varlock/src/env-graph/test/cache-resolver.test.ts +++ b/packages/varlock/src/env-graph/test/cache-resolver.test.ts @@ -25,6 +25,8 @@ vi.mock('../../lib/local-encrypt', () => ({ decryptValue: vi.fn(async (value: string) => value.replace('encrypted:', '')), // eslint-disable-next-line @typescript-eslint/no-empty-function ensureKey: vi.fn(async () => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ensureEncryptionReady: vi.fn(async () => {}), keyExists: vi.fn(() => true), })); diff --git a/packages/varlock/src/env-graph/test/loader-cache-policy.test.ts b/packages/varlock/src/env-graph/test/loader-cache-policy.test.ts index 10180759f..f0777dd5d 100644 --- a/packages/varlock/src/env-graph/test/loader-cache-policy.test.ts +++ b/packages/varlock/src/env-graph/test/loader-cache-policy.test.ts @@ -14,6 +14,7 @@ vi.mock('../../lib/local-encrypt', () => ({ getBackendInfo: () => ({ type: mockBackendType, isFileFallback: mockBackendType === 'file' }), keyExists: () => true, ensureKey: vi.fn(async () => undefined), + ensureEncryptionReady: vi.fn(async () => undefined), encryptValue: vi.fn(async (v: string) => `enc:${v}`), decryptValue: vi.fn(async (v: string) => v.replace('enc:', '')), })); diff --git a/packages/varlock/src/env-graph/test/unlock-inventory.test.ts b/packages/varlock/src/env-graph/test/unlock-inventory.test.ts new file mode 100644 index 000000000..bdfac7947 --- /dev/null +++ b/packages/varlock/src/env-graph/test/unlock-inventory.test.ts @@ -0,0 +1,188 @@ +/** + * What the first unlock of a run is allowed to say, from the loading end. + * + * The panel that opens a session grant is the only one the user will see, so it + * has to describe everything that grant covers. That cannot be worked out from + * the batch that happens to ask first: the env files and the value cache are + * opened by different callers at different moments. So the load declares the + * whole picture up front, and these tests are about what a load declares. + */ + +import { + describe, it, expect, vi, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { loadEnvGraph } from '../lib/loader'; +import { CacheStore } from '../../lib/cache'; +import { VarlockResolver } from '../../lib/local-encrypt/builtin-resolver'; +import { unlockInventoryForKey, clearUnlockInventory } from '../../lib/local-encrypt/unlock-inventory'; + +// a native backend, so the loader picks the disk cache the identity key opens +vi.mock('../../lib/local-encrypt', () => ({ + getBackendInfo: () => ({ type: 'secure-enclave', isFileFallback: false }), + keyExists: () => true, + ensureKey: vi.fn(async () => undefined), + ensureEncryptionReady: vi.fn(async () => undefined), + encryptValue: vi.fn(async (v: string) => `enc:${v}`), + decryptValue: vi.fn(async (v: string) => v.replace('enc:', '')), +})); + +let tempConfigDir: string; +vi.mock('../../lib/user-config-dir', () => ({ + getUserVarlockDir: () => tempConfigDir, +})); + +let tempProjectDir: string; + +/** A `varlock()` reference whose payload carries a real v2 version byte */ +function encryptedRef(marker: string) { + const payload = Buffer.concat([Buffer.from([0x02]), Buffer.from(marker, 'utf-8')]).toString('base64'); + return `varlock("local:${payload}")`; +} + +/** A reference to a device-key (v1) payload, which no session grant covers */ +function legacyRef(marker: string) { + const payload = Buffer.concat([Buffer.from([0x01]), Buffer.from(marker, 'utf-8')]).toString('base64'); + return `varlock("local:${payload}")`; +} + +function writeEnvFile(name: string, lines: Array) { + fs.writeFileSync(path.join(tempProjectDir, name), `${lines.join('\n')}\n`); +} + +/** + * How the panel names one of these files. A file under the working directory is + * named relative to it; this test's project is a temp dir that is not, so the + * full path is what the declaration carries. + */ +function shownAs(name: string) { + return path.join(tempProjectDir, name); +} + +beforeEach(() => { + clearUnlockInventory(); + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-unlock-inventory-config-')); + tempProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-unlock-inventory-proj-')); + writeEnvFile('.env.schema', ['# @defaultRequired=false', '# ---', 'PLAIN=hello']); +}); + +afterEach(() => { + clearUnlockInventory(); + fs.rmSync(tempConfigDir, { recursive: true, force: true }); + fs.rmSync(tempProjectDir, { recursive: true, force: true }); +}); + +async function load() { + return loadEnvGraph({ + basePath: tempProjectDir, + processEnvOverride: {}, + // the same registration the real loader does + afterInit: async (graph) => { graph.registerResolver(VarlockResolver); }, + }); +} + +describe('what a load declares to the unlock panel', () => { + it('names every encrypted value in the graph, before any of them resolve', async () => { + writeEnvFile('.env', [`DB_URL=${encryptedRef('db')}`]); + writeEnvFile('.env.local', [ + `STRIPE_KEY=${encryptedRef('stripe')}`, + `NGROK_TOKEN=${encryptedRef('ngrok')}`, + ]); + + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([ + { kind: 'file', path: shownAs('.env'), entries: [{ name: 'DB_URL' }] }, + { + kind: 'file', + path: shownAs('.env.local'), + entries: [{ name: 'STRIPE_KEY' }, { name: 'NGROK_TOKEN' }], + }, + ]); + }); + + it('lists the value cache beside the files that share its key', async () => { + writeEnvFile('.env.local', [`STRIPE_KEY=${encryptedRef('stripe')}`]); + const cache = new CacheStore(); + await cache.set('plugin:1password:vault/db', 'a', 60_000); + await cache.set('plugin:1password:vault/api', 'b', 60_000); + + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([ + { kind: 'file', path: shownAs('.env.local'), entries: [{ name: 'STRIPE_KEY' }] }, + { + kind: 'cache', + itemCount: 2, + entries: [{ name: '1password', count: 2 }], + }, + ]); + }); + + it('leaves an empty cache off, since the grant will open nothing in it', async () => { + writeEnvFile('.env.local', [`STRIPE_KEY=${encryptedRef('stripe')}`]); + + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([{ kind: 'file', path: shownAs('.env.local'), entries: [{ name: 'STRIPE_KEY' }] }]); + }); + + it('drops a cache the next run does not use', async () => { + const cache = new CacheStore(); + await cache.set('plugin:1password:vault/db', 'a', 60_000); + await load(); + expect(unlockInventoryForKey('varlock-default')).toHaveLength(1); + + // @cache=disabled means nothing on this key opens a cache file + writeEnvFile('.env.schema', [ + '# @defaultRequired=false', + '# @cache=disabled', + '# ---', + `STRIPE_KEY=${encryptedRef('stripe')}`, + ]); + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([{ kind: 'file', path: shownAs('.env.schema'), entries: [{ name: 'STRIPE_KEY' }] }]); + }); + + it('forgets a value that a later load no longer defines', async () => { + writeEnvFile('.env.local', [ + `STRIPE_KEY=${encryptedRef('stripe')}`, + `NGROK_TOKEN=${encryptedRef('ngrok')}`, + ]); + await load(); + expect(unlockInventoryForKey('varlock-default')[0].entries).toHaveLength(2); + + writeEnvFile('.env.local', [`STRIPE_KEY=${encryptedRef('stripe')}`]); + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([{ kind: 'file', path: shownAs('.env.local'), entries: [{ name: 'STRIPE_KEY' }] }]); + }); + + /** + * A value a later file overrides is never opened, so listing it would promise + * something the approval does not buy. + */ + it('leaves out a value a later file has overridden', async () => { + writeEnvFile('.env', [`STRIPE_KEY=${encryptedRef('old')}`]); + writeEnvFile('.env.local', ['STRIPE_KEY=plain-value']); + + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([]); + }); + + /** Only identity-encrypted values ride a session grant; a v1 payload asks its own way */ + it('leaves out device-key values, which no unlock session covers', async () => { + writeEnvFile('.env.local', [ + `OLD_KEY=${legacyRef('legacy')}`, + `STRIPE_KEY=${encryptedRef('stripe')}`, + ]); + + await load(); + + expect(unlockInventoryForKey('varlock-default')).toEqual([{ kind: 'file', path: shownAs('.env.local'), entries: [{ name: 'STRIPE_KEY' }] }]); + }); +}); diff --git a/packages/varlock/src/lib/cache/cache-identity-key.test.ts b/packages/varlock/src/lib/cache/cache-identity-key.test.ts new file mode 100644 index 000000000..83f18db34 --- /dev/null +++ b/packages/varlock/src/lib/cache/cache-identity-key.test.ts @@ -0,0 +1,92 @@ +/** + * Which key protects cache entries. + * + * Runs the real encryption stack (forced onto the file backend) rather than a + * stub codec, because the thing under test is exactly which key CacheStore + * reaches for. + */ + +import { + describe, it, expect, vi, beforeEach, afterEach, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { DEVICE_PAYLOAD_VERSION, IDENTITY_PAYLOAD_VERSION } from '../local-encrypt/crypto'; + +process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + +let tempDir: string; +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => tempDir, +})); + +let CacheStore: typeof import('./cache-store')['CacheStore']; + +/** Version byte of the stored ciphertext for one cache entry */ +function storedEntryVersion(keyId: string, cacheKey: string) { + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, 'cache', `${keyId}.json`), 'utf-8')); + return Buffer.from(raw[cacheKey].v, 'base64')[0]; +} + +beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-cache-identity-test-')); + vi.resetModules(); + process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + CacheStore = (await import('./cache-store')).CacheStore; +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +/** + * A codec pinned to the device key, standing in for cache files written before + * the identity layer existed. + */ +async function deviceKeyCodec(keyId: string) { + const localEncrypt = await import('../local-encrypt'); + return { + ensureReady: () => localEncrypt.ensureKey(keyId), + encrypt: (plaintext: string) => localEncrypt.encryptValue(plaintext, keyId, { target: 'device' }), + decrypt: (ciphertext: string) => localEncrypt.decryptValue(ciphertext, keyId), + }; +} + +describe('cache key selection', () => { + it('encrypts cache entries to the identity key on the file backend', async () => { + const store = new CacheStore('varlock-default'); + await store.set('plugin:test:key', 'cached value', 60_000); + + expect(storedEntryVersion('varlock-default', 'plugin:test:key')).toBe(IDENTITY_PAYLOAD_VERSION); + expect(fs.existsSync(path.join(tempDir, 'identities', 'default.json'))).toBe(true); + }); + + it('round-trips a cached value through the identity key', async () => { + const store = new CacheStore('varlock-default'); + await store.set('plugin:test:key', { nested: [1, 2, 3] }, 60_000); + + const result = await store.get('plugin:test:key'); + expect(result!.value).toEqual({ nested: [1, 2, 3] }); + }); + + it('reads entries back in a later process', async () => { + const first = new CacheStore('varlock-default'); + await first.set('plugin:test:key', 'persisted', 60_000); + + vi.resetModules(); + const ReloadedCacheStore = (await import('./cache-store')).CacheStore; + const second = new ReloadedCacheStore('varlock-default'); + expect((await second.get('plugin:test:key'))!.value).toBe('persisted'); + }); + + it('still reads entries written to the device key before the identity existed', async () => { + const legacyStore = new CacheStore('varlock-default', await deviceKeyCodec('varlock-default')); + await legacyStore.set('plugin:test:key', 'written as v1', 60_000); + expect(storedEntryVersion('varlock-default', 'plugin:test:key')).toBe(DEVICE_PAYLOAD_VERSION); + + // a store on the default codec, which now targets the identity, still opens it + const currentStore = new CacheStore('varlock-default'); + expect((await currentStore.get('plugin:test:key'))!.value).toBe('written as v1'); + }); +}); diff --git a/packages/varlock/src/lib/cache/cache-lock.test.ts b/packages/varlock/src/lib/cache/cache-lock.test.ts index 52228708d..e0330f146 100644 --- a/packages/varlock/src/lib/cache/cache-lock.test.ts +++ b/packages/varlock/src/lib/cache/cache-lock.test.ts @@ -11,6 +11,8 @@ import { CacheStore, withDirLock } from './cache-store'; vi.mock('../local-encrypt', () => ({ // eslint-disable-next-line @typescript-eslint/no-empty-function ensureKey: vi.fn(async () => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ensureEncryptionReady: vi.fn(async () => {}), encryptValue: vi.fn(async (value: string) => `encrypted:${value}`), decryptValue: vi.fn(async (value: string) => value.replace('encrypted:', '')), })); diff --git a/packages/varlock/src/lib/cache/cache-store.test.ts b/packages/varlock/src/lib/cache/cache-store.test.ts index 50d062b89..33f392ee5 100644 --- a/packages/varlock/src/lib/cache/cache-store.test.ts +++ b/packages/varlock/src/lib/cache/cache-store.test.ts @@ -4,12 +4,15 @@ import { import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { CacheStore } from './cache-store'; +import { CacheStore, cacheProducerLabel, summariseCacheProducers } from './cache-store'; +import * as localEncrypt from '../local-encrypt'; // mock localEncrypt to avoid needing real encryption keys vi.mock('../local-encrypt', () => ({ // eslint-disable-next-line @typescript-eslint/no-empty-function ensureKey: vi.fn(async () => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ensureEncryptionReady: vi.fn(async () => {}), encryptValue: vi.fn(async (value: string) => `encrypted:${value}`), decryptValue: vi.fn(async (value: string) => value.replace('encrypted:', '')), })); @@ -291,6 +294,63 @@ describe('CacheStore', () => { }); }); + // Opening a cached value can cost a presence check, and a panel that cannot + // say what it is asking about is a panel nobody can answer honestly. So the + // cache says which key it sits behind, how much is behind it, and who filled + // it. All of it is client-reported decoration, like every other display line. + describe('what the unlock panel is told', () => { + it('describes itself as a source under the key that encrypts it', async () => { + const store = new CacheStore(); + await store.set('plugin:1password:vault/db', 'a', 60_000); + await store.set('plugin:1password:vault/api', 'b', 60_000); + await store.set('resolver:/code/acme/.env.local:TOKEN:exec(...)', 'c', 60_000); + await store.get('plugin:1password:vault/db'); + + const display = vi.mocked(localEncrypt.decryptValue).mock.calls.at(-1)![2]?.display; + expect(display?.projectName).toBe(path.basename(process.cwd())); + expect(display?.keys?.['varlock-default']).toEqual({ + valueCount: 3, + sources: [ + { + kind: 'cache', + itemCount: 3, + entries: [{ name: '1password', count: 2 }, { name: '.env.local', count: 1 }], + }, + ], + }); + }); + + it('counts only what is still live', async () => { + const store = new CacheStore(); + await store.set('plugin:test:fresh', 'a', 60_000); + await store.set('plugin:test:stale', 'b', 60_000); + + const raw = JSON.parse(fs.readFileSync(store.getFilePath(), 'utf-8')); + raw['plugin:test:stale'].e = Date.now() - 1; + fs.writeFileSync(store.getFilePath(), JSON.stringify(raw)); + + await store.get('plugin:test:fresh'); + const display = vi.mocked(localEncrypt.decryptValue).mock.calls.at(-1)![2]?.display; + expect(display?.keys?.['varlock-default']?.valueCount).toBe(1); + }); + + it('names producers without spelling out the cache keys behind them', () => { + expect(cacheProducerLabel('plugin:1password')).toBe('1password'); + expect(cacheProducerLabel('resolver:custom')).toBe('custom cache keys'); + // the directory is neither recognisable at panel size nor anyone else's + // business on a machine hosting several projects + expect(cacheProducerLabel('resolver:/code/acme/.env.local')).toBe('.env.local'); + }); + + it('adds up the tail rather than dropping it, so the entries match the total', () => { + const keys = Array.from({ length: 12 }, (_, i) => `plugin:p${i}:x`); + const producers = summariseCacheProducers(keys); + expect(producers).toHaveLength(8); + expect(producers.at(-1)).toEqual({ name: '5 more', count: 5 }); + expect(producers.reduce((sum, p) => sum + p.count, 0)).toBe(12); + }); + }); + describe('set return value', () => { it('returns the stored timestamps', async () => { const store = new CacheStore(); diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts index c69015b4e..cf36a87a7 100644 --- a/packages/varlock/src/lib/cache/cache-store.ts +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -5,6 +5,9 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash, randomBytes } from 'node:crypto'; import { getUserVarlockDir } from '../user-config-dir'; import * as localEncrypt from '../local-encrypt'; +import { projectDisplay } from '../local-encrypt/session-decrypt'; +import { declareCacheInventory } from '../local-encrypt/unlock-inventory'; +import type { UnlockDisplayInfo, UnlockValueSource } from '../local-encrypt/types'; import { createDebug } from '../debug'; const debug = createDebug('varlock:cache'); @@ -275,7 +278,13 @@ export type CacheValueCodec = { /** called before the first write — e.g. ensure a key exists / is valid */ ensureReady(): Promise | void; encrypt(plaintext: string): Promise | string; - decrypt(ciphertext: string): Promise | string; + /** + * `display` describes the read for the unlock panel, when opening this value + * costs a presence check. Nothing is bound into the crypto and the daemon + * never checks it: it exists so a cache read is something a person can + * recognise on the panel instead of an unexplained request. + */ + decrypt(ciphertext: string, display?: UnlockDisplayInfo): Promise | string; }; export type CacheStoreLike = { @@ -288,6 +297,11 @@ export type CacheStoreLike = { set(cacheKey: string, value: any, ttlMs: number): Promise<{ cachedAt: number; expiresAt: number } | undefined>; delete(cacheKey: string): Promise; clearAll(): Promise; + /** + * Tell the unlock panel what this store holds, for the stores that cost an + * unlock to read. A store that costs none does not implement it. + */ + declareUnlockInventory?(): void; }; /** Compute a concrete expiry timestamp from a TTL (Infinity → far-future) */ @@ -315,6 +329,53 @@ export function hasInvalidCacheKeyChars(key: string): boolean { return false; } +/** + * What a cache key's group is called on the unlock panel. + * + * A plugin is named by its own name, which is the useful half of + * `plugin:1password:vault/...`; a resolver group is named by the file it + * resolved in, since the directory it sits in is neither recognisable at panel + * size nor anyone's business on a machine that hosts several projects. The + * cache key itself is never drawn: it can spell out which item in which vault + * was fetched, and the panel only needs to say who filled the cache. + */ +export function cacheProducerLabel(prefix: string): string { + if (prefix.startsWith('plugin:')) return prefix.slice('plugin:'.length); + if (prefix === 'resolver:custom') return 'custom cache keys'; + if (prefix.startsWith('resolver:')) return path.basename(prefix.slice('resolver:'.length)); + return prefix; +} + +/** How many producers the panel is willing to name before summarising the tail */ +const MAX_DISPLAYED_PRODUCERS = 8; + +/** + * Who filled the cache, and how much each of them contributed. + * + * Biggest first, because that is the order that answers "what is in here" the + * fastest, and the tail past the cap is added up rather than dropped: a total + * the entries do not add up to would be the panel misleading by omission. + */ +export function summariseCacheProducers( + cacheKeys: Array, +): Array<{ name: string; count: number }> { + const counts = new Map(); + for (const key of cacheKeys) { + const label = cacheProducerLabel(groupKeyPrefix(key)); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + if (sorted.length <= MAX_DISPLAYED_PRODUCERS) { + return sorted.map(([name, count]) => ({ name, count })); + } + const shown = sorted.slice(0, MAX_DISPLAYED_PRODUCERS - 1); + const rest = sorted.slice(MAX_DISPLAYED_PRODUCERS - 1); + return [ + ...shown.map(([name, count]) => ({ name, count })), + { name: `${rest.length} more`, count: rest.reduce((sum, [, count]) => sum + count, 0) }, + ]; +} + export function assertValidCacheKey(key: string, label = 'cache key'): void { if (typeof key !== 'string') { throw new Error(`Invalid ${label}: must be a string`); @@ -339,19 +400,105 @@ export function assertValidCacheKey(key: string, label = 'cache key'): void { * envelope that records its cache key so ciphertexts cannot be swapped between * entries within the file. Cache keys are structured strings like * `plugin:name:key` or `resolver:path:item:text`. + * + * Which key protects those entries follows the same routing as env values: the + * identity key on backends that encrypt to one, the device key elsewhere. + * `ensureEncryptionReady` puts whichever of those the run needs in place before + * the first write, so a cache write never races key creation inside the lock. + * + * Sharing that key means sharing the unlock, so a cache read can be what puts + * the panel on screen, and it must be able to say what it is. It describes + * itself as a source under its key: see `unlockDisplay`. Cached values are often + * the more sensitive half of what a key protects, since they are what came back + * from 1Password and the other providers, so a cache read the panel could not + * name would be the worst thing on it to approve blind. */ export class CacheStore { private filePath: string; + private keyId: string; private codec: CacheValueCodec; + /** + * Whether this store's values sit behind the local encryption key, which is + * what decides whether it belongs on the unlock panel at all. A store with a + * codec of its own (the `_VARLOCK_CACHE_KEY` one) costs no unlock, so listing + * it would name a source no grant opens. + */ + private sharesLocalEncryptKey: boolean; private static warnedWriteFailure = false; constructor(keyId: string = 'varlock-default', codec?: CacheValueCodec) { const cacheDir = path.join(getUserVarlockDir(), 'cache'); + this.keyId = keyId; this.filePath = path.join(cacheDir, `${keyId}.json`); + this.sharesLocalEncryptKey = codec === undefined; this.codec = codec ?? { - ensureReady: () => localEncrypt.ensureKey(keyId), + ensureReady: () => localEncrypt.ensureEncryptionReady(keyId), encrypt: (plaintext) => localEncrypt.encryptValue(plaintext, keyId), - decrypt: (ciphertext) => localEncrypt.decryptValue(ciphertext, keyId), + decrypt: (ciphertext, display) => localEncrypt.decryptValue(ciphertext, keyId, { display }), + }; + } + + /** + * The cache as one source under its key, or nothing when it holds nothing. + * + * The cache is one of the things this key protects, so it is described the + * same way an env file is: a source under the key, with what filled it and + * how much. Anything else on this key (the project's own `varlock()` values) + * is a sibling source, and one approval covers the lot, which is exactly why + * they belong in one list rather than in separate requests that each look + * like the whole story. + * + * An empty cache returns nothing rather than an empty line: a grant that will + * open no cached value should not be shown one. + */ + private unlockSource(data: CacheData): UnlockValueSource | undefined { + const live = Object.keys(data).filter((key) => Date.now() <= data[key].e); + if (live.length === 0) return undefined; + return { kind: 'cache', itemCount: live.length, entries: summariseCacheProducers(live) }; + } + + /** + * Say what this cache holds before anything asks it for a value. + * + * Called once, when this store becomes the run's cache. Without it the cache + * only describes itself at the moment it decrypts, which is too late whenever + * an env file got to the same key first: the panel would then have described + * one caller's batch as if it were the whole grant, and the cache would open + * moments later on an approval that never mentioned it. + * + * Costs one read of a file the first cache hit would have read anyway, and a + * failure here loses a line on a panel, never a load. + */ + declareUnlockInventory(): void { + if (!this.sharesLocalEncryptKey) return; + try { + declareCacheInventory(this.keyId, this.unlockSource(this.loadFile())); + } catch (err) { + debug('could not declare cache contents for the unlock panel: %O', err); + } + } + + /** + * What the unlock panel is told a cache read is for. + * + * Built from the cache file the caller has already read, so it costs no extra + * IO, and it is client-reported like every other line the daemon draws from a + * caller: none of it reaches the crypto and the daemon checks none of it. + * The same fresher account replaces what was declared at load time, so an + * unlock triggered later in the run counts what the cache holds now. + */ + private unlockDisplay(data: CacheData): UnlockDisplayInfo { + const source = this.unlockSource(data); + if (this.sharesLocalEncryptKey) declareCacheInventory(this.keyId, source); + if (!source) return { ...projectDisplay() }; + return { + ...projectDisplay(), + keys: { + [this.keyId]: { + valueCount: source.itemCount, + sources: [source], + }, + }, }; } @@ -372,7 +519,7 @@ export class CacheStore { } try { - const plaintext = await this.codec.decrypt(entry.v); + const plaintext = await this.codec.decrypt(entry.v, this.unlockDisplay(data)); const envelope = JSON.parse(plaintext); // the envelope binds the ciphertext to its key — a swapped/replayed entry decrypts // fine but fails this check diff --git a/packages/varlock/src/lib/exec-sync-varlock.ts b/packages/varlock/src/lib/exec-sync-varlock.ts index 0b856de52..3f237fe95 100644 --- a/packages/varlock/src/lib/exec-sync-varlock.ts +++ b/packages/varlock/src/lib/exec-sync-varlock.ts @@ -69,6 +69,11 @@ function mergeExecEnv( for (const key of Object.keys(merged)) { if (key.toUpperCase() === 'NODE_OPTIONS') delete merged[key]; } + // Every call through here is varlock being loaded BY something, not a person + // typing a command, and the unlock panel says so ("auto-loaded inside next + // dev") rather than showing an internal command line nobody typed. The child + // is the process that talks to the daemon, so it has to be told. + merged._VARLOCK_INVOCATION_MODE = 'auto-load'; if (opts?.integrationTelemetry) { // __VARLOCK_INTEGRATION is for our internal use only — the integration-provided // identity is authoritative and always wins over any inherited/user-set value. @@ -124,31 +129,20 @@ export function execSyncVarlock( ...childProcessOpts } = opts ?? {}; try { - // in most cases, user will be running via their package manager - // and a package.json script (ie `pnpm run start`) - // which will inject node_modules/.bin into PATH - try { - const result = execSync(`varlock ${command}`, { - env: execEnv, - ...opts?.cwd && { cwd: opts.cwd }, - stdio: 'pipe', - }); - return opts?.fullResult - ? { stdout: result.toString(), stderr: '' } - : result.toString(); - } catch (err) { - // code 127 means not found (on linux only) - // ENOENT from execSync means that a shell was not found - if (!isWindows && (err as any).status !== 127 && (err as any).code !== 'ENOENT') throw err; - // on windows, we'll just do the extra checks anyway - } - - // if varlock was not found, it either means it is not installed - // or we must find the path to node_modules/.bin ourselves. - // Search from cwd (if provided), callerDir, then process.cwd(). - // This handles monorepo setups where cwd may be an unrelated workspace - // root while varlock is only installed in a sub-package - the callerDir - // supplied by auto-load.ts points inside that sub-package's node_modules. + // The project's own varlock first, then whatever is on PATH. + // + // A package manager script (`pnpm run start`) puts node_modules/.bin on + // PATH and the two agree, but plenty of real entry points do not: a file + // run directly, a test runner, an editor task, a tool that spawns node + // itself. In those a globally installed varlock silently answers for the + // project's own, which is how you end up debugging a version the project + // does not depend on. Local install wins, the same precedence every + // package manager applies. + // + // Search from cwd (if provided), callerDir, then process.cwd(). This + // handles monorepo setups where cwd may be an unrelated workspace root + // while varlock is only installed in a sub-package: the callerDir supplied + // by auto-load.ts points inside that sub-package's node_modules. const cwdStr = opts?.cwd ? String(opts.cwd) : undefined; const searchDirs = [ ...(cwdStr ? [cwdStr] : []), @@ -172,6 +166,25 @@ export function execSyncVarlock( : result.toString(); } } + + // No local install: a standalone binary or a global install is the whole + // point of this path, so ask PATH. + try { + const result = execSync(`varlock ${command}`, { + env: execEnv, + ...opts?.cwd && { cwd: opts.cwd }, + stdio: 'pipe', + }); + return opts?.fullResult + ? { stdout: result.toString(), stderr: '' } + : result.toString(); + } catch (err) { + // code 127 means not found (on linux only) + // ENOENT from execSync means that a shell was not found + if (!isWindows && (err as any).status !== 127 && (err as any).code !== 'ENOENT') throw err; + // on windows, we'll just do the extra checks anyway + } + throw new Error('Unable to find varlock executable'); } catch (err) { // In fullResult mode, wrap the error as VarlockExecError with structured fields diff --git a/packages/varlock/src/lib/local-encrypt/binary-resolver.ts b/packages/varlock/src/lib/local-encrypt/binary-resolver.ts index 59ac8933a..8241b1d59 100644 --- a/packages/varlock/src/lib/local-encrypt/binary-resolver.ts +++ b/packages/varlock/src/lib/local-encrypt/binary-resolver.ts @@ -183,6 +183,15 @@ function resolveNpmBundled(): string | undefined { return resolveBinaryFromDir(path.join(packageRoot, 'native-bins', getNativeBinSubdir())); } +/** Modification time of a binary, or 0 if it cannot be statted */ +function getMtimeMs(binaryPath: string): number { + try { + return fs.statSync(binaryPath).mtimeMs; + } catch { + return 0; + } +} + /** * Strategy 4: Development fallback — look for build output in the monorepo. * Walks up from __dirname looking for native binary build output @@ -194,28 +203,29 @@ function resolveDevFallback(): string | undefined { if (parent === dir) break; dir = parent; - // Check for Swift build output (macOS) + // Debug as well as release, newest wins. Day to day work on the native + // helper builds debug (`swift build`), so probing only release meant a dev + // checkout silently fell through to whatever was installed from npm and ran + // an old daemon against new client code. + const buildOutputs: Array = []; if (process.platform === 'darwin') { - const swiftBuild = path.join(dir, 'packages', 'encryption-binary-swift', 'swift', '.build', 'release', 'VarlockEnclave'); - if (fs.existsSync(swiftBuild)) return swiftBuild; + for (const config of ['release', 'debug']) { + buildOutputs.push(path.join(dir, 'packages', 'encryption-binary-swift', 'swift', '.build', config, 'VarlockEnclave')); + } + } + for (const config of ['release', 'debug']) { + buildOutputs.push(path.join(dir, 'packages', 'encryption-binary-rust', 'target', config, getPlatformBinaryName())); } - // Check for Rust build output (Linux/Windows) - const rustBuild = path.join(dir, 'packages', 'encryption-binary-rust', 'target', 'release', getPlatformBinaryName()); - if (fs.existsSync(rustBuild)) return rustBuild; + const built = buildOutputs.filter((binaryPath) => fs.existsSync(binaryPath)); + if (built.length) { + return built.reduce((a, b) => (getMtimeMs(b) > getMtimeMs(a) ? b : a)); + } } return undefined; } -/** Modification time of a binary, or 0 if it cannot be statted */ -function getMtimeMs(binaryPath: string): number { - try { - return fs.statSync(binaryPath).mtimeMs; - } catch { - return 0; - } -} /** * Ensure the binary at the given path is executable. @@ -249,6 +259,21 @@ export function resolveNativeBinary(): string | undefined { return undefined; } + // An explicit path wins over every strategy below. Resolution otherwise + // depends on where the caller happens to sit and on build timestamps, which + // is fine in an install and miserable when working on the helper itself: + // pointing a scratch project at a specific build should not require guessing + // which candidate the resolver will prefer. + const override = process.env._VARLOCK_NATIVE_BINARY; + if (override) { + if (!fs.existsSync(override)) { + throw new Error(`_VARLOCK_NATIVE_BINARY is set to "${override}", which does not exist`); + } + debug(`resolved via _VARLOCK_NATIVE_BINARY: ${override}`); + _cachedBinaryPath = ensureExecutable(override); + return _cachedBinaryPath; + } + debug(`resolving: platform=${process.platform}, isWSL=${isWSL()}, binaryName=${getPlatformBinaryName()}, subdir=${getNativeBinSubdir()}`); const seaSibling = resolveSeaSibling(); diff --git a/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts b/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts index 9f66b3fc2..afb3986c7 100644 --- a/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts +++ b/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts @@ -100,6 +100,59 @@ describe('VarlockResolver with file fallback', () => { expect(resolver.schemaErrors.length).toBeGreaterThan(0); }); + it('throws a ResolutionError naming an unknown scheme', async () => { + await localEncrypt.ensureKey(); + + const resolver = new VarlockResolver([new StaticValueResolver('teamvault:AQIDBA==')]); + resolver.process(); + expect(resolver.schemaErrors).toHaveLength(0); + + const err = await resolver.resolve().then(() => undefined, (e) => e); + // must name the scheme rather than report a decryption failure + expect(err.message).toContain('unknown varlock() scheme "teamvault"'); + expect(err.message).not.toMatch(/Decryption failed/); + }); + + it('reports an unsupported payload version instead of a generic failure', async () => { + await localEncrypt.ensureKey(); + const ciphertext = await localEncrypt.encryptValue('some-secret'); + + // bump the version byte to a format this build cannot read + const buf = Buffer.from(ciphertext, 'base64'); + buf[0] = 0x03; + + const resolver = new VarlockResolver([new StaticValueResolver(`local:${buf.toString('base64')}`)]); + resolver.process(); + + await expect(resolver.resolve()).rejects.toThrow(/unsupported encrypted payload version 3; upgrade varlock/); + }); + + it('decrypts an identity-encrypted (v2) payload end-to-end', async () => { + await localEncrypt.ensureKey(); + const plaintext = 'identity-held-secret'; + const ciphertext = await localEncrypt.encryptValue(plaintext); + // the file backend encrypts new values to the identity key + expect(Buffer.from(ciphertext, 'base64')[0]).toBe(0x02); + + const resolver = new VarlockResolver([new StaticValueResolver(`local:${ciphertext}`)]); + resolver.process(); + expect(resolver.schemaErrors).toHaveLength(0); + + expect(await resolver.resolve()).toBe(plaintext); + }); + + it('still decrypts device-encrypted (v1) payloads', async () => { + await localEncrypt.ensureKey(); + const plaintext = 'device-held-secret'; + const ciphertext = await localEncrypt.encryptValue(plaintext, undefined, { target: 'device' }); + expect(Buffer.from(ciphertext, 'base64')[0]).toBe(0x01); + + const resolver = new VarlockResolver([new StaticValueResolver(`local:${ciphertext}`)]); + resolver.process(); + + expect(await resolver.resolve()).toBe(plaintext); + }); + it('handles concurrent decrypt calls via batch queue', async () => { await localEncrypt.ensureKey(); const values = ['secret-1', 'secret-2', 'secret-3']; diff --git a/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts b/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts index b03b44bc9..a917f0d09 100644 --- a/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts +++ b/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts @@ -5,13 +5,17 @@ * Works cross-platform using the local-encrypt abstraction layer. */ +import path from 'node:path'; import { createResolver, Resolver } from '../../env-graph/lib/resolver'; import { ResolutionError, SchemaError } from '../../env-graph/lib/errors'; import prompts from '../../cli/helpers/prompts'; +import { DEFAULT_KEY_ID } from './constants'; +import { IDENTITY_PAYLOAD_VERSION, readPayloadVersion } from './crypto'; import * as localEncrypt from './index'; +import { buildVarlockReference, LOCAL_SCHEME, parseVarlockReference } from './reference'; +import { projectDisplay } from './session-decrypt'; import { writeBackValue } from './write-back'; -const LOCAL_PREFIX = 'local:'; const PLUGIN_ICON = 'mdi:fingerprint'; // ── Unified varlock() batch queue ────────────────────────────── @@ -20,13 +24,26 @@ const PLUGIN_ICON = 'mdi:fingerprint'; // Prompts are sorted first so the user enters values before biometric decrypts. // If the user cancels a prompt or biometric auth, all remaining items in the // batch are rejected immediately. +// +// Identity-encrypted (v2) values are the exception to "sequentially": they are +// opened as one group before the loop runs, so a file full of secrets costs a +// single unlock instead of one per value. The loop then just hands out results +// that are already in hand. type VarlockBatchEntry = { kind: 'prompt' | 'decrypt'; + /** local encryption key id this entry encrypts or decrypts with */ + keyId: string; resolve: (value: string) => void; reject: (reason: unknown) => void; } & ( - | { kind: 'decrypt'; ciphertext: string } + | { + kind: 'decrypt'; + ciphertext: string; + /** the env var this value belongs to, and the file that set it, for the panel */ + itemKey?: string; + sourceFilePath?: string; + } | { kind: 'prompt'; execute: () => Promise } ); @@ -46,18 +63,28 @@ function enqueueBatchEntry(entry: VarlockBatchEntry) { } } -function enqueueDecrypt(ciphertext: string): Promise { +function enqueueDecrypt( + ciphertext: string, + keyId: string, + origin: { itemKey?: string; sourceFilePath?: string } = {}, +): Promise { return new Promise((resolve, reject) => { enqueueBatchEntry({ - kind: 'decrypt', ciphertext, resolve, reject, + kind: 'decrypt', + ciphertext, + keyId, + itemKey: origin.itemKey, + sourceFilePath: origin.sourceFilePath, + resolve, + reject, }); }); } -function enqueuePrompt(execute: () => Promise): Promise { +function enqueuePrompt(keyId: string, execute: () => Promise): Promise { return new Promise((resolve, reject) => { enqueueBatchEntry({ - kind: 'prompt', execute, resolve, reject, + kind: 'prompt', keyId, execute, resolve, reject, }); }); } @@ -68,6 +95,44 @@ function bailRemaining(batch: Array, startIndex: number, erro } } +type DecryptBatchEntry = Extract; + +function isIdentityEntry(entry: VarlockBatchEntry): entry is DecryptBatchEntry { + return entry.kind === 'decrypt' + && readPayloadVersion(entry.ciphertext) === IDENTITY_PAYLOAD_VERSION; +} + +/** + * Open every identity-encrypted entry in the batch as one group. + * + * Run once, at the first v2 value the loop reaches rather than up front, so the + * prompts sorted ahead of it still get the user's attention first: nobody wants + * an unlock panel over the top of a dialog asking them to type a secret. + */ +async function openIdentityEntries( + batch: Array, +): Promise> { + const identityEntries = batch.filter(isIdentityEntry); + const opened = new Map(); + if (identityEntries.length === 0) return opened; + + const plaintexts = await localEncrypt.decryptIdentityPayloads( + identityEntries.map((entry) => ({ + ciphertext: entry.ciphertext, + keyId: entry.keyId, + // What the panel lists behind each key's row. Display only: the daemon + // never checks it and the crypto never sees it. + valueName: entry.itemKey, + sourceFile: entry.sourceFilePath, + })), + // decoration for the unlock panel. The daemon works out who is asking from + // the connection itself and treats all of this as secondary. + { display: projectDisplay() }, + ); + identityEntries.forEach((entry, i) => opened.set(entry, plaintexts[i])); + return opened; +} + async function executeBatch() { const batch = pendingBatch; pendingBatch = undefined; @@ -79,14 +144,31 @@ async function executeBatch() { return a.kind === 'prompt' ? -1 : 1; }); - // Ensure encryption key exists before processing any items - await localEncrypt.ensureKey(); + // Ensure every key this batch touches exists before processing any items + for (const keyId of new Set(batch.map((e) => e.keyId))) { + await localEncrypt.ensureKey(keyId); + } + + let identityValues: Map | undefined; for (let i = 0; i < batch.length; i++) { const entry = batch[i]; try { if (entry.kind === 'decrypt') { - const plaintext = await localEncrypt.decryptValue(entry.ciphertext); + if (isIdentityEntry(entry) && !identityValues) { + // the single unlock that covers every v2 value in this batch; a failure + // here belongs to all of them, so it takes the whole rest of the batch + try { + identityValues = await openIdentityEntries(batch); + } catch (err) { + bailRemaining(batch, i, err instanceof Error ? err : new Error(String(err))); + return; + } + } + const alreadyOpened = identityValues?.get(entry); + const plaintext = alreadyOpened !== undefined + ? alreadyOpened + : await localEncrypt.decryptValue(entry.ciphertext, entry.keyId); entry.resolve(plaintext); } else { const result = await entry.execute(); @@ -108,9 +190,24 @@ async function executeBatch() { } } +/** + * The file an env value came from, named the way a person would name it. + * + * `.env.local` rather than a home-directory-deep absolute path: the panel row + * is about recognising your own project, and the full path is neither + * recognisable nor small enough to draw. + */ +function displayFilePath(fullPath: string | undefined): string | undefined { + if (!fullPath) return undefined; + const relative = path.relative(process.cwd(), fullPath); + return relative && !relative.startsWith('..') ? relative : fullPath; +} + type VarlockResolverState = { mode: 'decrypt'; payload: string; + itemKey?: string; + sourceFilePath?: string; } | { mode: 'prompt'; itemKey: string; @@ -122,8 +219,41 @@ function writeBackEncryptedValue( ciphertext: string, sourceFilePath: string | undefined, ) { - const prefixedCiphertext = `${LOCAL_PREFIX}${ciphertext}`; - return writeBackValue(itemKey, `varlock("${prefixedCiphertext}")`, sourceFilePath); + return writeBackValue(itemKey, buildVarlockReference(LOCAL_SCHEME, ciphertext), sourceFilePath); +} + +/** + * Encrypt a value the user just typed at the terminal. + * + * The two native daemons capture secrets differently, on purpose. macOS has + * `prompt-secret`: the Swift daemon draws its own dialog, so the value is read + * and encrypted without ever crossing the socket, and the branch above uses it. + * The Rust daemon has no such op and is not going to grow one, because on + * Windows and Linux the reading happens here in the terminal. What it offers + * instead is `encrypt` with an `identityPublicKey`, which is what this uses, so + * both platforms end up with the daemon minting the ciphertext. + * + * Do not "unify" these by pointing one platform at the other's op: neither + * daemon implements the other's, and the difference is about which process owns + * the input, not about the encryption. The in-process fallback is safe either + * way, since encrypting to an identity needs nothing but its public key. + */ +async function encryptCapturedSecret(plaintext: string, keyId: string): Promise { + const backend = localEncrypt.getBackendInfo(); + const identityPublicKey = await localEncrypt.getEncryptionIdentityPublicKey(keyId); + + if (identityPublicKey && backend.type !== 'file' && localEncrypt.canUseIdentityEncryption()) { + try { + return await localEncrypt.getDaemonClient().encryptToIdentity(plaintext, identityPublicKey); + } catch (err) { + // A daemon that will not start is no reason to lose the value the user + // just typed: the same encryption runs here from the same public key. + localEncrypt.debugLog( + `daemon encrypt failed, encrypting in-process: ${err instanceof Error ? err.message : err}`, + ); + } + } + return localEncrypt.encryptValue(plaintext, keyId); } @@ -162,20 +292,111 @@ export const VarlockResolver: typeof Resolver = createResolver --no-auth', + ].join('\n'), + }); + } + if (err instanceof localEncrypt.StaleDaemonError) { + throw new ResolutionError(err.message, { + tip: 'The native helper is older than this varlock. Reinstall to get a matching one.', + }); + } + // An identity-encrypted value somewhere that cannot open one is a + // capability gap, not a corrupt value: say so. + if (err instanceof localEncrypt.IdentityBackendUnsupportedError) { + throw new ResolutionError(err.message, { + tip: 'Run `varlock encrypt --upgrade` from native Windows, or keep these values device-encrypted.', + }); + } + if (err instanceof localEncrypt.IdentityWrapMissingError) { + throw new ResolutionError(err.message, { + tip: 'Set the value again on this machine with `varlock encrypt` or `KEY=varlock(prompt)`.', + }); + } + if (err instanceof localEncrypt.IdentityNotFoundError) { + throw new ResolutionError(err.message, { + tip: 'This value was encrypted to an identity key that is not on this machine.', + }); + } + const backend = localEncrypt.getBackendInfo(); throw new ResolutionError( `Decryption failed: ${err instanceof Error ? err.message : err}`, @@ -193,14 +414,24 @@ export const VarlockResolver: typeof Resolver = createResolver { + return enqueuePrompt(keyId, async () => { const backend = localEncrypt.getBackendInfo(); - // Use daemon's native dialog on macOS Secure Enclave + // Use daemon's native dialog on macOS Secure Enclave. + // + // Only the Swift daemon has `prompt-secret`, because only it draws the + // dialog. The Rust daemon deliberately has no such op: on Windows and + // Linux the value is typed into the terminal below and handed to the + // daemon's `encrypt` op instead. That asymmetry is intentional and the two + // daemons are not meant to converge here, so do not "fix" one to match the + // other. Either way the recipient is the identity public key, so the + // daemon returns a v2 payload. if (backend.type === 'secure-enclave' && backend.biometricAvailable) { const client = localEncrypt.getDaemonClient(); const ciphertext = await client.promptSecret({ itemKey, + keyId, + identityPublicKey: await localEncrypt.getEncryptionIdentityPublicKey(keyId), message: `Enter the secret value for ${itemKey}:`, }); @@ -228,7 +459,26 @@ export const VarlockResolver: typeof Resolver = createResolver { it('round-trips encrypt → decrypt', async () => { @@ -55,7 +58,7 @@ describe('ECIES crypto', () => { const tampered = buf.toString('base64'); await expect(decrypt(keyPair.privateKey, keyPair.publicKey, tampered)).rejects.toThrow( - 'Unsupported payload version', + 'unsupported encrypted payload version 255', ); }); @@ -93,3 +96,84 @@ describe('ECIES crypto', () => { expect(payload.length).toBe(1 + 65 + 12 + 4 + 16); // 98 bytes }); }); + +describe('assertSupportedPayloadVersion', () => { + /** Build a payload-shaped buffer whose first byte is the given version */ + function payloadWithVersion(version: number) { + const buf = Buffer.alloc(1 + 65 + 12 + 4 + 16); + buf[0] = version; + buf[1] = 0x04; + return buf.toString('base64'); + } + + it('accepts a real v1 payload', async () => { + const keyPair = await createKeyPair(); + const ciphertext = await encrypt(keyPair.publicKey, 'test'); + expect(DEVICE_PAYLOAD_VERSION).toBe(0x01); + expect(() => assertSupportedPayloadVersion(ciphertext)).not.toThrow(); + }); + + it('accepts a synthetic v1 payload', () => { + expect(() => assertSupportedPayloadVersion(payloadWithVersion(0x01))).not.toThrow(); + }); + + it('accepts a v2 (identity-encrypted) payload', () => { + expect(IDENTITY_PAYLOAD_VERSION).toBe(0x02); + expect(() => assertSupportedPayloadVersion(payloadWithVersion(0x02))).not.toThrow(); + }); + + it('rejects a v3 payload with an upgrade hint', () => { + expect(() => assertSupportedPayloadVersion(payloadWithVersion(0x03))) + .toThrow('unsupported encrypted payload version 3; upgrade varlock'); + }); + + it('reports the actual version number it found', () => { + expect(() => assertSupportedPayloadVersion(payloadWithVersion(0xFF))) + .toThrow('unsupported encrypted payload version 255'); + }); + + it('leaves non-payload junk to the backend to report', () => { + // not canonical base64, so it is not one of our payloads at all + expect(() => assertSupportedPayloadVersion('garbage-data')).not.toThrow(); + expect(() => assertSupportedPayloadVersion('not-valid-base64-ciphertext!')).not.toThrow(); + expect(() => assertSupportedPayloadVersion('')).not.toThrow(); + }); +}); + +describe('readPayloadVersion', () => { + it('reports the version byte of a real payload', async () => { + const keyPair = await createKeyPair(); + const v1 = await encrypt(keyPair.publicKey, 'test'); + const v2 = await encrypt(keyPair.publicKey, 'test', { version: IDENTITY_PAYLOAD_VERSION }); + expect(readPayloadVersion(v1)).toBe(0x01); + expect(readPayloadVersion(v2)).toBe(0x02); + }); + + it('returns undefined for things that are not payloads', () => { + expect(readPayloadVersion('')).toBeUndefined(); + expect(readPayloadVersion('not-valid-base64-ciphertext!')).toBeUndefined(); + }); +}); + +describe('identity (v2) payloads', () => { + it('round-trips encrypt → decrypt', async () => { + const keyPair = await createKeyPair(); + const plaintext = 'identity-encrypted secret'; + + const ciphertext = await encrypt(keyPair.publicKey, plaintext, { version: IDENTITY_PAYLOAD_VERSION }); + expect(Buffer.from(ciphertext, 'base64')[0]).toBe(0x02); + + expect(await decrypt(keyPair.privateKey, keyPair.publicKey, ciphertext)).toBe(plaintext); + }); + + it('uses the same wire format as v1 apart from the version byte', async () => { + const keyPair = await createKeyPair(); + const v1 = Buffer.from(await encrypt(keyPair.publicKey, 'test'), 'base64'); + const v2 = Buffer.from( + await encrypt(keyPair.publicKey, 'test', { version: IDENTITY_PAYLOAD_VERSION }), + 'base64', + ); + expect(v2.length).toBe(v1.length); + expect(v2[1]).toBe(0x04); // uncompressed point prefix + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/crypto.ts b/packages/varlock/src/lib/local-encrypt/crypto.ts index c3e7da68f..1f11b08ec 100644 --- a/packages/varlock/src/lib/local-encrypt/crypto.ts +++ b/packages/varlock/src/lib/local-encrypt/crypto.ts @@ -15,7 +15,16 @@ import { webcrypto } from 'node:crypto'; const subtle = webcrypto.subtle; -const PAYLOAD_VERSION = 0x01; +/** Payload encrypted directly to a device key (Secure Enclave / TPM / file) */ +export const DEVICE_PAYLOAD_VERSION = 0x01; +/** Payload encrypted to an identity public key, which is itself wrapped to a device key */ +export const IDENTITY_PAYLOAD_VERSION = 0x02; + +export type PayloadVersion = typeof DEVICE_PAYLOAD_VERSION | typeof IDENTITY_PAYLOAD_VERSION; + +/** Every payload format version this build can read. */ +export const SUPPORTED_PAYLOAD_VERSIONS: Array = [DEVICE_PAYLOAD_VERSION, IDENTITY_PAYLOAD_VERSION]; + const HKDF_SALT = new TextEncoder().encode('varlock-ecies-v1'); const EC_ALGORITHM = { name: 'ECDH', namedCurve: 'P-256' }; @@ -64,6 +73,46 @@ function base64ToUint8(base64: string): Uint8Array { return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); } +// ── Payload version ──────────────────────────────────────────────────── + +function checkPayloadVersion(version: number) { + if (!SUPPORTED_PAYLOAD_VERSIONS.includes(version)) { + throw new Error(`unsupported encrypted payload version ${version}; upgrade varlock`); + } +} + +/** + * Read the version byte of a payload, or undefined when the input is not one of + * our payloads at all (non-base64 junk, empty strings). + * + * The version byte is not covered by the AEAD tag, so it is a routing hint + * rather than an authenticated claim. Flipping it only sends the payload at the + * wrong key, where it fails to decrypt. + */ +export function readPayloadVersion(ciphertextBase64: string): number | undefined { + const payloadBytes = base64ToUint8(ciphertextBase64); + if (payloadBytes.byteLength === 0) return undefined; + if (bufferToBase64(payloadBytes) !== ciphertextBase64) return undefined; + return payloadBytes[0]; +} + +/** + * Fail early and clearly on a payload written by a newer varlock. + * + * Called before handing a payload to any backend (including the native + * binaries, whose error text we cannot change from here) so a future v3 payload + * degrades into "upgrade varlock" rather than a decryption failure. + * + * Anything that is not one of our payloads at all (non-base64 junk, empty + * strings) is left alone, so the backend keeps reporting it the way it always + * has. + */ +export function assertSupportedPayloadVersion(ciphertextBase64: string) { + const version = readPayloadVersion(ciphertextBase64); + if (version === undefined) return; + checkPayloadVersion(version); +} + // ── HKDF-SHA256 ──────────────────────────────────────────────────────── /** @@ -131,11 +180,21 @@ export async function createKeyPair(): Promise { /** * Encrypt plaintext using ECIES with the recipient's public key. * + * The wire format is identical for both payload versions. The version byte only + * records who the recipient key belongs to: a device key (v1) or an identity + * key (v2). That tells the reader which key to reach for. + * * @param publicKeyBase64 - Base64-encoded uncompressed P-256 public key (65 bytes raw) * @param plaintext - UTF-8 string to encrypt + * @param opts.version - payload version byte to stamp (defaults to device-direct v1) * @returns Base64-encoded ciphertext payload */ -export async function encrypt(publicKeyBase64: string, plaintext: string): Promise { +export async function encrypt( + publicKeyBase64: string, + plaintext: string, + opts?: { version?: PayloadVersion }, +): Promise { + const version = opts?.version ?? DEVICE_PAYLOAD_VERSION; const recipientPublicKey = await importPublicKey(publicKeyBase64); const recipientPubKeyRaw = base64ToUint8(publicKeyBase64); @@ -170,7 +229,7 @@ export async function encrypt(publicKeyBase64: string, plaintext: string): Promi // Assemble payload: version(1) | ephemeralPub(65) | nonce(12) | ciphertext(N) | tag(16) const payload = concatBuffers( - new Uint8Array([PAYLOAD_VERSION]), + new Uint8Array([version]), ephemeralPubKeyRaw, nonce, ciphertext, @@ -185,6 +244,9 @@ export async function encrypt(publicKeyBase64: string, plaintext: string): Promi /** * Decrypt ciphertext using ECIES with the recipient's private key. * + * Reads every supported payload version. The version byte says which key the + * payload was written for, so picking the right key pair is the caller's job. + * * @param privateKeyBase64 - Base64-encoded PKCS8 private key * @param publicKeyBase64 - Base64-encoded uncompressed P-256 public key of the recipient * @param ciphertextBase64 - Base64-encoded ciphertext payload @@ -202,10 +264,7 @@ export async function decrypt( } // Parse payload - const version = payloadBytes[0]; - if (version !== PAYLOAD_VERSION) { - throw new Error(`Unsupported payload version: ${version}`); - } + checkPayloadVersion(payloadBytes[0]); const ephemeralPubKeyRaw = payloadBytes.slice(1, 1 + PUBLIC_KEY_LENGTH); const nonce = payloadBytes.slice(1 + PUBLIC_KEY_LENGTH, HEADER_LENGTH); diff --git a/packages/varlock/src/lib/local-encrypt/daemon-client.ts b/packages/varlock/src/lib/local-encrypt/daemon-client.ts index 449b86235..ee4741265 100644 --- a/packages/varlock/src/lib/local-encrypt/daemon-client.ts +++ b/packages/varlock/src/lib/local-encrypt/daemon-client.ts @@ -22,9 +22,14 @@ import { spawn } from 'node:child_process'; import { getUserVarlockDir } from '../user-config-dir'; import { resolveNativeBinary } from './binary-resolver'; +import { DEFAULT_KEY_ID } from './constants'; import { isWSL } from './wsl-detect'; -import type { - KeychainFixAccessResult, KeychainItemMeta, KeychainItemRef, KeychainSetResult, +import { + DAEMON_PROTOCOL_VERSION, + type DaemonPingResult, type DecryptV2Request, type DecryptV2Result, + type InvalidateSessionRequest, type InvalidateSessionResult, type ListSessionsResult, + type KeychainFixAccessResult, type KeychainItemMeta, type KeychainItemRef, + type KeychainSetResult, type UnlockSessionRequest, type UnlockSessionResult, } from './types'; /** Timeout for daemon IPC messages that don't involve user interaction */ @@ -48,6 +53,25 @@ export class DaemonError extends Error { } } +/** + * Thrown when the daemon on this machine is too old for the op being asked of + * it, and restarting it did not produce a newer one. + * + * That means the binary on disk is itself old, so the fix is a reinstall rather + * than anything the client can retry. + */ +export class StaleDaemonError extends Error { + constructor(readonly running: number, readonly required: number) { + super( + `The varlock encryption daemon on this machine speaks protocol v${running}, ` + + `but this version of varlock needs v${required}. Restarting it did not help, ` + + 'so the installed native helper is out of date. Reinstall varlock (and its ' + + '@varlock/native-helper-* dependency) to get a matching helper.', + ); + this.name = 'StaleDaemonError'; + } +} + function debug(msg: string) { if (process.env.VARLOCK_DEBUG) { process.stderr.write(`[varlock:daemon-client] ${msg}\n`); @@ -229,6 +253,8 @@ export class DaemonClient { private connectingPromise: Promise | null = null; /** Set after we spawn a daemon in this process — skip stale check to avoid restart loops */ private spawnedInThisProcess = false; + /** Set after one protocol-driven restart, so a still-old daemon errors instead of looping */ + private restartedForProtocol = false; async ensureConnected(): Promise { if (this.isConnected && this.socket) return; @@ -291,7 +317,7 @@ export class DaemonClient { await this.connectToSocket(socketPath); } - async decrypt(ciphertext: string, keyId = 'varlock-default'): Promise { + async decrypt(ciphertext: string, keyId: string = DEFAULT_KEY_ID): Promise { return this.withRetry(async () => { await this.ensureConnected(); const result = await this.sendMessage({ @@ -306,10 +332,19 @@ export class DaemonClient { }); } + /** + * Read a secret in the daemon's own secure input dialog and get it back + * already encrypted, so the plaintext never crosses the socket. + * + * Passing `identityPublicKey` makes the daemon encrypt to that identity (a v2 + * payload) instead of to the device key. macOS only: this op exists on the + * Swift daemon and not the Rust one, which offers `encrypt` instead. + */ async promptSecret(opts?: { itemKey?: string; message?: string; keyId?: string; + identityPublicKey?: string; }): Promise { return this.withRetry(async () => { await this.ensureConnected(); @@ -320,6 +355,7 @@ export class DaemonClient { itemKey: opts?.itemKey, message: opts?.message, keyId: opts?.keyId, + identityPublicKey: opts?.identityPublicKey, }, }, INTERACTIVE_TIMEOUT_MS); if (result && typeof result === 'object' && 'ciphertext' in result) { @@ -333,10 +369,134 @@ export class DaemonClient { }); } - async invalidateSession(): Promise { + /** + * Ask the daemon what it is and what it speaks. + * + * `protocolVersion` is absent on daemons older than the identity session ops, + * and is reported as 1 here so callers can compare it numerically. + */ + async ping(): Promise { + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ action: 'ping' }) ?? {}; + return { + pong: result.pong === true, + sessionWarm: result.sessionWarm === true, + sessionId: result.sessionId ?? undefined, + protocolVersion: typeof result.protocolVersion === 'number' ? result.protocolVersion : 1, + }; + }); + } + + /** + * Make sure the running daemon is new enough for what we are about to ask it. + * + * A daemon that outlives an upgrade keeps serving the old protocol, and the + * caller would get "Unknown action" for an op this build depends on. So when + * the running one is too old we terminate it and let the next connect spawn a + * fresh one from the binary now on disk. That happens at most once per + * process: if the respawn is also old, the binary itself is old and no amount + * of restarting will fix it. + */ + private async ensureProtocolVersion(minVersion: number, opName: string): Promise { + const running = (await this.ping()).protocolVersion; + if (running >= minVersion) return; + + if (this.restartedForProtocol) throw new StaleDaemonError(running, minVersion); + this.restartedForProtocol = true; + + process.stderr.write( + `[varlock] The running encryption daemon speaks protocol v${running}, but "${opName}" ` + + `needs v${minVersion}. Restarting it.\n`, + ); + // forceCleanup kills the daemon by pid and clears its state files, so the + // ensureConnected inside the next ping spawns one from the current binary + this.forceCleanup(); + + const afterRestart = (await this.ping()).protocolVersion; + if (afterRestart < minVersion) throw new StaleDaemonError(afterRestart, minVersion); + } + + /** + * Open a grant so the daemon may hold the identity key on this session's + * behalf. One call covers every key it names, for a single presence check. + * + * Uses the interactive timeout: the daemon may be drawing the approval panel + * and waiting on a person, which takes as long as it takes. + */ + async unlockSession(request: UnlockSessionRequest): Promise { + await this.ensureProtocolVersion(DAEMON_PROTOCOL_VERSION, 'unlock-session'); + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ + action: 'unlock-session', + payload: request, + }, INTERACTIVE_TIMEOUT_MS); + return result as UnlockSessionResult; + }); + } + + /** + * Decrypt identity-encrypted payloads under a grant this session already + * holds. There is no implicit unlock: without a grant the daemon answers + * NO_SESSION_GRANT and the caller runs `unlock-session` first. + */ + async decryptV2(request: DecryptV2Request): Promise { + await this.ensureProtocolVersion(DAEMON_PROTOCOL_VERSION, 'decrypt-v2'); + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ + action: 'decrypt-v2', + payload: request, + }, BIOMETRIC_TIMEOUT_MS); + return result as DecryptV2Result; + }); + } + + /** Every live grant the daemon is holding, across all sessions */ + async listSessions(): Promise { + await this.ensureProtocolVersion(DAEMON_PROTOCOL_VERSION, 'list-sessions'); + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ action: 'list-sessions' }); + return { sessions: (result as ListSessionsResult | undefined)?.sessions ?? [] }; + }); + } + + /** + * Encrypt to an identity public key using the daemon. + * + * Only needed where the daemon has to hand back ciphertext for a value this + * process never sees, which is the secret-capture path on the Rust daemon. + * Ordinary encryption needs no daemon at all: the recipient is a public key. + */ + async encryptToIdentity(plaintext: string, identityPublicKey: string): Promise { + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ + action: 'encrypt', + payload: { plaintext, identityPublicKey }, + }); + return String(result); + }); + } + + /** + * Drop cached auth and any identity grants the daemon is holding. + * + * With no arguments this drops everything, as it always has. Naming a session + * drops that session's grants; naming a key as well drops exactly one. The + * targeted forms only exist on daemons that speak the session protocol, so + * they check the version first while the bare form stays compatible. + */ + async invalidateSession(target?: InvalidateSessionRequest): Promise { + if (target?.sessionId || target?.keyId) { + await this.ensureProtocolVersion(DAEMON_PROTOCOL_VERSION, 'invalidate-session'); + } return this.withRetry(async () => { await this.ensureConnected(); - await this.sendMessage({ action: 'invalidate-session' }); + const result = await this.sendMessage({ action: 'invalidate-session', payload: target ?? {} }); + return { invalidated: (result as InvalidateSessionResult | undefined)?.invalidated ?? 0 }; }); } @@ -636,6 +796,11 @@ export class DaemonClient { ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], + // Explicit, because under Bun (which the compiled varlock binary runs + // on) a child with no `env` inherits the env this process started with, + // not the current one. The daemon resolves the key store and identity + // files from the environment, so it has to see the same one we do. + env: process.env, }); const timeout = setTimeout(() => { diff --git a/packages/varlock/src/lib/local-encrypt/file-backend.ts b/packages/varlock/src/lib/local-encrypt/file-backend.ts index 8e00e0e9d..f9cd453d9 100644 --- a/packages/varlock/src/lib/local-encrypt/file-backend.ts +++ b/packages/varlock/src/lib/local-encrypt/file-backend.ts @@ -9,10 +9,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { getUserVarlockDir } from '../user-config-dir'; +import { DEFAULT_KEY_ID } from './constants'; import { createKeyPair, encrypt, decrypt } from './crypto'; const KEY_STORE_SUBDIR = 'local-encrypt/keys'; -const DEFAULT_KEY_ID = 'varlock-default'; interface StoredKeyPair { keyId: string; diff --git a/packages/varlock/src/lib/local-encrypt/identity-routing.test.ts b/packages/varlock/src/lib/local-encrypt/identity-routing.test.ts new file mode 100644 index 000000000..de1390936 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/identity-routing.test.ts @@ -0,0 +1,206 @@ +/** + * Payload-version routing in the orchestration layer (index.ts). + * + * Covers both halves of the custody rule: the file backend runs the whole v2 + * flow in TS, and a hardware backend hands the opening to the daemon, because + * unwrapping the identity key here would put it in this process. The daemon side + * of that is exercised in session-decrypt.test.ts; what is pinned here is that + * routing sends it there at all, and never down the in-process path. + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { DEVICE_PAYLOAD_VERSION, IDENTITY_PAYLOAD_VERSION } from './crypto'; + +const testDir = path.join(os.tmpdir(), `varlock-identity-routing-test-${process.pid}`); + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => testDir, +})); + +/** Pretend a native helper is installed, so index.ts detects a hardware backend */ +let pretendNativeBinaryInstalled = false; + +vi.mock('./binary-resolver', () => ({ + resolveNativeBinary: () => (pretendNativeBinaryInstalled ? '/fake/varlock-local-encrypt' : undefined), + getInstalledPlatformPackageName: () => undefined, +})); + +vi.mock('node:child_process', () => ({ + execFileSync: () => JSON.stringify({ + backend: 'secure-enclave', + hardwareBacked: true, + biometricAvailable: true, + keys: ['varlock-default'], + }), + spawn: () => { + throw new Error('unexpected spawn in test'); + }, + spawnSync: () => { + throw new Error('unexpected spawnSync in test'); + }, +})); + +function versionOf(ciphertext: string) { + return Buffer.from(ciphertext, 'base64')[0]; +} + +async function loadLocalEncrypt() { + vi.resetModules(); + return import('./index'); +} + +beforeEach(() => { + fs.mkdirSync(testDir, { recursive: true }); + pretendNativeBinaryInstalled = false; + process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe('file backend', () => { + it('encrypts new values to the identity key (v2)', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.ensureKey(); + + const ciphertext = await localEncrypt.encryptValue('a new secret'); + expect(versionOf(ciphertext)).toBe(IDENTITY_PAYLOAD_VERSION); + expect(fs.existsSync(path.join(testDir, 'identities', 'default.json'))).toBe(true); + }); + + it('round-trips a v2 value', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.ensureKey(); + + const plaintext = 'identity round trip 🔐'; + const ciphertext = await localEncrypt.encryptValue(plaintext); + expect(await localEncrypt.decryptValue(ciphertext)).toBe(plaintext); + }); + + it('reads v2 values written by an earlier process', async () => { + const first = await loadLocalEncrypt(); + await first.ensureKey(); + const ciphertext = await first.encryptValue('persisted secret'); + + const second = await loadLocalEncrypt(); + expect(await second.decryptValue(ciphertext)).toBe('persisted secret'); + }); + + it('reads and writes device-encrypted (v1) values without creating an identity', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.ensureKey(); + + const ciphertext = await localEncrypt.encryptValue('legacy secret', undefined, { target: 'device' }); + expect(versionOf(ciphertext)).toBe(DEVICE_PAYLOAD_VERSION); + expect(fs.existsSync(path.join(testDir, 'identities', 'default.json'))).toBe(false); + expect(await localEncrypt.decryptValue(ciphertext)).toBe('legacy secret'); + }); + + it('rejects a v3 payload as an upgrade problem', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.ensureKey(); + + const buf = Buffer.from(await localEncrypt.encryptValue('secret'), 'base64'); + buf[0] = 0x03; + + await expect(localEncrypt.decryptValue(buf.toString('base64'))) + .rejects.toThrow('unsupported encrypted payload version 3; upgrade varlock'); + }); + + it('ensureEncryptionReady creates both the device key and the identity', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.ensureEncryptionReady(); + + expect(localEncrypt.keyExists()).toBe(true); + expect(fs.existsSync(path.join(testDir, 'identities', 'default.json'))).toBe(true); + }); +}); + +describe('hardware backend', () => { + beforeEach(() => { + pretendNativeBinaryInstalled = true; + delete process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK; + }); + + it('is detected as hardware-backed', async () => { + const localEncrypt = await loadLocalEncrypt(); + const backend = localEncrypt.getBackendInfo(); + expect(backend.type).not.toBe('file'); + expect(backend.hardwareBacked).toBe(true); + }); + + it('hands a v2 payload to the daemon rather than unwrapping it in-process', async () => { + // build the v2 payload on the file backend, then hand it to the hardware one + process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + pretendNativeBinaryInstalled = false; + const fileBacked = await loadLocalEncrypt(); + await fileBacked.ensureKey(); + const ciphertext = await fileBacked.encryptValue('secret'); + expect(versionOf(ciphertext)).toBe(IDENTITY_PAYLOAD_VERSION); + + delete process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK; + pretendNativeBinaryInstalled = true; + const hardwareBacked = await loadLocalEncrypt(); + + // There is no daemon here and no way to start one, so this cannot succeed. + // Failing at the socket is the assertion: it means routing went looking for + // the daemon rather than quietly opening the identity in this process, which + // it could have done, since the wrap on disk is one this key could unwrap. + const err = await hardwareBacked.decryptValue(ciphertext).then(() => undefined, (e) => e); + expect(err).toBeDefined(); + expect(String(err.message)).toMatch(/daemon\.sock|unexpected spawn in test/); + }); + + it('encrypts new values to the identity key, with no daemon involved', async () => { + // an identity this machine already has a wrap for, so nothing has to be + // created; the mocked spawn throwing is what proves no native call happened + process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + pretendNativeBinaryInstalled = false; + const fileBacked = await loadLocalEncrypt(); + await fileBacked.ensureKey(); + await fileBacked.encryptValue('seed the identity'); + + delete process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK; + pretendNativeBinaryInstalled = true; + const hardwareBacked = await loadLocalEncrypt(); + + // encryption is public-key only, so a hardware backend does it right here: + // no spawn, no daemon, no presence check + const ciphertext = await hardwareBacked.encryptValue('secret'); + expect(versionOf(ciphertext)).toBe(IDENTITY_PAYLOAD_VERSION); + }); + + it('allows a re-encryption pass, which is what `encrypt --upgrade` checks', async () => { + await loadLocalEncrypt(); + const { canReEncryptLocally } = await import('./re-encrypt'); + + expect(canReEncryptLocally()).toEqual({ ok: true }); + }); + + it('refuses to add a device wrap to an identity created elsewhere', async () => { + // an identity file that came from another machine: it has a public key and a + // wrap, but not one this device can open + fs.mkdirSync(path.join(testDir, 'identities'), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'identities', 'default.json'), JSON.stringify({ + version: 1, + id: 'default', + publicKey: Buffer.alloc(65, 4).toString('base64'), + wraps: { 'some-other-device-key': 'AQID' }, + createdAt: new Date().toISOString(), + })); + + const localEncrypt = await loadLocalEncrypt(); + const err = await localEncrypt.encryptValue('secret').then(() => undefined, (e) => e); + + // adding a wrap means unwrapping through a key that has one, which would put + // the identity key in this process + expect(err).toBeInstanceOf(localEncrypt.IdentityWrapMissingError); + expect(err.message).toMatch(/created on another device/); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/identity.test.ts b/packages/varlock/src/lib/local-encrypt/identity.test.ts new file mode 100644 index 000000000..e049a68e5 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/identity.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for the identity store (identity.ts). + * + * The device crypto is injected, so these run against a stand-in device key + * rather than a real backend. That is the same seam `index.ts` uses. + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + createKeyPair, encrypt, decrypt, IDENTITY_PAYLOAD_VERSION, +} from './crypto'; + +const testDir = path.join(os.tmpdir(), `varlock-identity-test-${process.pid}`); + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => testDir, +})); + +let identity: typeof import('./identity'); + +/** A device key that behaves like the file backend: real ECIES, v1 payloads */ +async function makeDeviceCrypto() { + const keys = new Map(); + const decryptCalls: Array = []; + + const ensure = async (keyId: string) => { + let pair = keys.get(keyId); + if (!pair) { + pair = await createKeyPair(); + keys.set(keyId, pair); + } + return pair; + }; + + return { + keys, + decryptCalls, + device: { + async encrypt(plaintext: string, keyId: string) { + const pair = await ensure(keyId); + return encrypt(pair.publicKey, plaintext); + }, + async decrypt(ciphertext: string, keyId: string) { + decryptCalls.push(keyId); + const pair = keys.get(keyId); + if (!pair) throw new Error(`Key not found: ${keyId}`); + return decrypt(pair.privateKey, pair.publicKey, ciphertext); + }, + }, + }; +} + +beforeEach(async () => { + fs.mkdirSync(testDir, { recursive: true }); + vi.resetModules(); + identity = await import('./identity'); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe('identity store', () => { + it('creates a default identity on first use', async () => { + const { device } = await makeDeviceCrypto(); + expect(identity.identityExists()).toBe(false); + + const created = await identity.ensureIdentity(device, 'varlock-default'); + + expect(identity.identityExists()).toBe(true); + expect(created.id).toBe('default'); + expect(created.version).toBe(1); + expect(created.publicKey).toBeTruthy(); + expect(Object.keys(created.wraps)).toEqual(['varlock-default']); + expect(created.createdAt).toBeTruthy(); + }); + + it('stores the identity at identities/default.json with mode 0600', async () => { + const { device } = await makeDeviceCrypto(); + await identity.ensureIdentity(device, 'varlock-default'); + + const filePath = identity.getIdentityFilePath(); + expect(filePath).toBe(path.join(testDir, 'identities', 'default.json')); + + // permission bits are the last three octal digits of the mode + const permissions = fs.statSync(filePath).mode.toString(8).slice(-3); + expect(permissions).toBe('600'); + }); + + it('never stores the private key in the clear', async () => { + const { device } = await makeDeviceCrypto(); + await identity.ensureIdentity(device, 'varlock-default'); + + const raw = fs.readFileSync(identity.getIdentityFilePath(), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.privateKey).toBeUndefined(); + + // the wrap is a device-key (v1) payload, not the raw key + const wrapBytes = Buffer.from(parsed.wraps['varlock-default'], 'base64'); + expect(wrapBytes[0]).toBe(0x01); + }); + + it('is idempotent: a second ensure reuses the same identity', async () => { + const { device } = await makeDeviceCrypto(); + const first = await identity.ensureIdentity(device, 'varlock-default'); + const second = await identity.ensureIdentity(device, 'varlock-default'); + expect(second.publicKey).toBe(first.publicKey); + expect(second.createdAt).toBe(first.createdAt); + }); + + it('round-trips wrap → unwrap → decrypt', async () => { + const { device } = await makeDeviceCrypto(); + const plaintext = 'a secret held by the identity'; + + const ciphertext = await identity.encryptToIdentity(device, plaintext, 'varlock-default'); + expect(Buffer.from(ciphertext, 'base64')[0]).toBe(IDENTITY_PAYLOAD_VERSION); + + identity.clearUnwrappedIdentityCache(); + expect(await identity.decryptWithIdentity(device, ciphertext)).toBe(plaintext); + }); + + it('reads back an identity written by a previous process', async () => { + const { device } = await makeDeviceCrypto(); + const ciphertext = await identity.encryptToIdentity(device, 'persisted', 'varlock-default'); + + // fresh module instance: nothing cached in memory + vi.resetModules(); + const reloaded = await import('./identity'); + expect(await reloaded.decryptWithIdentity(device, ciphertext)).toBe('persisted'); + }); + + it('caches the unwrapped private key so repeated decrypts hit the device once', async () => { + const { device, decryptCalls } = await makeDeviceCrypto(); + const ct1 = await identity.encryptToIdentity(device, 'one', 'varlock-default'); + const ct2 = await identity.encryptToIdentity(device, 'two', 'varlock-default'); + identity.clearUnwrappedIdentityCache(); + + expect(await identity.decryptWithIdentity(device, ct1)).toBe('one'); + expect(await identity.decryptWithIdentity(device, ct2)).toBe('two'); + expect(decryptCalls).toEqual(['varlock-default']); + }); + + it('adds a wrap for a second device key without changing the identity', async () => { + const { device } = await makeDeviceCrypto(); + const first = await identity.ensureIdentity(device, 'varlock-default'); + const ciphertext = await identity.encryptToIdentity(device, 'shared', 'varlock-default'); + + const updated = await identity.ensureIdentity(device, 'second-device'); + expect(updated.publicKey).toBe(first.publicKey); + expect(Object.keys(updated.wraps).sort()).toEqual(['second-device', 'varlock-default']); + + // the value still opens, and it opens from the new wrap too + identity.clearUnwrappedIdentityCache(); + expect(await identity.decryptWithIdentity(device, ciphertext)).toBe('shared'); + }); + + it('fails clearly when no identity exists for a v2 payload', async () => { + const { device } = await makeDeviceCrypto(); + const ciphertext = await identity.encryptToIdentity(device, 'secret', 'varlock-default'); + + fs.rmSync(identity.getIdentityFilePath()); + identity.clearUnwrappedIdentityCache(); + + await expect(identity.decryptWithIdentity(device, ciphertext)) + .rejects.toThrow(identity.IdentityNotFoundError); + }); + + it('fails clearly when no device key can unwrap the identity', async () => { + const { device, keys } = await makeDeviceCrypto(); + const ciphertext = await identity.encryptToIdentity(device, 'secret', 'varlock-default'); + + keys.delete('varlock-default'); + identity.clearUnwrappedIdentityCache(); + + await expect(identity.decryptWithIdentity(device, ciphertext)) + .rejects.toThrow(/Unable to unwrap identity "default"/); + }); + + it('rejects an identity file from a newer varlock', async () => { + const { device } = await makeDeviceCrypto(); + await identity.ensureIdentity(device, 'varlock-default'); + + const filePath = identity.getIdentityFilePath(); + const stored = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + stored.version = 2; + fs.writeFileSync(filePath, JSON.stringify(stored)); + + expect(() => identity.readIdentity()).toThrow('unsupported identity file version 2; upgrade varlock'); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/identity.ts b/packages/varlock/src/lib/local-encrypt/identity.ts new file mode 100644 index 000000000..2e1e6da18 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/identity.ts @@ -0,0 +1,368 @@ +/** + * Identity keys for local encryption. + * + * Values used to be encrypted straight to the device key (Secure Enclave / TPM / + * file). That ties every value to one machine and, on macOS, to the enclave's + * 5-minute biometric reuse window. An identity key sits in between: + * + * device key -> identity key -> values + * + * The identity is a software P-256 key pair. Its private key is never stored in + * the clear: it is wrapped (ECIES) to one or more device keys, so unwrapping it + * goes through whatever gate the device backend applies. Values are then + * encrypted to the identity public key as v2 payloads. + * + * Custody rule: for hardware backends the identity private key must never be + * *unwrapped* in this process, so only the native daemon may open v2 payloads. + * Creating an identity is different: the key pair is born here, wrapped to the + * device key straight away, and the private half is dropped without ever being + * cached. Callers say which of the two they are allowed to do via + * `allowInProcessUnwrap`. + * + * Nothing here ever touches project files. Identities live in user-level state + * at `/identities/.json`, mode 0600. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getUserVarlockDir } from '../user-config-dir'; +import { + createKeyPair, decrypt, encrypt, IDENTITY_PAYLOAD_VERSION, +} from './crypto'; + +const IDENTITY_STORE_SUBDIR = 'identities'; + +/** Format version of the identity file itself (not the payload version) */ +export const IDENTITY_FILE_VERSION = 1; + +/** Identity used when a caller does not name one */ +export const DEFAULT_IDENTITY_ID = 'default'; + +export interface StoredIdentity { + version: number; + id: string; + /** Base64-encoded uncompressed P-256 public key */ + publicKey: string; + /** + * The identity private key, encrypted to each device key that is allowed to + * unwrap it, keyed by device key id. + */ + wraps: Record; + createdAt: string; +} + +/** + * The device-key crypto the identity layer builds on. + * + * Injected rather than imported so this module stays a leaf: `index.ts` owns + * backend routing and imports this, not the other way around. + */ +export interface DeviceCrypto { + encrypt(plaintext: string, keyId: string): Promise; + decrypt(ciphertext: string, keyId: string): Promise; +} + +/** + * Thrown when a v2 payload turns up somewhere that has no way to open one. + * + * Every backend can now, with one exception: WSL reaches the Windows daemon by + * running the helper .exe per call, and each of those runs is its own session, + * so an unlock session cannot be held across them. + */ +export class IdentityBackendUnsupportedError extends Error { + constructor(public backendType: string) { + super( + `Identity-encrypted values cannot be opened from WSL (${backendType} backend). ` + + 'The Windows daemon is reached one process at a time from here, so an unlock ' + + 'session cannot be held open across calls.', + ); + this.name = 'IdentityBackendUnsupportedError'; + } +} + +/** + * Thrown when an identity exists but carries no wrap this machine can open, and + * the caller is not allowed to make one by unwrapping in-process. + * + * Adding a wrap means holding the identity private key here, which is exactly + * what hardware backends exist to prevent, so it is refused rather than done + * quietly. + */ +export class IdentityWrapMissingError extends Error { + constructor(identityId: string, deviceKeyId: string) { + super( + `Identity "${identityId}" has no wrap for this machine's key "${deviceKeyId}", and ` + + 'adding one would mean holding the identity key outside the secure hardware. ' + + 'This identity was created on another device.', + ); + this.name = 'IdentityWrapMissingError'; + } +} + +/** Thrown when a v2 payload needs an identity this machine does not have */ +export class IdentityNotFoundError extends Error { + constructor(identityId: string) { + super(`No local identity "${identityId}" found to decrypt this value`); + this.name = 'IdentityNotFoundError'; + } +} + +// ── Storage ──────────────────────────────────────────────────────────── + +function getIdentityStorePath(): string { + return path.join(getUserVarlockDir(), IDENTITY_STORE_SUBDIR); +} + +export function getIdentityFilePath(identityId: string = DEFAULT_IDENTITY_ID): string { + return path.join(getIdentityStorePath(), `${identityId}.json`); +} + +export function identityExists(identityId: string = DEFAULT_IDENTITY_ID): boolean { + return fs.existsSync(getIdentityFilePath(identityId)); +} + + +export function readIdentity(identityId: string = DEFAULT_IDENTITY_ID): StoredIdentity | undefined { + const filePath = getIdentityFilePath(identityId); + if (!fs.existsSync(filePath)) return undefined; + + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial; + if (parsed.version !== IDENTITY_FILE_VERSION) { + throw new Error(`unsupported identity file version ${parsed.version}; upgrade varlock`); + } + if (!parsed.publicKey || !parsed.wraps || typeof parsed.wraps !== 'object') { + throw new Error(`Invalid identity file format for identity: ${identityId}`); + } + return { + version: parsed.version, + id: parsed.id || identityId, + publicKey: parsed.publicKey, + wraps: parsed.wraps, + createdAt: parsed.createdAt || new Date().toISOString(), + }; +} + +function serializeIdentity(identity: StoredIdentity) { + return `${JSON.stringify(identity, null, 2)}\n`; +} + +/** + * Write a brand-new identity file, refusing to clobber one that already exists. + * + * Two varlock processes can reach first use at the same moment. Without the + * exclusive create the loser would overwrite the winner's identity, orphaning + * every value the winner had just encrypted. Returns false when someone else + * got there first, and the caller adopts their identity instead. + */ +function tryWriteNewIdentity(identity: StoredIdentity): boolean { + fs.mkdirSync(getIdentityStorePath(), { recursive: true, mode: 0o700 }); + try { + fs.writeFileSync( + getIdentityFilePath(identity.id), + serializeIdentity(identity), + { mode: 0o600, flag: 'wx' }, + ); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw err; + } +} + +/** + * Add a wrap to an identity that already exists on disk. + * + * Re-reads immediately before writing so a wrap another process added in the + * meantime survives. + */ +function addWrapToIdentity(identityId: string, deviceKeyId: string, wrapped: string): StoredIdentity { + const current = readIdentity(identityId); + if (!current) throw new IdentityNotFoundError(identityId); + current.wraps[deviceKeyId] = wrapped; + fs.writeFileSync(getIdentityFilePath(identityId), serializeIdentity(current), { mode: 0o600 }); + return current; +} + +// ── Unwrapped key cache ──────────────────────────────────────────────── + +/** + * Unwrapped identity private keys, per identity + device key, for this process. + * + * Without this every decrypt in a load would re-run a device-key unwrap. The + * key is only ever held here on the file backend, where the device key it was + * wrapped to is itself a plaintext file, so this does not weaken anything. + * Hardware backends pass `allowInProcessUnwrap: false` and never populate it. + */ +const unwrappedPrivateKeys = new Map(); + +function cacheKeyFor(identityId: string, deviceKeyId: string) { + return `${identityId}\u0000${deviceKeyId}`; +} + +/** Drop any unwrapped private keys held for this process (tests, and lock flows) */ +export function clearUnwrappedIdentityCache() { + unwrappedPrivateKeys.clear(); +} + +// ── Identity lifecycle ───────────────────────────────────────────────── + +/** + * Create a new identity whose private key is wrapped to the given device key. + * Returns undefined when another process created one first. + * + * The key pair is generated here on every backend. That is not a custody + * problem: it is wrapped to the device key immediately, and on backends that + * may not hold it the private half is dropped rather than cached, so the only + * way back to it afterwards is through the device key's gate. + */ +async function createIdentity( + device: DeviceCrypto, + deviceKeyId: string, + identityId: string, + allowInProcessUnwrap: boolean, +): Promise { + const keyPair = await createKeyPair(); + const wrapped = await device.encrypt(keyPair.privateKey, deviceKeyId); + + const identity: StoredIdentity = { + version: IDENTITY_FILE_VERSION, + id: identityId, + publicKey: keyPair.publicKey, + wraps: { [deviceKeyId]: wrapped }, + createdAt: new Date().toISOString(), + }; + if (!tryWriteNewIdentity(identity)) return undefined; + + if (allowInProcessUnwrap) { + unwrappedPrivateKeys.set(cacheKeyFor(identityId, deviceKeyId), keyPair.privateKey); + } + return identity; +} + +/** + * Unwrap the identity private key using any device key this machine can reach. + * Tries the wraps in file order, so a machine that has picked up wraps from + * several devices still opens on the one it actually holds. + */ +async function unwrapIdentityPrivateKey( + device: DeviceCrypto, + identity: StoredIdentity, + identityId: string, +): Promise { + const wrapEntries = Object.entries(identity.wraps); + if (wrapEntries.length === 0) { + throw new Error(`Identity "${identityId}" has no wrapped private key`); + } + + let lastError: unknown; + for (const [deviceKeyId, wrapped] of wrapEntries) { + const cached = unwrappedPrivateKeys.get(cacheKeyFor(identityId, deviceKeyId)); + if (cached) return cached; + try { + const privateKey = await device.decrypt(wrapped, deviceKeyId); + unwrappedPrivateKeys.set(cacheKeyFor(identityId, deviceKeyId), privateKey); + return privateKey; + } catch (err) { + lastError = err; + } + } + + throw new Error( + `Unable to unwrap identity "${identityId}" with any device key on this machine`, + { cause: lastError }, + ); +} + +async function resolveIdentity( + device: DeviceCrypto, + deviceKeyId: string, + identityId: string, + allowInProcessUnwrap: boolean, +): Promise { + let existing = readIdentity(identityId); + + if (!existing) { + const created = await createIdentity(device, deviceKeyId, identityId, allowInProcessUnwrap); + if (created) return created; + // someone else created one between our read and our write: adopt theirs, + // and fall through so it picks up a wrap for our device key if it needs one + existing = readIdentity(identityId); + if (!existing) throw new IdentityNotFoundError(identityId); + } + + if (existing.wraps[deviceKeyId]) return existing; + + // Adding a wrap means opening the identity with a device key that already has + // one, which puts the private key in this process. Backends that must not + // hold it say so instead of doing it. + if (!allowInProcessUnwrap) throw new IdentityWrapMissingError(identityId, deviceKeyId); + + const privateKey = await unwrapIdentityPrivateKey(device, existing, identityId); + const wrapped = await device.encrypt(privateKey, deviceKeyId); + const updated = addWrapToIdentity(identityId, deviceKeyId, wrapped); + unwrappedPrivateKeys.set(cacheKeyFor(identityId, deviceKeyId), privateKey); + return updated; +} + +/** In-flight ensures, so concurrent first-use callers share one creation */ +const pendingEnsures = new Map>(); + +/** + * Load the identity, creating it on first use, and make sure it carries a wrap + * for the given device key. + * + * Adding a wrap for a device key that has none requires unwrapping through a + * device key that does, which is the same trust step as any other decrypt. + * + * Concurrent callers share a single ensure. A batch of values encrypted at once + * on a fresh machine would otherwise each generate their own identity, and all + * but the last would end up encrypted to a key nothing can unwrap. + */ +export async function ensureIdentity( + device: DeviceCrypto, + deviceKeyId: string, + identityId: string = DEFAULT_IDENTITY_ID, + opts?: { allowInProcessUnwrap?: boolean }, +): Promise { + const allowInProcessUnwrap = opts?.allowInProcessUnwrap ?? true; + const pendingKey = cacheKeyFor(identityId, deviceKeyId); + const pending = pendingEnsures.get(pendingKey); + if (pending) return pending; + + const ensuring = resolveIdentity(device, deviceKeyId, identityId, allowInProcessUnwrap) + .finally(() => pendingEnsures.delete(pendingKey)); + pendingEnsures.set(pendingKey, ensuring); + return ensuring; +} + +// ── Encrypt / Decrypt ────────────────────────────────────────────────── + +/** + * Encrypt a value to the identity public key, producing a v2 payload. + * + * Encryption is public-key only, so this runs the same way on every backend and + * never needs the daemon, a grant, or a presence check. + */ +export async function encryptToIdentity( + device: DeviceCrypto, + plaintext: string, + deviceKeyId: string, + identityId: string = DEFAULT_IDENTITY_ID, + opts?: { allowInProcessUnwrap?: boolean }, +): Promise { + const identity = await ensureIdentity(device, deviceKeyId, identityId, opts); + return encrypt(identity.publicKey, plaintext, { version: IDENTITY_PAYLOAD_VERSION }); +} + +/** Decrypt a v2 payload by unwrapping the identity private key first. */ +export async function decryptWithIdentity( + device: DeviceCrypto, + ciphertext: string, + identityId: string = DEFAULT_IDENTITY_ID, +): Promise { + const identity = readIdentity(identityId); + if (!identity) throw new IdentityNotFoundError(identityId); + + const privateKey = await unwrapIdentityPrivateKey(device, identity, identityId); + return decrypt(privateKey, identity.publicKey, ciphertext); +} diff --git a/packages/varlock/src/lib/local-encrypt/index.ts b/packages/varlock/src/lib/local-encrypt/index.ts index cfa168862..5aed1d7f6 100644 --- a/packages/varlock/src/lib/local-encrypt/index.ts +++ b/packages/varlock/src/lib/local-encrypt/index.ts @@ -13,14 +13,35 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import { resolveNativeBinary, getInstalledPlatformPackageName } from './binary-resolver'; +import { DEFAULT_KEY_ID } from './constants'; +import { assertSupportedPayloadVersion, IDENTITY_PAYLOAD_VERSION, readPayloadVersion } from './crypto'; import { DaemonClient } from './daemon-client'; import * as fileBackend from './file-backend'; +import * as identity from './identity'; +import { + clearKnownGrants, decryptIdentityPayloadsViaDaemon, type IdentityPayloadRequest, +} from './session-decrypt'; import { isWSL } from './wsl-detect'; -import type { BackendInfo, BackendType, NativeStatusResult } from './types'; - -export type { BackendInfo, BackendType } from './types'; - -const DEFAULT_KEY_ID = 'varlock-default'; +import type { + BackendInfo, BackendType, InvalidateSessionRequest, NativeKeyDetail, NativeStatusResult, + SessionGrantInfo, UnlockDisplayInfo, +} from './types'; + +export type { + BackendInfo, BackendType, NativeKeyDetail, SessionGrantInfo, UnlockDisplayInfo, +} from './types'; + +export { DEFAULT_KEY_ID }; +export { + IdentityBackendUnsupportedError, IdentityNotFoundError, IdentityWrapMissingError, +} from './identity'; +export { UnlockDeclinedError, UnlockNoUiError } from './session-decrypt'; +export { StaleDaemonError } from './daemon-client'; +export { + clearDeclaredCacheInventories, clearUnlockInventory, declareCacheInventory, + declareEncryptedFileValues, unlockInventoryForKey, unlockItemsForKey, + type DeclaredEncryptedValue, +} from './unlock-inventory'; /** Debug logger — prints to stderr when VARLOCK_DEBUG is set */ function debug(msg: string) { @@ -29,6 +50,9 @@ function debug(msg: string) { } } +/** The same debug logger, for the sibling modules that make up this layer */ +export const debugLog = debug; + const SHELL_RUNNER_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'fish', 'ksh', 'csh', 'tcsh']); const VARLOCK_LAUNCHER_NAMES = new Set(['varlock', 'varlock.exe', 'varlock.cmd']); const PACKAGE_MANAGER_RUNNER_NAMES = new Set(['bun', 'node', 'npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'yarnpkg']); @@ -275,6 +299,13 @@ function runNativeBinary(args: Array, opts?: { timeout?: number; sensiti const output = execFileSync(binaryPath, args, { encoding: 'utf-8', timeout: opts?.timeout ?? 30_000, + // Passed explicitly rather than left to inherit. Under Bun, which is what + // the compiled varlock binary runs on, a child with no `env` gets the env + // this process *started* with, so anything set at runtime (XDG_CONFIG_HOME, + // HOME) would be invisible to the helper and it would read a different key + // store than the one this process is using. Node inherits the live env, so + // being explicit is what makes the two agree. + env: process.env, }).trim(); debug(`runNativeBinary result: ${opts?.sensitiveOutput ? `<${output.length} chars>` : output.slice(0, 200)}`); return output; @@ -297,7 +328,8 @@ function spawnNativeBinaryAsync( const timeoutMs = opts.timeout ?? 30_000; return new Promise((resolve, reject) => { debug(`spawnNativeBinaryAsync: ${binaryPath} ${redactDataArg(args).join(' ')}`); - const proc = spawn(binaryPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + // env passed explicitly for the same reason as in runNativeBinary above + const proc = spawn(binaryPath, args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env }); let stdout = ''; let stderr = ''; let settled = false; @@ -360,6 +392,8 @@ function runNativeBinaryJson>( let cachedBackendInfo: BackendInfo | undefined; /** Keys reported by the status command — avoids a separate key-exists .exe spawn on WSL2 */ let cachedStatusKeys: Array | undefined; +/** Per-key metadata reported by the status command (binaries that predate it omit it) */ +let cachedKeyDetails: Array | undefined; function detectBackendType(): { type: BackendType; isFileFallback: boolean } { const binaryPath = resolveNativeBinary(); @@ -393,7 +427,13 @@ export function getBackendInfo(): BackendInfo { try { const status = runNativeBinaryJson(['status']); debug(`getBackendInfo: status result: hardwareBacked=${status.hardwareBacked}, biometricAvailable=${status.biometricAvailable}, backend=${status.backend}, keys=${status.keys?.join(',')}`); + // keyDetails decides whether a decrypt goes through the daemon at all, so + // a helper that does not report it is worth seeing in a debug log + debug(`getBackendInfo: keyDetails=${status.keyDetails + ? status.keyDetails.map((d) => `${d.keyId}:requireAuth=${d.requireAuth}`).join(',') + : ''}`); cachedStatusKeys = status.keys; + cachedKeyDetails = status.keyDetails; cachedBackendInfo = { type, platform: process.platform, @@ -472,6 +512,29 @@ export function keyExists(keyId: string = DEFAULT_KEY_ID): boolean { return result.exists; } +/** + * Whether decrypts of this key should require user-presence verification + * (on machines that have a gate at all). + * + * Three cases, and all three are pinned by tests: + * + * - the binary reports no `keyDetails` at all (an older native helper): true, + * because prompting when we did not need to is the safe way to be wrong + * - the key was created with `--no-auth` (CI and headless hosts): false, so it + * takes the one-shot non-interactive path with no daemon and no session + * - anything else, including `--auth-every-time` keys: true + * + * The `--no-auth` case is the one that changed: those keys used to be routed + * through the daemon like every other key, because no binary reported the flag. + */ +export function keyRequiresAuth(keyId: string): boolean { + // the per-key metadata arrives with the backend probe, so make sure that has + // happened: without this the answer depends on whether something else ran first + getBackendInfo(); + const detail = cachedKeyDetails?.find((d) => d.keyId === keyId); + return detail?.requireAuth ?? true; +} + /** Generate a new encryption key. */ export async function generateKey(keyId: string = DEFAULT_KEY_ID): Promise<{ keyId: string; publicKey: string }> { const backend = getBackendInfo(); @@ -479,7 +542,12 @@ export async function generateKey(keyId: string = DEFAULT_KEY_ID): Promise<{ key warnIfFileFallback(backend); return fileBackend.generateKey(keyId); } - return runNativeBinaryJson<{ keyId: string; publicKey: string }>(['generate-key', '--key-id', keyId]); + const result = runNativeBinaryJson<{ keyId: string; publicKey: string }>(['generate-key', '--key-id', keyId]); + // keep the status-derived caches coherent so lookups later in this same + // process (keyExists, decrypt routing) see the new key + cachedStatusKeys?.push(keyId); + cachedKeyDetails?.push({ keyId, requireAuth: true }); + return result; } /** Ensure a key exists, generating one if necessary. */ @@ -492,12 +560,12 @@ export async function ensureKey(keyId: string = DEFAULT_KEY_ID): Promise { // ── Encrypt / Decrypt ────────────────────────────────────────────────── /** - * Encrypt a plaintext value. + * Encrypt directly to the device key, producing a v1 payload. * * For hardware-backed backends, encryption uses the public key only (no biometric needed). * For file-based backend, uses the pure JS ECIES implementation. */ -export async function encryptValue(plaintext: string, keyId: string = DEFAULT_KEY_ID): Promise { +async function encryptToDeviceKey(plaintext: string, keyId: string = DEFAULT_KEY_ID): Promise { const backend = getBackendInfo(); if (backend.type === 'file') { warnIfFileFallback(backend); @@ -520,13 +588,13 @@ export async function encryptValue(plaintext: string, keyId: string = DEFAULT_KE } /** - * Decrypt a ciphertext value. + * Decrypt a v1 (device-direct) ciphertext value. * * For biometric-enabled backends (macOS Secure Enclave, Windows Hello), * uses the daemon client for session caching (avoids repeated biometric prompts). * For file-based backend, uses the pure JS ECIES implementation. */ -export async function decryptValue(ciphertext: string, keyId: string = DEFAULT_KEY_ID): Promise { +async function decryptWithDeviceKey(ciphertext: string, keyId: string = DEFAULT_KEY_ID): Promise { const backend = getBackendInfo(); if (backend.type === 'file') { debug('decryptValue: using file backend'); @@ -534,9 +602,11 @@ export async function decryptValue(ciphertext: string, keyId: string = DEFAULT_K return fileBackend.decryptValue(ciphertext, keyId); } - // Use daemon client for biometric backends (session caching) + // Use daemon client for biometric backends (session caching). + // A key whose metadata opts out of the presence gate takes the one-shot path + // below instead. No binary reports that today, so every key gates as before. // In WSL2, the .exe handles daemon management internally via --via-daemon - if (backend.biometricAvailable) { + if (backend.biometricAvailable && keyRequiresAuth(keyId)) { if (isWSL()) { debug('decryptValue: WSL2 biometric decrypt via --via-daemon'); const binaryPath = resolveNativeBinary(); @@ -600,7 +670,8 @@ export async function decryptValue(ciphertext: string, keyId: string = DEFAULT_K return client.decrypt(ciphertext, keyId); } - // Non-biometric native backend (e.g., Linux TPM without polkit) — one-shot + // One-shot decrypt: non-biometric native backend (e.g. Linux TPM without + // polkit), or a key that opted out of the presence gate debug('decryptValue: non-biometric one-shot decrypt'); const result = runNativeBinaryJson<{ plaintext: string }>( ['decrypt', '--key-id', keyId, '--data', ciphertext], @@ -609,17 +680,224 @@ export async function decryptValue(ciphertext: string, keyId: string = DEFAULT_K return result.plaintext; } +// ── Identity routing ─────────────────────────────────────────────────── + +/** + * Device-key crypto handed to the identity layer, which uses it to wrap and + * unwrap the identity private key. Always v1, never identity-routed: the wrap + * is what makes identity payloads readable in the first place. + */ +const deviceCrypto: identity.DeviceCrypto = { + encrypt: (plaintext, keyId) => encryptToDeviceKey(plaintext, keyId), + decrypt: (ciphertext, keyId) => decryptWithDeviceKey(ciphertext, keyId), +}; + +/** + * Whether this process is allowed to unwrap the identity private key itself. + * + * True only on the file backend, where the device key guarding the wrap is a + * plaintext file anyway, so routing through a daemon would protect nothing. On + * every hardware backend the daemon does the unwrapping and the key never + * reaches V8. + */ +function mayUnwrapIdentityInProcess(): boolean { + return getBackendInfo().type === 'file'; +} + +function identityOpts() { + return { allowInProcessUnwrap: mayUnwrapIdentityInProcess() }; +} + /** - * Invalidate the biometric session, requiring re-authentication for next decrypt. - * Connects to the running daemon without spawning one (varlock lock runs in a separate process). + * Whether v2 payloads can be opened at all here. + * + * WSL is the one place they cannot. It reaches the Windows daemon by running the + * helper .exe once per call, and each of those runs is its own session, so there + * is no session for a grant to belong to. Writes there stay on v1 so a WSL + * machine never produces a value it cannot read back. */ -export async function lockSession(): Promise { +export function canUseIdentityEncryption(): boolean { + return !isWSL(); +} + +/** + * Whether new values should be encrypted to the identity key (v2) rather than + * straight to the device key (v1). + * + * Every backend that can read a v2 payload also writes them. Encryption itself + * is public-key only, so it needs no daemon, no grant and no presence check on + * any backend: what decides this is purely whether reading back would work. + */ +function shouldEncryptToIdentity(): boolean { + return canUseIdentityEncryption(); +} + +/** + * Make sure everything needed to encrypt is in place: the device key, plus the + * identity when this backend encrypts to one. + */ +export async function ensureEncryptionReady(keyId: string = DEFAULT_KEY_ID): Promise { + await ensureKey(keyId); + if (shouldEncryptToIdentity()) { + await identity.ensureIdentity(deviceCrypto, keyId, undefined, identityOpts()); + } +} + +/** + * The public key new values are encrypted to, creating the identity if this is + * its first use. Undefined when this machine writes v1 payloads. + * + * Callers that hand encryption to the daemon (the secure input dialog) need the + * recipient without doing the encrypting themselves. + */ +export async function getEncryptionIdentityPublicKey( + keyId: string = DEFAULT_KEY_ID, +): Promise { + if (!shouldEncryptToIdentity()) return undefined; + const stored = await identity.ensureIdentity(deviceCrypto, keyId, undefined, identityOpts()); + return stored.publicKey; +} + +/** + * Encrypt a plaintext value. + * + * Routes to the identity key where that backend supports it, and to the device + * key otherwise. Pass `target: 'device'` to force a v1 payload. + */ +export async function encryptValue( + plaintext: string, + keyId: string = DEFAULT_KEY_ID, + opts?: { target?: 'auto' | 'device' }, +): Promise { + if (opts?.target !== 'device' && shouldEncryptToIdentity()) { + debug('encryptValue: encrypting to identity key (v2)'); + return identity.encryptToIdentity(deviceCrypto, plaintext, keyId, undefined, identityOpts()); + } + return encryptToDeviceKey(plaintext, keyId); +} + +/** + * Open identity-encrypted (v2) payloads as one group. + * + * This is the batched entry point, and the one callers resolving a whole env + * file should reach for: on a hardware backend the whole group costs a single + * unlock, where the same payloads opened one at a time could cost one each. + * Results come back in the order they were passed in. + */ +export async function decryptIdentityPayloads( + payloads: Array, + opts?: { display?: UnlockDisplayInfo }, +): Promise> { + if (payloads.length === 0) return []; + for (const payload of payloads) assertSupportedPayloadVersion(payload.ciphertext); + + const backend = getBackendInfo(); + + if (backend.type === 'file') { + debug(`decryptIdentityPayloads: ${payloads.length} payload(s) via the file backend`); + warnIfFileFallback(backend); + const plaintexts: Array = []; + for (const payload of payloads) { + plaintexts.push(await identity.decryptWithIdentity(deviceCrypto, payload.ciphertext)); + } + return plaintexts; + } + + if (!canUseIdentityEncryption()) { + throw new identity.IdentityBackendUnsupportedError(backend.type); + } + + debug(`decryptIdentityPayloads: ${payloads.length} payload(s) via the daemon session`); + return decryptIdentityPayloadsViaDaemon(getDaemonClient(), payloads, opts); +} + +/** + * Decrypt a ciphertext value, routing on the payload version byte. + * + * v1 payloads go to the device key exactly as they always have. v2 payloads go + * through the identity: in-process on the file backend, and through the daemon's + * unlock session everywhere else. Decrypting a single value is just a batch of + * one, so callers with several should use `decryptIdentityPayloads` instead and + * pay for one unlock rather than one per value. + * + * `display` is what the unlock panel says this decrypt is for. Pass it: a + * caller that does not is a caller the panel has to describe as "something", + * and a person cannot approve something the panel cannot name. + */ +export async function decryptValue( + ciphertext: string, + keyId: string = DEFAULT_KEY_ID, + opts?: { display?: UnlockDisplayInfo }, +): Promise { + // checked here rather than per-backend so payloads from a newer varlock fail + // the same way everywhere, including on the native binary paths + assertSupportedPayloadVersion(ciphertext); + + if (readPayloadVersion(ciphertext) === IDENTITY_PAYLOAD_VERSION) { + debug('decryptValue: identity-encrypted payload (v2)'); + const [plaintext] = await decryptIdentityPayloads([{ ciphertext, keyId }], opts); + return plaintext; + } + + return decryptWithDeviceKey(ciphertext, keyId); +} + +/** + * Invalidate unlock sessions, so the next decrypt has to ask again. + * + * With no target this drops everything the daemon is holding, as it always has. + * `sessionId` drops one session's grants; the caller's own session is named by + * passing no id and letting the daemon resolve it from the connection, which is + * what `varlock lock --current` does. + * + * Connects to a running daemon without spawning one: locking a daemon that is + * not there is already the state the user asked for. + */ +export async function lockSession(target?: InvalidateSessionRequest): Promise { + // an unwrapped identity key held in this process outlives a daemon lock, so + // drop it too, along with the grants this process thought it had + identity.clearUnwrappedIdentityCache(); + clearKnownGrants(); + const backend = getBackendInfo(); - if (!backend.biometricAvailable) return; + if (!backend.biometricAvailable) return 0; const client = getDaemonClient(); const connected = await client.tryConnect(); if (!connected) { throw new Error('No encryption daemon is running'); } - await client.invalidateSession(); + const result = await client.invalidateSession(target); + return result.invalidated; +} + +/** + * The session id the daemon resolves for this process. + * + * Derived by the daemon from the connection, never claimed by us, so it is the + * one way to name "my own session" without being able to name anyone else's. + * Undefined when no daemon is running or it could not place this caller. + */ +export async function getCurrentSessionId(): Promise { + const backend = getBackendInfo(); + if (!backend.biometricAvailable) return undefined; + const client = getDaemonClient(); + const connected = await client.tryConnect(); + if (!connected) return undefined; + return (await client.ping()).sessionId; +} + +/** + * Every unlock session the daemon is currently holding. + * + * Connects without spawning: a daemon that is not running holds nothing, which + * is an empty list rather than an error. + */ +export async function listSessions(): Promise> { + const backend = getBackendInfo(); + if (!backend.biometricAvailable) return []; + const client = getDaemonClient(); + const connected = await client.tryConnect(); + if (!connected) return []; + const result = await client.listSessions(); + return result.sessions; } diff --git a/packages/varlock/src/lib/local-encrypt/key-auth-routing.test.ts b/packages/varlock/src/lib/local-encrypt/key-auth-routing.test.ts new file mode 100644 index 000000000..adb03a5fa --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/key-auth-routing.test.ts @@ -0,0 +1,148 @@ +/** + * How per-key `requireAuth` metadata decides where a v1 decrypt goes. + * + * The native helpers report a `keyDetails` array from `status`. This is the + * matrix of what that array does, and it is a routing decision with teeth: a key + * reported as needing no presence check stops going through the daemon at all + * and takes the one-shot path instead, which is what makes unattended CI hosts + * work without a session. + * + * The `--no-auth` row is the one that changed. Those keys used to be routed + * through the daemon like every other key, because no helper reported the flag, + * so nothing could tell them apart. + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { FakeDaemonHarness } from './test/fake-daemon-harness'; +import type { NativeKeyDetail } from './types'; + +let harness: FakeDaemonHarness; +let statusKeyDetails: Array | undefined; +/** args of every one-shot native binary call, so the non-daemon path is visible */ +let oneShotCalls: Array>; + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => harness.userVarlockDir, +})); + +vi.mock('./binary-resolver', () => ({ + resolveNativeBinary: () => harness.binaryPath, + getInstalledPlatformPackageName: () => undefined, +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: (_binary: string, args: Array) => { + if (args[0] === 'status') { + return JSON.stringify({ + backend: 'secure-enclave', + hardwareBacked: true, + biometricAvailable: true, + keys: ['gated-key', 'no-auth-key', 'every-time-key'], + ...(statusKeyDetails ? { keyDetails: statusKeyDetails } : {}), + }); + } + oneShotCalls.push(args); + return JSON.stringify({ plaintext: 'one-shot' }); + }, + }; +}); + +async function loadLocalEncrypt() { + vi.resetModules(); + return import('./index'); +} + +/** A device-encrypted (v1) payload, which is what this routing applies to */ +function v1Payload(marker: string): string { + return Buffer.concat([Buffer.from([0x01]), Buffer.from(marker, 'utf-8')]).toString('base64'); +} + +beforeEach(() => { + harness = new FakeDaemonHarness(); + harness.setConfig({ plaintexts: { [v1Payload('value')]: 'via-daemon' } }); + statusKeyDetails = undefined; + oneShotCalls = []; +}); + +afterEach(() => { + harness.cleanup(); +}); + +describe.skipIf(process.platform === 'win32')('keyRequiresAuth', () => { + it('defaults to true when the helper reports no keyDetails at all', async () => { + // an older native helper, which cannot tell us either way + statusKeyDetails = undefined; + const localEncrypt = await loadLocalEncrypt(); + expect(localEncrypt.keyRequiresAuth('gated-key')).toBe(true); + expect(localEncrypt.keyRequiresAuth('anything-really')).toBe(true); + }); + + it('is false for a --no-auth key', async () => { + statusKeyDetails = [{ keyId: 'no-auth-key', requireAuth: false }]; + const localEncrypt = await loadLocalEncrypt(); + expect(localEncrypt.keyRequiresAuth('no-auth-key')).toBe(false); + }); + + it('is true for an --auth-every-time key', async () => { + statusKeyDetails = [{ keyId: 'every-time-key', requireAuth: true }]; + const localEncrypt = await loadLocalEncrypt(); + expect(localEncrypt.keyRequiresAuth('every-time-key')).toBe(true); + }); + + it('is true for a key the helper did not mention', async () => { + statusKeyDetails = [{ keyId: 'no-auth-key', requireAuth: false }]; + const localEncrypt = await loadLocalEncrypt(); + expect(localEncrypt.keyRequiresAuth('some-other-key')).toBe(true); + }); +}); + +describe.skipIf(process.platform === 'win32')('where a v1 decrypt is routed', () => { + it('goes through the daemon when the helper reports no keyDetails', async () => { + statusKeyDetails = undefined; + const localEncrypt = await loadLocalEncrypt(); + + expect(await localEncrypt.decryptValue(v1Payload('value'), 'gated-key')).toBe('via-daemon'); + expect(harness.callsOf('decrypt')).toHaveLength(1); + expect(oneShotCalls).toHaveLength(0); + }); + + it('goes through the daemon for a gated key', async () => { + statusKeyDetails = [{ keyId: 'gated-key', requireAuth: true }]; + const localEncrypt = await loadLocalEncrypt(); + + expect(await localEncrypt.decryptValue(v1Payload('value'), 'gated-key')).toBe('via-daemon'); + expect(harness.callsOf('decrypt')).toHaveLength(1); + expect(oneShotCalls).toHaveLength(0); + }); + + it('takes the one-shot path for a --no-auth key, with no daemon at all', async () => { + statusKeyDetails = [{ keyId: 'no-auth-key', requireAuth: false }]; + const localEncrypt = await loadLocalEncrypt(); + + expect(await localEncrypt.decryptValue(v1Payload('value'), 'no-auth-key')).toBe('one-shot'); + expect(oneShotCalls).toHaveLength(1); + expect(oneShotCalls[0]).toContain('decrypt'); + expect(oneShotCalls[0]).toContain('no-auth-key'); + // no session, no grant, nothing for a headless host to get stuck on + expect(harness.calls()).toHaveLength(0); + }); + + it('routes each key on its own metadata within one process', async () => { + statusKeyDetails = [ + { keyId: 'gated-key', requireAuth: true }, + { keyId: 'no-auth-key', requireAuth: false }, + ]; + const localEncrypt = await loadLocalEncrypt(); + + await localEncrypt.decryptValue(v1Payload('value'), 'no-auth-key'); + await localEncrypt.decryptValue(v1Payload('value'), 'gated-key'); + + expect(oneShotCalls).toHaveLength(1); + expect(harness.callsOf('decrypt')).toHaveLength(1); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/local-encrypt.test.ts b/packages/varlock/src/lib/local-encrypt/local-encrypt.test.ts index 63c03d807..a57ee8f91 100644 --- a/packages/varlock/src/lib/local-encrypt/local-encrypt.test.ts +++ b/packages/varlock/src/lib/local-encrypt/local-encrypt.test.ts @@ -104,13 +104,22 @@ describe('local-encrypt with file fallback', () => { expect(decrypted).toBe(plaintext); }); - it('fails to decrypt with wrong key', async () => { + it('fails to decrypt a device-encrypted value with the wrong key', async () => { await localEncrypt.ensureKey('key-a'); await localEncrypt.ensureKey('key-b'); - const ciphertext = await localEncrypt.encryptValue('secret', 'key-a'); + const ciphertext = await localEncrypt.encryptValue('secret', 'key-a', { target: 'device' }); await expect(localEncrypt.decryptValue(ciphertext, 'key-b')).rejects.toThrow(); }); + it('opens an identity-encrypted value regardless of which device key is named', async () => { + // v2 payloads belong to the identity, not to one device key: the key id + // only picks which wrap to unwrap through, and any wrap will do + await localEncrypt.ensureKey('key-a'); + await localEncrypt.ensureKey('key-b'); + const ciphertext = await localEncrypt.encryptValue('secret', 'key-a'); + expect(await localEncrypt.decryptValue(ciphertext, 'key-b')).toBe('secret'); + }); + it('fails to decrypt garbage ciphertext', async () => { await localEncrypt.ensureKey(); await expect(localEncrypt.decryptValue('not-valid-base64-ciphertext!')).rejects.toThrow(); diff --git a/packages/varlock/src/lib/local-encrypt/re-encrypt.test.ts b/packages/varlock/src/lib/local-encrypt/re-encrypt.test.ts new file mode 100644 index 000000000..a849a97b8 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/re-encrypt.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for the re-encryption core (re-encrypt.ts) on the file backend. + * This is what `varlock encrypt --upgrade` drives. + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { DEVICE_PAYLOAD_VERSION, IDENTITY_PAYLOAD_VERSION, readPayloadVersion } from './crypto'; +import { parseVarlockReference } from './reference'; + +process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + +const testDir = path.join(os.tmpdir(), `varlock-re-encrypt-test-${process.pid}`); +const projectDir = path.join(testDir, 'project'); + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => path.join(testDir, 'state'), +})); + +let localEncrypt: typeof import('./index'); +let reEncrypt: typeof import('./re-encrypt'); + +/** Read the payload version of `KEY=varlock("local:...")` in a file */ +function payloadVersionOf(contents: string, key: string) { + const match = new RegExp(`^${key}=varlock\\("([^"]+)"\\)$`, 'm').exec(contents); + if (!match) throw new Error(`no varlock() reference found for ${key}`); + return readPayloadVersion(parseVarlockReference(match[1]).payload); +} + +/** The default local pass: device-encrypted values move to the current target */ +function localUpgradePass() { + return { source: reEncrypt.deviceEncryptedSource(), target: reEncrypt.currentLocalTarget() }; +} + +/** Run the default local upgrade pass against a file */ +function reEncryptFileWithDefaults(filePath: string) { + return reEncrypt.reEncryptFile(filePath, localUpgradePass()); +} + +beforeEach(async () => { + fs.mkdirSync(projectDir, { recursive: true }); + vi.resetModules(); + process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK = '1'; + localEncrypt = await import('./index'); + reEncrypt = await import('./re-encrypt'); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +/** Write a fixture env file with two device-encrypted values and assorted noise */ +async function writeFixture() { + await localEncrypt.ensureKey(); + const apiKey = await localEncrypt.encryptValue('sk-live-1234', undefined, { target: 'device' }); + const dbUrl = await localEncrypt.encryptValue('postgres://localhost/db', undefined, { target: 'device' }); + const alreadyUpgraded = await localEncrypt.encryptValue('already-v2'); + + const contents = [ + '# @envFlag=APP_ENV', + '# ---', + '', + '# @sensitive', + `API_KEY=varlock("local:${apiKey}")`, + '', + '# a plain value nobody should touch', + 'PUBLIC_URL=https://example.com', + '', + '# @sensitive', + `DATABASE_URL=varlock("local:${dbUrl}")`, + '', + '# @sensitive', + `ALREADY=varlock("local:${alreadyUpgraded}")`, + '', + '# @sensitive', + 'NEEDS_INPUT=varlock(prompt=1)', + '', + ].join('\n'); + + const filePath = path.join(projectDir, '.env.local'); + fs.writeFileSync(filePath, contents); + return { filePath, contents, plaintexts: { API_KEY: 'sk-live-1234', DATABASE_URL: 'postgres://localhost/db' } }; +} + +describe('reEncryptFile', () => { + it('rewrites v1 values to the current target and leaves the rest alone', async () => { + const { filePath, contents: before } = await writeFixture(); + + const result = await reEncryptFileWithDefaults(filePath); + expect(result.reEncrypted.sort()).toEqual(['API_KEY', 'DATABASE_URL']); + + const after = fs.readFileSync(filePath, 'utf-8'); + expect(payloadVersionOf(after, 'API_KEY')).toBe(IDENTITY_PAYLOAD_VERSION); + expect(payloadVersionOf(after, 'DATABASE_URL')).toBe(IDENTITY_PAYLOAD_VERSION); + + // Every line except the two that changed comes through byte for byte, in + // the same order. (The shared write-back path normalizes blank lines, so + // those are compared out.) + const meaningfulLines = (contents: string) => contents.split('\n').filter((l) => l.trim() !== ''); + const beforeLines = meaningfulLines(before); + const afterLines = meaningfulLines(after); + expect(afterLines).toHaveLength(beforeLines.length); + + const changedKeys = ['API_KEY', 'DATABASE_URL']; + const isChangedLine = (line: string) => changedKeys.some((k) => line.startsWith(`${k}=`)); + for (let i = 0; i < beforeLines.length; i++) { + if (isChangedLine(beforeLines[i])) { + expect(afterLines[i]).not.toBe(beforeLines[i]); + } else { + expect(afterLines[i]).toBe(beforeLines[i]); + } + } + }); + + it('preserves the decrypted values', async () => { + const { filePath, plaintexts } = await writeFixture(); + await reEncryptFileWithDefaults(filePath); + + const after = fs.readFileSync(filePath, 'utf-8'); + for (const [key, expected] of Object.entries(plaintexts)) { + const match = new RegExp(`^${key}=varlock\\("([^"]+)"\\)$`, 'm').exec(after)!; + const { payload } = parseVarlockReference(match[1]); + + expect(await localEncrypt.decryptValue(payload)).toBe(expected); + } + }); + + it('leaves values that are already at the target', async () => { + const { filePath, contents: before } = await writeFixture(); + const result = await reEncryptFileWithDefaults(filePath); + + expect(result.skipped).toContainEqual({ key: 'ALREADY', reason: 'not-a-source-value' }); + + const beforeAlready = /^ALREADY=(.*)$/m.exec(before)![1]; + const afterAlready = /^ALREADY=(.*)$/m.exec(fs.readFileSync(filePath, 'utf-8'))![1]; + expect(afterAlready).toBe(beforeAlready); + }); + + it('skips prompt entries, which carry no payload', async () => { + const { filePath } = await writeFixture(); + const result = await reEncryptFileWithDefaults(filePath); + expect(result.skipped).toContainEqual({ key: 'NEEDS_INPUT', reason: 'not-a-static-payload' }); + }); + + it('is a no-op on a second run', async () => { + const { filePath } = await writeFixture(); + await reEncryptFileWithDefaults(filePath); + const afterFirst = fs.readFileSync(filePath, 'utf-8'); + + const second = await reEncryptFileWithDefaults(filePath); + expect(second.reEncrypted).toEqual([]); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(afterFirst); + }); + + it('changes nothing on a dry run', async () => { + const { filePath, contents: before } = await writeFixture(); + + const result = await reEncrypt.reEncryptFile(filePath, { ...localUpgradePass(), dryRun: true }); + expect(result.reEncrypted.sort()).toEqual(['API_KEY', 'DATABASE_URL']); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(before); + }); + + it('leaves references from another scheme alone', async () => { + await localEncrypt.ensureKey(); + const filePath = path.join(projectDir, '.env.other'); + const contents = 'TEAM_SECRET=varlock("teamvault:AQIDBA==")\n'; + fs.writeFileSync(filePath, contents); + + const result = await reEncryptFileWithDefaults(filePath); + expect(result.reEncrypted).toEqual([]); + expect(result.skipped).toContainEqual({ key: 'TEAM_SECRET', reason: 'unknown-scheme' }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(contents); + }); + + it('routes values through whatever source and target it is given', async () => { + // a pass with no relation to v1 or v2: it picks up identity-encrypted + // values and writes them somewhere else entirely + const { filePath } = await writeFixture(); + const seen: Array = []; + + const result = await reEncrypt.reEncryptFile(filePath, { + source: { + matches: (reference) => readPayloadVersion(reference.payload) === IDENTITY_PAYLOAD_VERSION, + decrypt: async (reference) => { + const plaintext = await localEncrypt.decryptValue(reference.payload); + seen.push(plaintext); + return plaintext; + }, + }, + target: { scheme: 'local', encrypt: async (plaintext) => `rewritten-${plaintext}` }, + }); + + expect(result.reEncrypted).toEqual(['ALREADY']); + expect(seen).toEqual(['already-v2']); + expect(fs.readFileSync(filePath, 'utf-8')).toContain('ALREADY=varlock("local:rewritten-already-v2")'); + // the v1 values were not this pass's source, so they were left alone + expect(payloadVersionOf(fs.readFileSync(filePath, 'utf-8'), 'API_KEY')).toBe(DEVICE_PAYLOAD_VERSION); + }); +}); + +describe('canReEncryptLocally', () => { + it('allows re-encryption on the file backend', () => { + expect(reEncrypt.canReEncryptLocally()).toEqual({ ok: true }); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/re-encrypt.ts b/packages/varlock/src/lib/local-encrypt/re-encrypt.ts new file mode 100644 index 000000000..ce106ccb5 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/re-encrypt.ts @@ -0,0 +1,174 @@ +/** + * Re-encrypt values that are already encrypted, in place, in an env file. + * + * A pass is a decrypt followed by an encrypt, described as a source (which + * existing values to pick up, and how to open them) and a target (where they + * should land). Today the only pass is device-encrypted (v1) values moving to + * whatever the local backend currently encrypts to, but the same core is meant + * to carry `encrypt --to `, key rotation, and cloud migration, so it + * takes source and target rather than hardcoding v1 to v2. + * + * A local pass runs wherever both halves can happen. Encrypting always can, since + * it only needs a public key. Decrypting is the half that varies: in-process on + * the file backend, and through the daemon's unlock session on hardware ones, so + * the identity key is never handed to V8. + */ + +import fs from 'node:fs'; +import { + parseEnvSpecDotEnvFile, + ParsedEnvSpecFunctionCall, +} from '@env-spec/parser'; +import { DEFAULT_KEY_ID } from './constants'; +import { DEVICE_PAYLOAD_VERSION, readPayloadVersion } from './crypto'; +import * as localEncrypt from './index'; +import { + buildVarlockReference, LOCAL_SCHEME, parseVarlockReference, type ParsedVarlockReference, + type VarlockScheme, +} from './reference'; +import { writeBackValue } from './write-back'; + +/** Which already-encrypted values a pass picks up, and how it opens them */ +export interface ReEncryptSource { + /** Whether this value is one the pass should re-encrypt */ + matches: (reference: ParsedVarlockReference) => boolean; + /** Turn a matched value back into plaintext */ + decrypt: (reference: ParsedVarlockReference) => Promise; +} + +/** Where a re-encrypted value lands */ +export interface ReEncryptTarget { + /** Scheme the rewritten `varlock()` reference is built with */ + scheme: VarlockScheme; + encrypt: (plaintext: string) => Promise; +} + +/** + * Why a value was left alone. + * + * - `not-a-static-payload`: carries no payload to re-encrypt (e.g. `varlock(prompt=1)`) + * - `unknown-scheme`: the reference names a scheme this build does not know + * - `not-a-source-value`: not what this pass is looking for, including values already at the target + * - `write-back-failed`: decrypted and re-encrypted, but the file could not be updated + */ +export type ReEncryptSkipReason = 'not-a-static-payload' + | 'unknown-scheme' + | 'not-a-source-value' + | 'write-back-failed'; + +export interface ReEncryptFileResult { + filePath: string; + /** keys rewritten from the source to the target */ + reEncrypted: Array; + /** keys left alone, and why */ + skipped: Array<{ key: string; reason: ReEncryptSkipReason }>; +} + +/** + * Values encrypted directly to this device's key, as a source. + * + * v2 values are deliberately not matched: they are already where a local pass + * would put them. + */ +export function deviceEncryptedSource(keyId: string = DEFAULT_KEY_ID): ReEncryptSource { + return { + matches: (reference) => reference.scheme === LOCAL_SCHEME + && readPayloadVersion(reference.payload) === DEVICE_PAYLOAD_VERSION, + decrypt: (reference) => localEncrypt.decryptValue(reference.payload, keyId), + }; +} + +/** + * Whatever the local backend currently encrypts to, as a target. That is the + * identity key on the file backend and the device key elsewhere, so this stays + * correct as backends gain identity support. + */ +export function currentLocalTarget(keyId: string = DEFAULT_KEY_ID): ReEncryptTarget { + return { + scheme: LOCAL_SCHEME, + encrypt: (plaintext) => localEncrypt.encryptValue(plaintext, keyId), + }; +} + +/** + * Whether this machine can run a local re-encryption pass at all. + * + * The one place it cannot is WSL, which reaches the Windows daemon a process at + * a time and so has no session to hold an unlock in. Values there stay on v1, + * which its own daemon reads perfectly well. + */ +export function canReEncryptLocally(): { ok: true } | { ok: false; reason: string } { + const backend = localEncrypt.getBackendInfo(); + if (!localEncrypt.canUseIdentityEncryption()) { + return { + ok: false, + reason: `The ${backend.type} backend cannot re-encrypt values from WSL, because the Windows ` + + 'daemon is reached one process at a time from here and an unlock session cannot be ' + + 'held open across those calls. Values stay device-encrypted, which still loads normally.', + }; + } + return { ok: true }; +} + +/** + * Re-encrypt every value in one env file that the source matches. + * + * Values the source does not match, prompts, and anything that is not a + * `varlock()` reference are left exactly as they are: only the entries that + * actually change get rewritten. + */ +export async function reEncryptFile( + filePath: string, + opts: { source: ReEncryptSource; target: ReEncryptTarget; dryRun?: boolean }, +): Promise { + const { source, target } = opts; + const result: ReEncryptFileResult = { filePath, reEncrypted: [], skipped: [] }; + + const parsed = parseEnvSpecDotEnvFile(fs.readFileSync(filePath, 'utf-8')); + + for (const item of parsed.configItems) { + const value = item.value; + if (!(value instanceof ParsedEnvSpecFunctionCall) || value.name !== 'varlock') continue; + + const args = value.simplifiedArgs; + if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string') { + // varlock(prompt=1) and friends carry no payload to re-encrypt + result.skipped.push({ key: item.key, reason: 'not-a-static-payload' }); + continue; + } + + let reference; + try { + reference = parseVarlockReference(args[0]); + } catch { + result.skipped.push({ key: item.key, reason: 'unknown-scheme' }); + continue; + } + + if (!source.matches(reference)) { + result.skipped.push({ key: item.key, reason: 'not-a-source-value' }); + continue; + } + + const plaintext = await source.decrypt(reference); + const reEncrypted = await target.encrypt(plaintext); + + if (opts.dryRun) { + result.reEncrypted.push(item.key); + continue; + } + + const writeResult = writeBackValue( + item.key, + buildVarlockReference(target.scheme, reEncrypted), + filePath, + ); + if (!writeResult.updated) { + result.skipped.push({ key: item.key, reason: 'write-back-failed' }); + continue; + } + result.reEncrypted.push(item.key); + } + + return result; +} diff --git a/packages/varlock/src/lib/local-encrypt/reference.test.ts b/packages/varlock/src/lib/local-encrypt/reference.test.ts new file mode 100644 index 000000000..4891b5ec6 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/reference.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { + LOCAL_SCHEME, buildVarlockReference, isKnownVarlockScheme, parseVarlockReference, +} from './reference'; + +describe('parseVarlockReference', () => { + it('parses the local scheme', () => { + expect(parseVarlockReference('local:AQID')).toEqual({ scheme: 'local', payload: 'AQID' }); + }); + + it('keeps the payload intact, including base64 padding', () => { + expect(parseVarlockReference('local:AQIDBA==').payload).toBe('AQIDBA=='); + }); + + it('treats a prefixless payload as local (legacy references)', () => { + // base64 never contains a colon, so an unprefixed payload can never be + // mistaken for a scheme + expect(parseVarlockReference('AQIDBA==')).toEqual({ scheme: 'local', payload: 'AQIDBA==' }); + }); + + it('throws on an unknown scheme rather than trying to decrypt', () => { + expect(() => parseVarlockReference('foo:AQID')).toThrow('unknown varlock() scheme "foo"'); + }); + + it('lists the known schemes in the unknown-scheme error', () => { + expect(() => parseVarlockReference('teamvault:AQID')).toThrow(/known schemes: "local"/); + }); + + it('does not treat a leading digit or symbol as a scheme', () => { + // schemes must start with a letter, so these stay legacy local payloads + expect(parseVarlockReference('9foo:AQID').scheme).toBe(LOCAL_SCHEME); + expect(parseVarlockReference('9foo:AQID').payload).toBe('9foo:AQID'); + expect(parseVarlockReference('-foo:AQID').payload).toBe('-foo:AQID'); + }); + + it('is case sensitive about the registered scheme name', () => { + expect(() => parseVarlockReference('LOCAL:AQID')).toThrow(/unknown varlock\(\) scheme "LOCAL"/); + }); + + it('allows underscores and digits inside a scheme name', () => { + expect(() => parseVarlockReference('team_vault2:AQID')).toThrow(/scheme "team_vault2"/); + }); + + it('handles an empty payload', () => { + expect(parseVarlockReference('local:')).toEqual({ scheme: 'local', payload: '' }); + }); +}); + +describe('isKnownVarlockScheme', () => { + it('knows local and nothing else', () => { + expect(isKnownVarlockScheme('local')).toBe(true); + expect(isKnownVarlockScheme('foo')).toBe(false); + // must not pick up inherited object properties + expect(isKnownVarlockScheme('toString')).toBe(false); + }); +}); + +describe('buildVarlockReference', () => { + it('builds a local reference', () => { + expect(buildVarlockReference(LOCAL_SCHEME, 'AQIDBA==')).toBe('varlock("local:AQIDBA==")'); + }); + + it('round-trips with parseVarlockReference', () => { + const ciphertext = 'AQIDBAUGBwg='; + const reference = buildVarlockReference(LOCAL_SCHEME, ciphertext); + const inner = reference.slice('varlock("'.length, -'")'.length); + expect(parseVarlockReference(inner)).toEqual({ scheme: LOCAL_SCHEME, payload: ciphertext }); + }); + + it('refuses to build a reference for an unregistered scheme', () => { + expect(() => buildVarlockReference('foo' as any, 'AQID')).toThrow('unknown varlock() scheme "foo"'); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/reference.ts b/packages/varlock/src/lib/local-encrypt/reference.ts new file mode 100644 index 000000000..24efd7ba0 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/reference.ts @@ -0,0 +1,70 @@ +/** + * Parsing and building of `varlock()` reference strings. + * + * A reference looks like `varlock(":")`. The scheme says where + * the payload came from and which backend can turn it back into a value. Today + * `local` (device-local encryption) is the only registered scheme. + * + * Payloads written before schemes existed have no prefix at all. Those are still + * read as `local`, so old env files keep working. + */ + +/** Device-local encryption (Secure Enclave / TPM / file fallback) */ +export const LOCAL_SCHEME = 'local'; + +/** + * Every scheme this build knows how to resolve. Adding a scheme here is what + * makes `varlock(":...")` stop being an error. + */ +export const VARLOCK_SCHEMES = { + [LOCAL_SCHEME]: { label: 'device-local encryption' }, +} as const; + +export type VarlockScheme = keyof typeof VARLOCK_SCHEMES; + +/** + * Matches a leading `:` prefix. + * + * Base64 payloads never contain a colon, so a legacy prefixless payload cannot + * accidentally match this and get read as a scheme. + */ +const SCHEME_PREFIX_REGEX = /^([a-zA-Z][a-zA-Z0-9_]*):([\s\S]*)$/; + +export type ParsedVarlockReference = { + scheme: VarlockScheme; + /** the reference with its scheme prefix removed */ + payload: string; +}; + +export function isKnownVarlockScheme(scheme: string): scheme is VarlockScheme { + return Object.hasOwn(VARLOCK_SCHEMES, scheme); +} + +function unknownSchemeError(scheme: string) { + const known = Object.keys(VARLOCK_SCHEMES).map((s) => `"${s}"`).join(', '); + return new Error(`unknown varlock() scheme "${scheme}" (known schemes: ${known})`); +} + +/** + * Build the reference string written into env files. + * This is the only place a `varlock("...")` string should be assembled. + */ +export function buildVarlockReference(scheme: VarlockScheme, ciphertext: string): string { + if (!isKnownVarlockScheme(scheme)) throw unknownSchemeError(scheme); + return `varlock("${scheme}:${ciphertext}")`; +} + +/** + * Split a `varlock()` argument into its scheme and payload. + * Throws when the reference names a scheme this build does not know. + */ +export function parseVarlockReference(reference: string): ParsedVarlockReference { + const match = SCHEME_PREFIX_REGEX.exec(reference); + if (!match) { + // no prefix at all: predates schemes, and those payloads were always local + return { scheme: LOCAL_SCHEME, payload: reference }; + } + const [, scheme, payload] = match; + if (!isKnownVarlockScheme(scheme)) throw unknownSchemeError(scheme); + return { scheme, payload }; +} diff --git a/packages/varlock/src/lib/local-encrypt/session-decrypt.test.ts b/packages/varlock/src/lib/local-encrypt/session-decrypt.test.ts new file mode 100644 index 000000000..ad00da9c9 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/session-decrypt.test.ts @@ -0,0 +1,612 @@ +/** + * Opening v2 payloads on a hardware backend, end to end against a fake daemon. + * + * What matters here is the shape of the conversation, not the crypto: that a + * batch costs one unlock rather than one per value, that the unlock names every + * key and carries the display metadata, that a grant dying mid-flight is + * retried exactly once, and that a refusal arrives as something a person can act + * on rather than as "decryption failed". + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { FakeDaemonHarness } from './test/fake-daemon-harness'; + +let harness: FakeDaemonHarness; + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => harness.userVarlockDir, +})); + +vi.mock('./binary-resolver', () => ({ + resolveNativeBinary: () => harness.binaryPath, + getInstalledPlatformPackageName: () => undefined, +})); + +// only the status probe is faked; spawn has to be real, because the fake daemon +// is a real process the client has to start for itself +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: () => JSON.stringify({ + backend: 'secure-enclave', + hardwareBacked: true, + biometricAvailable: true, + keys: ['varlock-default', 'other-key'], + keyDetails: [ + { keyId: 'varlock-default', requireAuth: true }, + { keyId: 'other-key', requireAuth: true }, + ], + }), + }; +}); + +async function loadLocalEncrypt() { + vi.resetModules(); + return import('./index'); +} + +/** A ciphertext the fake daemon will recognise, with a real v2 version byte */ +function v2Payload(marker: string): string { + return Buffer.concat([Buffer.from([0x02]), Buffer.from(marker, 'utf-8')]).toString('base64'); +} + +beforeEach(() => { + harness = new FakeDaemonHarness(); + delete process.env._VARLOCK_FORCE_FILE_ENCRYPTION_FALLBACK; +}); + +afterEach(() => { + harness.cleanup(); +}); + +describe.skipIf(process.platform === 'win32')('v2 decryption through the daemon', () => { + it('opens a whole batch with a single unlock', async () => { + const payloads = ['alpha', 'beta', 'gamma'].map(v2Payload); + harness.setConfig({ + plaintexts: Object.fromEntries(payloads.map((c, i) => [c, `secret-${i}`])), + }); + + const localEncrypt = await loadLocalEncrypt(); + const plaintexts = await localEncrypt.decryptIdentityPayloads( + payloads.map((ciphertext) => ({ ciphertext, keyId: 'varlock-default' })), + ); + + expect(plaintexts).toEqual(['secret-0', 'secret-1', 'secret-2']); + expect(harness.callsOf('unlock-session')).toHaveLength(1); + // three values, one decrypt call: the batching is the whole point + expect(harness.callsOf('decrypt-v2')).toHaveLength(1); + expect(harness.callsOf('decrypt-v2')[0].payload.ciphertexts).toHaveLength(3); + }); + + it('names every key in one unlock, and sends the display metadata', async () => { + const first = v2Payload('one'); + const second = v2Payload('two'); + harness.setConfig({ plaintexts: { [first]: 'a', [second]: 'b' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([ + { ciphertext: first, keyId: 'varlock-default' }, + { ciphertext: second, keyId: 'other-key' }, + ], { display: { projectName: 'my-project' } }); + + const unlocks = harness.callsOf('unlock-session'); + expect(unlocks).toHaveLength(1); + expect(unlocks[0].payload.keyIds).toEqual(['varlock-default', 'other-key']); + expect(unlocks[0].payload.scope).toBe('session'); + + const display = unlocks[0].payload.display as Record; + expect(display.projectName).toBe('my-project'); + expect(display.itemCounts).toEqual({ 'varlock-default': 1, 'other-key': 1 }); + + // one decrypt per key, since a grant is per (session x key) + expect(harness.callsOf('decrypt-v2')).toHaveLength(2); + }); + + it('tells the panel which values, in which files, each key is being asked for', async () => { + const dbUrl = v2Payload('db'); + const stripe = v2Payload('stripe'); + const localOnly = v2Payload('local'); + const prodKey = v2Payload('prod'); + harness.setConfig({ + plaintexts: { + [dbUrl]: 'a', [stripe]: 'b', [localOnly]: 'c', [prodKey]: 'd', + }, + }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([ + { + ciphertext: dbUrl, keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env', + }, + { + ciphertext: stripe, keyId: 'varlock-default', valueName: 'STRIPE_KEY', sourceFile: '.env', + }, + { + ciphertext: localOnly, keyId: 'varlock-default', valueName: 'NGROK_TOKEN', sourceFile: '.env.local', + }, + { + ciphertext: prodKey, keyId: 'other-key', valueName: 'PROD_TOKEN', sourceFile: '.env.prod', + }, + ], { display: { projectName: 'acme-api' } }); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.projectName).toBe('acme-api'); + expect(display.keys['varlock-default'].valueCount).toBe(3); + // grouped by the file that defined them, in the order the files first appear + expect(display.keys['varlock-default'].sources).toEqual([ + { kind: 'file', path: '.env', entries: [{ name: 'DATABASE_URL' }, { name: 'STRIPE_KEY' }] }, + { kind: 'file', path: '.env.local', entries: [{ name: 'NGROK_TOKEN' }] }, + ]); + expect(display.keys['other-key']).toEqual({ + valueCount: 1, + sources: [{ kind: 'file', path: '.env.prod', entries: [{ name: 'PROD_TOKEN' }] }], + }); + }); + + it('tells the panel how varlock came to be running', async () => { + const payload = v2Payload('mode'); + harness.setConfig({ plaintexts: { [payload]: 'x' } }); + process.env._VARLOCK_INVOCATION_MODE = 'auto-load'; + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]); + delete process.env._VARLOCK_INVOCATION_MODE; + + // A spawned auto-load is the same CLI a person would run, so the child has + // to be told which it was; the daemon reads the command itself. + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.invocationMode).toBe('auto-load'); + }); + + it('still reports counts when the caller knows no value names', async () => { + const payload = v2Payload('nameless'); + harness.setConfig({ plaintexts: { [payload]: 'x' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.keys['varlock-default']).toEqual({ valueCount: 1 }); + }); + + it('keeps the vault decoration the caller supplied for a key', async () => { + const payload = v2Payload('vaulted'); + harness.setConfig({ plaintexts: { [payload]: 'x' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads( + [{ ciphertext: payload, keyId: 'varlock-default', valueName: 'PROD_TOKEN' }], + { display: { keys: { 'varlock-default': { vaultLabel: 'acme-team vault', vaultColor: '#b48ce8' } } } }, + ); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.keys['varlock-default']).toEqual({ + vaultLabel: 'acme-team vault', + vaultColor: '#b48ce8', + valueCount: 1, + sources: [{ kind: 'file', entries: [{ name: 'PROD_TOKEN' }] }], + }); + }); + + it("lists a caller's own sources beside the files this batch is opening", async () => { + const payload = v2Payload('mixed'); + harness.setConfig({ plaintexts: { [payload]: 'x' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads( + [ + { + ciphertext: payload, keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env', + }, + ], + { + display: { + keys: { + 'varlock-default': { + // what the key covers, which is more than this batch decrypts + valueCount: 13, + sources: [{ kind: 'cache', itemCount: 12, entries: [{ name: '1password', count: 12 }] }], + }, + }, + }, + }, + ); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + // One list under one key: the files this batch opens, then the caller's own + // sources. Everything the grant covers, in one place. + expect(display.keys['varlock-default'].sources).toEqual([ + { kind: 'file', path: '.env', entries: [{ name: 'DATABASE_URL' }] }, + { kind: 'cache', itemCount: 12, entries: [{ name: '1password', count: 12 }] }, + ]); + // the caller's count wins: the payload count only measures this batch + expect(display.keys['varlock-default'].valueCount).toBe(13); + expect(display.itemCounts['varlock-default']).toBe(13); + }); + + /** + * The bug this covers: a run with encrypted values in `.env.local` and a + * populated value cache used to show whichever of the two asked first, and + * only that. The other opened moments later on the same grant with no panel + * of its own, so the approval was given on a fraction of what it bought. + * + * The fix is that the run declares everything before anything asks, so the + * order the callers happen to arrive in cannot change what the panel says. + */ + describe('the panel describes the whole grant, not the batch that asked first', () => { + const cacheEntry = v2Payload('cached'); + const fileValue = v2Payload('file-one'); + const otherFileValue = v2Payload('file-two'); + + /** What a `CacheStore` read sends: the cache as a source under its key */ + const cacheDisplay = { + keys: { + 'varlock-default': { + valueCount: 8, + sources: [ + { + kind: 'cache' as const, + itemCount: 8, + entries: [{ name: '1password', count: 8 }], + }, + ], + }, + }, + }; + + /** The two encrypted values in `.env.local`, as the graph declares them */ + const declaredFileValues = [ + { keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env.local' }, + { keyId: 'varlock-default', valueName: 'STRIPE_KEY', sourceFile: '.env.local' }, + ]; + + /** The file batch, as the varlock() resolver sends it */ + const filePayloads = [ + { + ciphertext: fileValue, keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env.local', + }, + { + ciphertext: otherFileValue, keyId: 'varlock-default', valueName: 'STRIPE_KEY', sourceFile: '.env.local', + }, + ]; + + beforeEach(() => { + harness.setConfig({ + plaintexts: { + [cacheEntry]: 'from-cache', [fileValue]: 'a', [otherFileValue]: 'b', + }, + }); + }); + + /** Both sources on the one row, and a header that agrees with them */ + function expectBothListed(display: any) { + expect(display.keys['varlock-default'].sources).toEqual([ + { + kind: 'file', + path: '.env.local', + entries: [{ name: 'DATABASE_URL' }, { name: 'STRIPE_KEY' }], + }, + { kind: 'cache', itemCount: 8, entries: [{ name: '1password', count: 8 }] }, + ]); + expect(display.keys['varlock-default'].valueCount).toBe(10); + expect(display.itemCounts['varlock-default']).toBe(10); + } + + it('lists the env files too when the cache is what triggers the unlock', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues(declaredFileValues); + + await localEncrypt.decryptValue(cacheEntry, 'varlock-default', { display: cacheDisplay }); + await localEncrypt.decryptIdentityPayloads(filePayloads); + + // one grant, so one panel: the second caller never gets one of its own + expect(harness.callsOf('unlock-session')).toHaveLength(1); + expectBothListed(harness.callsOf('unlock-session')[0].payload.display); + }); + + it('lists the cache too when an env file is what triggers the unlock', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues(declaredFileValues); + localEncrypt.declareCacheInventory('varlock-default', { + kind: 'cache', + itemCount: 8, + entries: [{ name: '1password', count: 8 }], + }); + + await localEncrypt.decryptIdentityPayloads(filePayloads); + await localEncrypt.decryptValue(cacheEntry, 'varlock-default', { display: cacheDisplay }); + + expect(harness.callsOf('unlock-session')).toHaveLength(1); + expectBothListed(harness.callsOf('unlock-session')[0].payload.display); + }); + + it('does not list a cache the run is not using', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues(declaredFileValues); + localEncrypt.declareCacheInventory('varlock-default', { kind: 'cache', itemCount: 8 }); + // e.g. @cache=memory, or a disk cache that turned out to be empty + localEncrypt.clearDeclaredCacheInventories(); + + await localEncrypt.decryptIdentityPayloads(filePayloads); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.keys['varlock-default'].sources).toEqual([ + { + kind: 'file', + path: '.env.local', + entries: [{ name: 'DATABASE_URL' }, { name: 'STRIPE_KEY' }], + }, + ]); + expect(display.keys['varlock-default'].valueCount).toBe(2); + }); + + it('counts a declared value once, however many times it is described', async () => { + const localEncrypt = await loadLocalEncrypt(); + // the graph declares three, and the batch happens to open two of them + localEncrypt.declareEncryptedFileValues([ + ...declaredFileValues, + { keyId: 'varlock-default', valueName: 'NGROK_TOKEN', sourceFile: '.env.local' }, + ]); + + await localEncrypt.decryptIdentityPayloads(filePayloads); + + const display = harness.callsOf('unlock-session')[0].payload.display as any; + expect(display.keys['varlock-default'].sources).toEqual([ + { + kind: 'file', + path: '.env.local', + entries: [{ name: 'DATABASE_URL' }, { name: 'STRIPE_KEY' }, { name: 'NGROK_TOKEN' }], + }, + ]); + // the third value rides this same grant, so the header says three rather + // than the two being decrypted at this moment + expect(display.keys['varlock-default'].valueCount).toBe(3); + }); + }); + + it('re-unlocks once when the grant dies between the unlock and the decrypt', async () => { + const payload = v2Payload('racy'); + harness.setConfig({ + plaintexts: { [payload]: 'still-worked' }, + dropGrantsBeforeDecrypt: true, + }); + + const localEncrypt = await loadLocalEncrypt(); + const [plaintext] = await localEncrypt.decryptIdentityPayloads( + [{ ciphertext: payload, keyId: 'varlock-default' }], + ); + + expect(plaintext).toBe('still-worked'); + // the first unlock, then the one the NO_SESSION_GRANT retry opened + expect(harness.callsOf('unlock-session')).toHaveLength(2); + expect(harness.callsOf('decrypt-v2')).toHaveLength(2); + }); + + it('surfaces a declined unlock as its own error, not a decryption failure', async () => { + const payload = v2Payload('declined'); + harness.setConfig({ + plaintexts: { [payload]: 'never-seen' }, + unlockError: { code: 'APPROVAL_DENIED', message: 'user said no' }, + }); + + const localEncrypt = await loadLocalEncrypt(); + const err = await localEncrypt + .decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]) + .then(() => undefined, (e) => e); + + expect(err).toBeInstanceOf(localEncrypt.UnlockDeclinedError); + expect(err.message).toMatch(/declined/i); + // a refusal is final; it must not be retried into a second panel + expect(harness.callsOf('unlock-session')).toHaveLength(1); + }); + + it('tells the user to use a GUI session when there is nowhere to ask', async () => { + const payload = v2Payload('headless'); + harness.setConfig({ + plaintexts: { [payload]: 'never-seen' }, + unlockError: { code: 'NO_UI', message: 'no window server' }, + }); + + const localEncrypt = await loadLocalEncrypt(); + const err = await localEncrypt + .decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]) + .then(() => undefined, (e) => e); + + expect(err).toBeInstanceOf(localEncrypt.UnlockNoUiError); + expect(err.message).toMatch(/graphical session/); + }); + + it('skips the unlock when this process already holds a live grant', async () => { + const first = v2Payload('first'); + const second = v2Payload('second'); + harness.setConfig({ plaintexts: { [first]: 'a', [second]: 'b' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: first, keyId: 'varlock-default' }]); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: second, keyId: 'varlock-default' }]); + + // the second batch rode the first batch's grant: still one panel, one scan + expect(harness.callsOf('unlock-session')).toHaveLength(1); + expect(harness.callsOf('decrypt-v2')).toHaveLength(2); + }); + + it('routes a single v2 value through the same session flow', async () => { + const payload = v2Payload('single'); + harness.setConfig({ plaintexts: { [payload]: 'one-value' } }); + + const localEncrypt = await loadLocalEncrypt(); + expect(await localEncrypt.decryptValue(payload)).toBe('one-value'); + expect(harness.callsOf('unlock-session')).toHaveLength(1); + }); +}); + +describe.skipIf(process.platform === 'win32')('lock and sessions over the daemon', () => { + it('lists what the daemon is holding', async () => { + const payload = v2Payload('listed'); + harness.setConfig({ plaintexts: { [payload]: 'x' }, sessionId: 'tty:/dev/ttys009' }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]); + + const sessions = await localEncrypt.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ sessionId: 'tty:/dev/ttys009', keyId: 'varlock-default' }); + }); + + /** + * The breadth axis, from this side of the socket. + * + * What the client owes the daemon is the full set of ciphertexts a grant will + * be asked to open, sent as payloads for the daemon to hash. What it owes the + * user is to turn a refusal into a panel rather than into an error, because a + * narrow grant meeting an unlisted value is the feature working, not a fault. + */ + describe('item-scoped grants', () => { + const declared = v2Payload('declared-by-the-graph'); + const inBatch = v2Payload('in-this-batch'); + const laterOn = v2Payload('nobody-mentioned-this'); + + beforeEach(() => { + harness.setConfig({ + itemScoped: true, + plaintexts: { [declared]: 'a', [inBatch]: 'b', [laterOn]: 'c' }, + }); + }); + + it('sends the ciphertexts the whole run declared, not just the batch in hand', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues([ + { + keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env.local', ciphertext: declared, + }, + { + keyId: 'varlock-default', valueName: 'STRIPE_KEY', sourceFile: '.env.local', ciphertext: inBatch, + }, + ]); + + await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default', valueName: 'STRIPE_KEY' }]); + + const items = harness.callsOf('unlock-session')[0].payload.items as Record>; + // Both, and only once each: a grant narrowed to the batch that asked first + // would refuse everything that rides it afterwards. + expect(new Set(items['varlock-default'])).toEqual(new Set([declared, inBatch])); + }); + + it('sends payloads rather than digests, so the daemon does its own hashing', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default' }]); + + const items = harness.callsOf('unlock-session')[0].payload.items as Record>; + expect(items['varlock-default']).toEqual([inBatch]); + }); + + it('leaves the value cache out of the items it sends', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues([ + { + keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env.local', ciphertext: declared, + }, + ]); + // The cache describes itself for the panel, and contributes no items: it + // is never item scoped, and the daemon covers it by reading its own file. + localEncrypt.declareCacheInventory('varlock-default', { + kind: 'cache', + itemCount: 8, + entries: [{ name: '1password', count: 8 }], + }); + + await localEncrypt.decryptIdentityPayloads([{ ciphertext: declared, keyId: 'varlock-default' }]); + + const call = harness.callsOf('unlock-session')[0].payload as any; + expect(call.items['varlock-default']).toEqual([declared]); + // ...while still being on the panel, which is the whole point of listing it + expect(call.display.keys['varlock-default'].sources).toContainEqual( + { kind: 'cache', itemCount: 8, entries: [{ name: '1password', count: 8 }] }, + ); + }); + + it('asks again when a batch carries a value the grant was never approved over', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default' }]); + expect(harness.callsOf('unlock-session')).toHaveLength(1); + + // A second batch in the same run, carrying something the first unlock + // never named. The daemon refuses it, and the client turns that into a + // fresh unlock rather than an error. + const opened = await localEncrypt.decryptIdentityPayloads([{ ciphertext: laterOn, keyId: 'varlock-default', valueName: 'NEW_TOKEN' }]); + + expect(opened).toEqual(['c']); + expect(harness.callsOf('unlock-session')).toHaveLength(2); + expect( + (harness.callsOf('unlock-session')[1].payload.items as any)['varlock-default'], + ).toContain(laterOn); + }); + + it('costs no second panel for a batch the grant already covers', async () => { + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.declareEncryptedFileValues([ + { + keyId: 'varlock-default', valueName: 'DATABASE_URL', sourceFile: '.env.local', ciphertext: declared, + }, + { + keyId: 'varlock-default', valueName: 'STRIPE_KEY', sourceFile: '.env.local', ciphertext: inBatch, + }, + ]); + + await localEncrypt.decryptIdentityPayloads([{ ciphertext: declared, keyId: 'varlock-default' }]); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default' }]); + + expect(harness.callsOf('unlock-session')).toHaveLength(1); + expect(harness.callsOf('decrypt-v2')).toHaveLength(2); + }); + + it('still opens the read it was approved for when nothing was declared', async () => { + // The `once` case: the panel draws no breadth control and the grant is + // narrow, so the items this request sends are the only thing standing + // between the caller and a refusal. The batch is always in them. + const localEncrypt = await loadLocalEncrypt(); + localEncrypt.clearUnlockInventory(); + + const opened = await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default' }]); + + expect(opened).toEqual(['b']); + const items = harness.callsOf('unlock-session')[0].payload.items as Record>; + expect(items['varlock-default']).toEqual([inBatch]); + // one panel, not a refusal followed by a second one + expect(harness.callsOf('unlock-session')).toHaveLength(1); + }); + + it('reports the breadth the grant came back with', async () => { + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: inBatch, keyId: 'varlock-default' }]); + + const sessions = await localEncrypt.listSessions(); + expect(sessions[0]).toMatchObject({ breadth: 'listed', coveredItemCount: 1, vaultId: 'local' }); + }); + }); + + it('locks one session by id, leaving the rest of the request shape alone', async () => { + const payload = v2Payload('locked'); + harness.setConfig({ plaintexts: { [payload]: 'x' } }); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]); + await localEncrypt.lockSession({ sessionId: 'fake-session' }); + + const invalidations = harness.callsOf('invalidate-session'); + expect(invalidations).toHaveLength(1); + expect(invalidations[0].payload).toEqual({ sessionId: 'fake-session' }); + }); + + it('reports the session id the daemon resolved, which is what --current locks', async () => { + harness.setConfig({ sessionId: 'ptree:4242:99' }); + const localEncrypt = await loadLocalEncrypt(); + // a daemon has to be running for there to be a session at all + await localEncrypt.getDaemonClient().ensureConnected(); + + expect(await localEncrypt.getCurrentSessionId()).toBe('ptree:4242:99'); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/session-decrypt.ts b/packages/varlock/src/lib/local-encrypt/session-decrypt.ts new file mode 100644 index 000000000..4080cb08c --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/session-decrypt.ts @@ -0,0 +1,414 @@ +/** + * Opening identity-encrypted (v2) payloads on a hardware backend. + * + * The identity private key must never enter this process on these backends, so + * the daemon holds it and does the decrypting. Two ops make that work: + * + * unlock-session open a grant, at the cost of one user-presence check + * decrypt-v2 spend that grant on a batch of payloads + * + * The batching is the point. A whole env file resolves at once, so the grouping + * here turns a file full of secrets into a single unlock: one panel, one scan, + * however many values. Splitting a batch would cost a prompt per value, which is + * the behaviour the identity layer exists to get rid of. + * + * The panel it draws describes the whole grant, not this batch. What a batch + * carries is only what it happens to be decrypting; what the run declared up + * front (see `unlock-inventory`) is everything that grant will open before it + * lapses, including the batches that will ride it later without a second panel. + */ + +import path from 'node:path'; +import { VARLOCK_VERSION } from '../varlock-version'; +import { DaemonError, type DaemonClient } from './daemon-client'; +import { unlockInventoryForKey, unlockItemsForKey } from './unlock-inventory'; +import type { + SessionGrantInfo, UnlockDisplayInfo, UnlockInvocationMode, UnlockKeyDisplay, UnlockValueSource, +} from './types'; + +/** + * The project the panel names, as the caller sees it. + * + * The working directory, which is what "the project asking" means to every + * caller here. Client-reported like everything else in the display, and shared + * so a cache unlock and a file unlock name the project the same way. + */ +export function projectDisplay(): { projectName: string; projectPath: string } { + return { projectName: path.basename(process.cwd()), projectPath: process.cwd() }; +} + +/** Thrown when the user was shown the unlock panel and said no */ +export class UnlockDeclinedError extends Error { + constructor() { + super('Unlock was declined'); + this.name = 'UnlockDeclinedError'; + } +} + +/** + * Thrown when there is no screen to ask on. + * + * An unlock needs a person, so an SSH session or a headless host has nowhere to + * put the question. The way out is a key that carries no presence gate. + */ +export class UnlockNoUiError extends Error { + constructor() { + super( + 'This machine has no graphical session to show the unlock panel on, so the ' + + 'encrypted values cannot be opened here.', + ); + this.name = 'UnlockNoUiError'; + } +} + +/** One payload to open, and the device key whose identity wrap opens it */ +export interface IdentityPayloadRequest { + ciphertext: string; + keyId: string; + /** + * The env var this payload belongs to, and the file that defined it. + * + * Both are for the panel only. They travel as display metadata, are never + * bound into the crypto, and a wrong or missing one changes nothing but the + * wording of a row the user can expand. + */ + valueName?: string; + sourceFile?: string; +} + +/** + * Grants this process believes are live, by key id, with the epoch ms they run + * out at. + * + * Only an optimisation: it lets a second batch skip straight to decrypt-v2 + * rather than re-asking for an unlock the daemon would answer trivially. Being + * wrong is harmless, because a decrypt against a dead grant comes back as + * NO_SESSION_GRANT and the retry below opens a new one. + */ +const liveGrants = new Map(); + +/** Forget the grants this process thinks it holds (used by the lock flows) */ +export function clearKnownGrants() { + liveGrants.clear(); +} + +function rememberGrant(grant: SessionGrantInfo | undefined) { + if (grant?.keyId && typeof grant.expiresAt === 'number') { + liveGrants.set(grant.keyId, grant.expiresAt); + } +} + +/** Leaves a little room, so a grant about to lapse is treated as already gone */ +const GRANT_FRESHNESS_MARGIN_MS = 5_000; + +function grantLooksLive(keyId: string): boolean { + const expiresAt = liveGrants.get(keyId); + return expiresAt !== undefined && expiresAt - GRANT_FRESHNESS_MARGIN_MS > Date.now(); +} + +function daemonErrorCode(err: unknown): string | undefined { + return err instanceof DaemonError ? err.code : undefined; +} + +/** Turn the daemon's refusals into errors that say what the user should do */ +function translateSessionError(err: unknown): unknown { + switch (daemonErrorCode(err)) { + case 'APPROVAL_DENIED': return new UnlockDeclinedError(); + case 'NO_UI': return new UnlockNoUiError(); + default: return err; + } +} + +/** + * Open a grant covering every key in one go. + * + * `scope: 'session'` is what makes this a session rather than a per-command + * prompt. `lockOn` is deliberately not sent: the machine config decides which + * system events end the session, and a project must not get to weaken that. + */ +async function unlock( + client: DaemonClient, + keyIds: Array, + display: UnlockDisplayInfo | undefined, + items: Record>, +) { + try { + const result = await client.unlockSession({ + keyIds, scope: 'session', display, items, + }); + for (const grant of result.grants ?? []) rememberGrant(grant); + return result; + } catch (err) { + for (const keyId of keyIds) liveGrants.delete(keyId); + throw translateSessionError(err); + } +} + +/** + * The daemon's answers that mean "ask again", rather than "this failed". + * + * The first two are a grant that died between the unlock and the decrypt: the + * session was locked from the menu bar, or the machine slept. The third is an + * item-scoped grant meeting a ciphertext it was never approved over, which is + * the narrow breadth doing exactly its job. All three are answered the same + * way, because from here they are the same situation: this session does not + * hold what this batch needs, so put it back in front of the user. + */ +const RETRYABLE_GRANT_CODES = new Set(['NO_SESSION_GRANT', 'SESSION_GRANT_EXPIRED', 'GRANT_ITEM_NOT_COVERED']); + +async function decryptGroup( + client: DaemonClient, + keyId: string, + ciphertexts: Array, + display: UnlockDisplayInfo | undefined, + items: Record>, +): Promise> { + try { + const result = await client.decryptV2({ keyId, ciphertexts }); + rememberGrant(result.grant); + return result.plaintexts; + } catch (err) { + if (!RETRYABLE_GRANT_CODES.has(daemonErrorCode(err) ?? '')) { + throw translateSessionError(err); + } + liveGrants.delete(keyId); + await unlock(client, [keyId], display, items); + try { + const retried = await client.decryptV2({ keyId, ciphertexts }); + rememberGrant(retried.grant); + return retried.plaintexts; + } catch (retryErr) { + throw translateSessionError(retryErr); + } + } +} + +/** + * How varlock came to be running here. + * + * `auto-load` is set by whatever spawned this CLI, because from inside the child + * an auto-load and a typed command look identical: both are the varlock CLI. + * Failing that, an entry point named varlock is somebody running varlock, and + * anything else is varlock being used as a library. + */ +export function detectInvocationMode(): UnlockInvocationMode { + const declared = process.env._VARLOCK_INVOCATION_MODE; + if (declared === 'auto-load' || declared === 'cli' || declared === 'sdk') return declared; + const entry = process.argv[1] ? path.basename(process.argv[1]).replace(/\.(c|m)?[jt]s$/, '') : ''; + return entry === 'varlock' ? 'cli' : 'sdk'; +} + +/** Two sources are the same place when they are the same kind of the same name */ +function sourceIdentity(source: UnlockValueSource): string { + const kind = source.kind ?? 'file'; + return kind === 'file' ? `file:${source.path ?? ''}` : kind; +} + +/** + * Fold a later description of a source into the one already listed. + * + * A file described twice (declared by the graph, then decrypted by a batch) is + * one file with the union of its values, so the panel lists it once and the + * count adds up. Anything else, the value cache included, summarises rather + * than enumerates: two summaries cannot be added together, so the later one + * wins, being the fresher read. + */ +function combineSources(existing: UnlockValueSource, incoming: UnlockValueSource): UnlockValueSource { + if ((existing.kind ?? 'file') !== 'file') return { ...incoming }; + const entries = [...(existing.entries ?? [])]; + const seen = new Set(entries.map((entry) => entry.name)); + for (const entry of incoming.entries ?? []) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + entries.push(entry); + } + return { ...existing, ...incoming, entries }; +} + +/** One list of sources from several accounts of the same key, in first-seen order */ +function mergeSources(...accounts: Array>): Array { + const byIdentity = new Map(); + for (const source of accounts.flat()) { + const id = sourceIdentity(source); + const existing = byIdentity.get(id); + byIdentity.set(id, existing ? combineSources(existing, source) : { ...source }); + } + return [...byIdentity.values()]; +} + +/** + * How many values a source stands for, counted the way the panel counts it. + * + * A source that summarises says so with `itemCount`; one that enumerates is its + * entries, each of which may itself stand for several. + */ +function sourceItemCount(source: UnlockValueSource): number { + if (typeof source.itemCount === 'number') return source.itemCount; + return (source.entries ?? []).reduce((total, entry) => total + (entry.count ?? 1), 0); +} + +/** + * Describe what each key is being asked to open. + * + * The panel says who is asking on its own authority, but it cannot know what + * the values are called: that lives in the env graph in this process. So the + * value names and the files that defined them are sent as display metadata, and + * the panel draws them as client-reported. Nothing here is bound into the + * crypto, and the daemon does not check any of it, on purpose: display metadata + * that a decrypt depended on would turn a cosmetic mismatch into a failed + * unlock. + * + * Three accounts of the same key are merged into one list of sources: what the + * run declared before anything resolved, what this batch is decrypting right + * now, and whatever the caller described for itself. Only the first of those + * knows the whole grant. A batch knows what it holds and nothing about the + * batches that will ride the same grant afterwards without a panel, so a panel + * built from a batch alone describes whichever caller happened to be first, + * which is not what the user is approving. + * + * The count follows the sources rather than being reported alongside them, so + * the header and the list underneath it can never tell different stories. Only + * a key nobody described at all falls back to counting this batch. + * + * A caller's own `keys` entries (a vault label and colour, once vaults exist) + * are kept, since only the caller knows those. + */ +function buildDisplayInfo( + payloads: Array, + groups: Map>, + supplied: UnlockDisplayInfo | undefined, +): UnlockDisplayInfo { + const keys: Record = {}; + const itemCounts: Record = {}; + + for (const [keyId, indexes] of groups) { + const suppliedKey = supplied?.keys?.[keyId]; + + // Grouped by file, in the order the files first appear, so the panel reads + // the way the env files were loaded rather than in some hash order. + const byFile = new Map(); + for (const index of indexes) { + const { valueName, sourceFile } = payloads[index]; + if (!valueName) continue; + // Values whose file is unknown still get listed, under no heading. + const groupKey = sourceFile ?? ''; + let file = byFile.get(groupKey); + if (!file) { + file = { kind: 'file', path: sourceFile, entries: [] }; + byFile.set(groupKey, file); + } + file.entries!.push({ name: valueName }); + } + + const sources = mergeSources( + unlockInventoryForKey(keyId), + [...byFile.values()], + suppliedKey?.sources ?? [], + ); + const valueCount = sources.length > 0 + ? sources.reduce((total, source) => total + sourceItemCount(source), 0) + : (suppliedKey?.valueCount ?? indexes.length); + itemCounts[keyId] = valueCount; + + keys[keyId] = { + ...suppliedKey, + valueCount, + ...(sources.length > 0 ? { sources } : {}), + }; + } + + return { + invocationMode: detectInvocationMode(), + // Which build of varlock is asking. The daemon resolves this for itself when + // varlock is running as JavaScript, since it can find the package on disk; + // the standalone binary carries no package to read, so this is the only + // answer available there, and the panel draws it as the caller's claim. + varlockVersion: VARLOCK_VERSION, + ...supplied, + // how much each key is being asked to cover, so the panel can say so + itemCounts, + keys, + }; +} + +/** + * The ciphertexts each key is being asked to cover, for the daemon to hash. + * + * Two accounts, unioned: what the run declared before anything resolved, and + * what this batch is holding. Both are needed, and for the same reason the + * panel's own source list needs both. The declaration knows the whole grant, + * which is what a narrow approval has to be bound to or it will refuse every + * value that rides the grant afterwards. The batch knows what is in hand right + * now, which covers a caller that never declared itself at all. + * + * The value cache is not here on purpose. It is never item scoped: its entries + * are machine-written and rewritten whenever a provider value is renewed, so + * binding a grant to them would put a panel in front of the user on a normal + * dev loop. The daemon covers cache reads by checking its own cache file, which + * is a fact it establishes rather than one this side asserts. + */ +function buildItems( + payloads: Array, + groups: Map>, +): Record> { + const items: Record> = {}; + for (const [keyId, indexes] of groups) { + const ciphertexts = new Set(unlockItemsForKey(keyId)); + for (const index of indexes) ciphertexts.add(payloads[index].ciphertext); + items[keyId] = [...ciphertexts]; + } + return items; +} + +/** + * Open every payload, in payload order, using as few unlocks as possible. + * + * Keys already covered by a grant this process opened are left out of the unlock + * request, so a second batch in the same run costs no panel at all. + */ +export async function decryptIdentityPayloadsViaDaemon( + client: DaemonClient, + payloads: Array, + opts?: { display?: UnlockDisplayInfo }, +): Promise> { + if (payloads.length === 0) return []; + + // group by key, keeping each payload's position so results come back in order + const groups = new Map>(); + for (const [index, payload] of payloads.entries()) { + const existing = groups.get(payload.keyId); + if (existing) existing.push(index); + else groups.set(payload.keyId, [index]); + } + + const keyIds = [...groups.keys()]; + const display = buildDisplayInfo(payloads, groups, opts?.display); + const items = buildItems(payloads, groups); + + const needUnlock = keyIds.filter((keyId) => !grantLooksLive(keyId)); + if (needUnlock.length > 0) await unlock(client, needUnlock, display, items); + + const plaintexts = new Array(payloads.length); + for (const [keyId, indexes] of groups) { + // sequential on purpose: a second unlock racing the first would draw a + // second panel for a session the first one is already opening + + const opened = await decryptGroup( + client, + keyId, + indexes.map((i) => payloads[i].ciphertext), + display, + items, + ); + if (opened.length !== indexes.length) { + throw new Error( + `Daemon returned ${opened.length} plaintexts for ${indexes.length} payloads on key "${keyId}"`, + ); + } + indexes.forEach((payloadIndex, position) => { + plaintexts[payloadIndex] = opened[position]; + }); + } + + return plaintexts; +} diff --git a/packages/varlock/src/lib/local-encrypt/stale-daemon.test.ts b/packages/varlock/src/lib/local-encrypt/stale-daemon.test.ts new file mode 100644 index 000000000..479b3bb7c --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/stale-daemon.test.ts @@ -0,0 +1,151 @@ +/** + * What happens when the daemon outlives an upgrade. + * + * A daemon started before varlock was updated keeps serving the protocol it was + * built with, and the ops this build depends on simply are not there. Rather than + * failing with "Unknown action", the client notices the version, terminates the + * old process, and lets the next connect start one from the binary now on disk. + * Once. If the replacement is also old, the installed helper is old and saying so + * is more useful than restarting forever. + */ + +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { FakeDaemonHarness } from './test/fake-daemon-harness'; + +let harness: FakeDaemonHarness; + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => harness.userVarlockDir, +})); + +vi.mock('./binary-resolver', () => ({ + resolveNativeBinary: () => harness.binaryPath, + getInstalledPlatformPackageName: () => undefined, +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: () => JSON.stringify({ + backend: 'secure-enclave', + hardwareBacked: true, + biometricAvailable: true, + keys: ['varlock-default'], + keyDetails: [{ keyId: 'varlock-default', requireAuth: true }], + }), + }; +}); + +function v2Payload(marker: string): string { + return Buffer.concat([Buffer.from([0x02]), Buffer.from(marker, 'utf-8')]).toString('base64'); +} + +async function loadLocalEncrypt() { + vi.resetModules(); + return import('./index'); +} + +/** Wait for a pid to go away, so the test is not racing the kill */ +async function waitForExit(pid: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!FakeDaemonHarness.isAlive(pid)) return true; + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + return false; +} + +let stderrWrites: Array; + +beforeEach(() => { + harness = new FakeDaemonHarness(); + stderrWrites = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { + stderrWrites.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + harness.cleanup(); +}); + +describe.skipIf(process.platform === 'win32')('a daemon older than this build', () => { + it('is terminated and replaced, with a note on stderr', async () => { + // an old daemon left running from before the upgrade + harness.setConfig({ protocolVersion: 1 }); + const stalePid = await harness.startExistingDaemon(); + + // the binary on disk is the new one, so a restart gets a newer daemon + const payload = v2Payload('after-restart'); + harness.setConfig({ protocolVersion: 3, plaintexts: { [payload]: 'opened' } }); + + const localEncrypt = await loadLocalEncrypt(); + const [plaintext] = await localEncrypt.decryptIdentityPayloads( + [{ ciphertext: payload, keyId: 'varlock-default' }], + ); + + expect(plaintext).toBe('opened'); + expect(await waitForExit(stalePid)).toBe(true); + + const note = stderrWrites.join(''); + expect(note).toContain('protocol v1'); + expect(note).toContain('Restarting it'); + + // the old daemon was asked its version and nothing else: no session op was + // ever sent to a daemon that could not have served it + const oldDaemonCalls = harness.calls().filter((call) => call.pid === stalePid); + expect(oldDaemonCalls.map((call) => call.action)).toEqual(['ping']); + }); + + it('gives up with a reinstall message when the replacement is old too', async () => { + // the binary itself is old here, so whatever gets spawned speaks v1 as well + harness.setConfig({ protocolVersion: 1 }); + await harness.startExistingDaemon(); + + const localEncrypt = await loadLocalEncrypt(); + const err = await localEncrypt + .decryptIdentityPayloads([{ ciphertext: v2Payload('nope'), keyId: 'varlock-default' }]) + .then(() => undefined, (e) => e); + + expect(err).toBeInstanceOf(localEncrypt.StaleDaemonError); + expect(err.message).toContain('speaks protocol v1'); + expect(err.message).toMatch(/Reinstall varlock/); + }); + + it('restarts at most once, however many ops are attempted', async () => { + harness.setConfig({ protocolVersion: 1 }); + await harness.startExistingDaemon(); + + const localEncrypt = await loadLocalEncrypt(); + const attempt = () => localEncrypt + .decryptIdentityPayloads([{ ciphertext: v2Payload('x'), keyId: 'varlock-default' }]) + .then(() => undefined, (e) => e); + + expect(await attempt()).toBeInstanceOf(localEncrypt.StaleDaemonError); + // the second attempt must not kill and respawn all over again + expect(await attempt()).toBeInstanceOf(localEncrypt.StaleDaemonError); + + const restartNotes = stderrWrites.filter((line) => line.includes('Restarting it')); + expect(restartNotes).toHaveLength(1); + }); + + it('leaves a current daemon alone', async () => { + const payload = v2Payload('current'); + harness.setConfig({ protocolVersion: 3, plaintexts: { [payload]: 'fine' } }); + const pid = await harness.startExistingDaemon(); + + const localEncrypt = await loadLocalEncrypt(); + await localEncrypt.decryptIdentityPayloads([{ ciphertext: payload, keyId: 'varlock-default' }]); + + expect(FakeDaemonHarness.isAlive(pid)).toBe(true); + expect(stderrWrites.join('')).not.toContain('Restarting it'); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/test/fake-daemon-harness.ts b/packages/varlock/src/lib/local-encrypt/test/fake-daemon-harness.ts new file mode 100644 index 000000000..cec7b1127 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/test/fake-daemon-harness.ts @@ -0,0 +1,153 @@ +/** + * Test harness for the fake daemon in `fake-daemon.mjs`. + * + * Sets up a throwaway user varlock dir, makes the fake script look like the + * native helper binary, and gives tests a handle on what the daemon was asked. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FAKE_DAEMON_SCRIPT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'fake-daemon.mjs', +); + +/** + * A short-enough base for a unix socket path. + * + * macOS caps `sun_path` around 104 bytes and its `os.tmpdir()` is already deep, + * so a nested socket under it can quietly fail to bind. /tmp is the escape hatch. + */ +function shortTempBase(): string { + const preferred = os.tmpdir(); + return preferred.length > 24 && fs.existsSync('/tmp') ? '/tmp' : preferred; +} + +export interface FakeDaemonConfig { + protocolVersion?: number; + sessionId?: string; + /** ciphertext -> plaintext, since the fake does no real crypto */ + plaintexts?: Record; + /** make `unlock-session` fail with this code */ + unlockError?: { code: string; message?: string }; + /** drop grants once, just before the next decrypt-v2, to stage the retry */ + dropGrantsBeforeDecrypt?: boolean; + /** + * Treat every unlock as if the user picked the narrow breadth, so a grant + * covers only the `items` its unlock named. Stands in for a panel nobody can + * click in a test. + */ + itemScoped?: boolean; +} + +export interface RecordedCall { + action: string; + payload: Record; + pid: number; +} + +export class FakeDaemonHarness { + readonly userVarlockDir: string; + readonly socketDir: string; + readonly socketPath: string; + readonly binaryPath: string; + private manual: ChildProcess | undefined; + + constructor() { + this.userVarlockDir = fs.mkdtempSync(path.join(shortTempBase(), 'vl-')); + this.socketDir = path.join(this.userVarlockDir, 'local-encrypt'); + this.socketPath = path.join(this.socketDir, 'daemon.sock'); + fs.mkdirSync(this.socketDir, { recursive: true }); + + // a copy rather than the original, so its mtime is ours to control and the + // client's stale-binary check compares against something stable + this.binaryPath = path.join(this.userVarlockDir, 'varlock-local-encrypt'); + fs.copyFileSync(FAKE_DAEMON_SCRIPT, this.binaryPath); + fs.chmodSync(this.binaryPath, 0o755); + + this.setConfig({}); + } + + setConfig(config: FakeDaemonConfig) { + fs.writeFileSync( + path.join(this.socketDir, 'fake-daemon.json'), + JSON.stringify(config, null, 2), + ); + } + + /** Every message the daemon has been sent, in order, across restarts */ + calls(): Array { + const logPath = path.join(this.socketDir, 'fake-daemon-calls.jsonl'); + if (!fs.existsSync(logPath)) return []; + return fs.readFileSync(logPath, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as RecordedCall); + } + + callsOf(action: string): Array { + return this.calls().filter((call) => call.action === action); + } + + clearCalls() { + try { + fs.unlinkSync(path.join(this.socketDir, 'fake-daemon-calls.jsonl')); + } catch { /* nothing logged yet */ } + } + + /** + * Start a daemon by hand, the way one left running from an earlier varlock + * would already be there. Writes the same `daemon.info` the client writes when + * it spawns one, so the stale-*binary* check does not fire and whatever the + * test is actually about gets a chance to happen. + */ + async startExistingDaemon(): Promise { + const pidPath = path.join(this.socketDir, 'daemon.pid'); + const child = spawn(this.binaryPath, ['daemon', '--socket-path', this.socketPath, '--pid-path', pidPath], { stdio: ['ignore', 'pipe', 'pipe'] }); + this.manual = child; + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('fake daemon did not start')), 10_000); + child.stdout!.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('"ready"')) { + clearTimeout(timer); + resolve(); + } + }); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + fs.writeFileSync(path.join(this.socketDir, 'daemon.info'), JSON.stringify({ + binaryPath: this.binaryPath, + binaryMtimeMs: fs.statSync(this.binaryPath).mtimeMs, + })); + return child.pid!; + } + + /** Whether a process is still around, for asserting a restart really happened */ + static isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + cleanup() { + this.manual?.kill('SIGKILL'); + // whatever the client spawned during the test is detached from us + try { + const pid = parseInt(fs.readFileSync(path.join(this.socketDir, 'daemon.pid'), 'utf-8').trim(), 10); + if (pid) process.kill(pid, 'SIGKILL'); + } catch { /* already gone */ } + fs.rmSync(this.userVarlockDir, { recursive: true, force: true }); + } +} diff --git a/packages/varlock/src/lib/local-encrypt/test/fake-daemon.mjs b/packages/varlock/src/lib/local-encrypt/test/fake-daemon.mjs new file mode 100644 index 000000000..0d9faa6be --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/test/fake-daemon.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/** + * A stand-in for the native encryption daemon, for tests. + * + * Speaks the same 4-byte LE length-prefixed JSON framing over a unix socket, and + * is spawned the same way the real one is (` daemon --socket-path ...`), + * so `DaemonClient` cannot tell the difference. That is the point: it lets the + * session flow, the unlock retry, and the stale-daemon restart be tested without + * a Secure Enclave, a TPM, or a human with a fingerprint. + * + * Behaviour comes from `fake-daemon.json` in the socket directory, re-read on + * every message so a test can change its mind mid-flight (which is how the + * "grant died between the unlock and the decrypt" case is staged). Every message + * it receives is appended to `fake-daemon-calls.jsonl` for tests to assert on. + * + * It holds no key material and does no crypto: `decrypt-v2` answers from a + * ciphertext-to-plaintext table in the config. Real ECIES is covered elsewhere. + */ + +import net from 'node:net'; +import fs from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +function getArg(name) { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} + +const socketPath = getArg('--socket-path'); +const pidPath = getArg('--pid-path'); +if (!socketPath) { + process.stderr.write('fake-daemon: --socket-path is required\n'); + process.exit(1); +} + +const socketDir = path.dirname(socketPath); +const configPath = path.join(socketDir, 'fake-daemon.json'); +const callLogPath = path.join(socketDir, 'fake-daemon-calls.jsonl'); + +function readConfig() { + try { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + return {}; + } +} + +function writeConfig(config) { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); +} + +function logCall(message) { + try { + fs.appendFileSync(callLogPath, `${JSON.stringify({ + action: message.action, + payload: message.payload ?? {}, + pid: process.pid, + })}\n`); + } catch { /* the log is a convenience, never the thing under test */ } +} + +/** + * Which protocol this daemon speaks, fixed when it starts. + * + * Unlike the rest of the config this is deliberately not re-read: a real daemon's + * protocol is whatever binary it was started from, and it cannot change under a + * running process. Pinning it is what makes the restart observable, because the + * only way to see a different version is for a new process to have started. + */ +const protocolVersionAtStartup = readConfig().protocolVersion ?? 3; + +/** Key ids this "session" currently holds a grant for */ +const grantedKeys = new Set(); +/** + * keyId -> the ciphertexts an item-scoped grant covers. + * + * Only populated when the config asks for `itemScoped`, which stands in for a + * user picking the narrow breadth on the real panel. The real daemon binds to + * SHA-256 digests it computes itself; the fake compares the ciphertexts + * directly, because what is under test on this side is the client's reaction to + * a refusal, not the hashing. + */ +const coveredItems = new Map(); + +function grantFor(keyId, config) { + const now = Date.now(); + return { + sessionId: config.sessionId ?? 'fake-session', + keyId, + identityId: 'default', + scope: 'session', + grantedAt: now, + expiresAt: now + 3_600_000, + sessionUnlockedAt: now, + sessionExpiresAt: now + 12 * 3_600_000, + sessionExpiresInMs: 12 * 3_600_000, + lockOn: 'sleep', + expiresInMs: 3_600_000, + useCount: 1, + breadth: coveredItems.has(keyId) ? 'listed' : 'key', + vaultId: 'local', + ...(coveredItems.has(keyId) ? { coveredItemCount: coveredItems.get(keyId).size } : {}), + }; +} + +function handle(message) { + const config = readConfig(); + const payload = message.payload ?? {}; + const protocolVersion = protocolVersionAtStartup; + + // An old daemon knows none of the session ops, which is exactly how a real + // one behaves and what the stale-daemon restart has to notice. + const isSessionOp = ['unlock-session', 'decrypt-v2', 'list-sessions'].includes(message.action); + if (isSessionOp && protocolVersion < 2) { + return { error: `Unknown action: ${message.action}` }; + } + + switch (message.action) { + case 'ping': + return { + result: { + pong: true, + sessionWarm: grantedKeys.size > 0, + sessionId: config.sessionId ?? 'fake-session', + protocolVersion, + }, + }; + + case 'unlock-session': { + if (config.unlockError) { + return { error: config.unlockError.message ?? 'unlock failed', errorCode: config.unlockError.code }; + } + const keyIds = payload.keyIds ?? (payload.keyId ? [payload.keyId] : []); + for (const keyId of keyIds) { + grantedKeys.add(keyId); + const items = (payload.items ?? {})[keyId]; + if (config.itemScoped && items?.length) coveredItems.set(keyId, new Set(items)); + else coveredItems.delete(keyId); + } + return { + result: { + sessionId: config.sessionId ?? 'fake-session', + policy: 'biometrics', + lockOn: 'sleep', + lockOnSource: 'built-in-default', + prompted: true, + grants: keyIds.map((keyId) => grantFor(keyId, config)), + }, + }; + } + + case 'decrypt-v2': { + const keyId = payload.keyId ?? 'varlock-default'; + const ciphertexts = payload.ciphertexts ?? (payload.ciphertext ? [payload.ciphertext] : []); + + // Staged race: the grant dies between the unlock and the decrypt. Consumed + // once, so the client's single retry is enough to get past it. + if (config.dropGrantsBeforeDecrypt) { + grantedKeys.delete(keyId); + writeConfig({ ...config, dropGrantsBeforeDecrypt: false }); + } + if (!grantedKeys.has(keyId)) { + return { error: `No grant for key ${keyId}`, errorCode: 'NO_SESSION_GRANT' }; + } + + // An item-scoped grant refuses the whole batch when any ciphertext in it + // was not approved over, and stays live: the caller is expected to ask. + const covered = coveredItems.get(keyId); + if (covered && ciphertexts.some((ciphertext) => !covered.has(ciphertext))) { + return { + error: `Key ${keyId} was not approved over every value in this request`, + errorCode: 'GRANT_ITEM_NOT_COVERED', + }; + } + + const table = config.plaintexts ?? {}; + const plaintexts = []; + for (const ciphertext of ciphertexts) { + if (!(ciphertext in table)) { + return { error: `fake-daemon has no plaintext for ${ciphertext.slice(0, 12)}` }; + } + plaintexts.push(table[ciphertext]); + } + return { result: { plaintexts, grant: grantFor(keyId, config) } }; + } + + case 'list-sessions': + return { result: { sessions: [...grantedKeys].map((keyId) => grantFor(keyId, config)) } }; + + case 'invalidate-session': { + const before = grantedKeys.size; + if (payload.keyId) grantedKeys.delete(payload.keyId); + else grantedKeys.clear(); + return { result: { invalidated: before - grantedKeys.size } }; + } + + // v1 device-key decrypt, which answers with a bare string rather than an + // object, exactly as both real daemons do + case 'decrypt': + return { result: (config.plaintexts ?? {})[payload.ciphertext] ?? 'via-daemon' }; + + case 'encrypt': + return { result: `fake-encrypted:${payload.plaintext}` }; + + default: + return { error: `Unknown action: ${message.action}` }; + } +} + +try { + fs.unlinkSync(socketPath); +} catch { /* nothing to clean up */ } + +const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32LE(0); + if (buffer.length < 4 + length) break; + const body = buffer.subarray(4, 4 + length); + buffer = buffer.subarray(4 + length); + + const message = JSON.parse(body.toString()); + logCall(message); + const response = { id: message.id, ...handle(message) }; + const out = Buffer.from(JSON.stringify(response), 'utf-8'); + const prefix = Buffer.alloc(4); + prefix.writeUInt32LE(out.length, 0); + socket.write(Buffer.concat([prefix, out])); + } + }); + socket.on('error', () => { /* a client hanging up is not our problem */ }); +}); + +server.listen(socketPath, () => { + if (pidPath) fs.writeFileSync(pidPath, String(process.pid)); + process.stdout.write(`${JSON.stringify({ ready: true, pid: process.pid })}\n`); +}); + +function shutdown() { + try { + server.close(); + } catch { /* already closing */ } + try { + fs.unlinkSync(socketPath); + } catch { /* already gone */ } + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/packages/varlock/src/lib/local-encrypt/types.ts b/packages/varlock/src/lib/local-encrypt/types.ts index a40f05df2..3b87d03bc 100644 --- a/packages/varlock/src/lib/local-encrypt/types.ts +++ b/packages/varlock/src/lib/local-encrypt/types.ts @@ -25,7 +25,8 @@ export interface BackendInfo { export interface DaemonMessage { id: string; action: 'decrypt' | 'encrypt' | 'prompt-secret' | 'ping' | 'invalidate-session' - | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-set'; + | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-set' + | IdentityDaemonAction; payload?: Record; } @@ -37,6 +38,448 @@ export interface DaemonResponse { errorCode?: string; } +// ── Identity session protocol ────────────────────────────────────────── +// +// Shapes for the identity session ops. The macOS daemon implements these; the +// loader does not call them yet, so these types are what the two sides agree on +// while the client side is built out. +// +// The daemon holds the unwrapped identity private key on behalf of a session, +// so hardware backends can read v2 payloads without the key ever entering this +// process. A grant is what makes that holding legitimate, and it is keyed by +// (sessionId x keyId): the same session unlocking a different key is a separate +// grant, and the same key in a different session is too. + +/** Daemon actions added for identity-backed sessions */ +export type IdentityDaemonAction = 'unlock-session' | 'list-sessions' | 'decrypt-v2' | 'request-approval'; + +/** + * How long a grant survives. + * + * - `once`: a single decrypt, then the grant is spent + * - `session`: until the session it is bound to ends (or the cap is hit) + * - `duration`: a caller-chosen window, still bounded by the cap + */ +export type SessionGrantScope = 'once' | 'session' | 'duration'; + +/** + * How MUCH of a key one approval opens, the second axis beside the duration. + * + * - `listed`: only the ciphertexts that were on the panel when it was approved + * - `key`: anything the key can decrypt, which is what an approval covered + * before there was a choice + * + * A client never asks for a breadth. It sends the payloads it needs (`items` + * below), the daemon hashes them itself, and only a person at the panel can + * choose the narrow answer. Reported back on the grant so a caller can say what + * it is holding, never to be sent up. + * + * The broad answer has a ceiling that is not a control: it stops at the vaults + * the panel showed. A key in a vault that was not on it raises a fresh prompt + * however broad the approval was. See `vaultId`. + */ +export type SessionGrantBreadth = 'listed' | 'key'; + +export const SESSION_GRANT_SCOPES: Array = ['once', 'session', 'duration']; + +/** + * Hard ceiling on any grant, whatever scope or duration was asked for. + * A `session` grant on a session that never ends still expires here. + */ +export const MAX_SESSION_GRANT_MS = 12 * 60 * 60 * 1000; + +/** + * Identifies one grant. + * + * `sessionId` uses the existing session-scoping identity (controlling TTY, or + * the process-tree/agent-env fallback), so a grant cannot be borrowed by an + * unrelated session on the same machine. + */ +export interface SessionGrantRef { + sessionId: string; + keyId: string; +} + +/** + * `unlock-session` payload: open a grant so the daemon may hold the identity key. + * + * `sessionId` is not sent. The daemon resolves the session from the connecting + * process itself, so a caller cannot name its way into someone else's session. + * One unlock covers every key it names, for a single user-presence check. + * + * There is no default key. A request that names none is refused with + * `NO_KEYS_REQUESTED`, rather than opening some key the caller never asked for. + */ +export interface UnlockSessionRequest { + keyIds: Array; + identityId?: string; + scope: SessionGrantScope; + /** only meaningful for scope `duration`; clamped to MAX_SESSION_GRANT_MS */ + durationMs?: number; + /** + * Which system events end this session. Omit to take the machine config, and + * then the built-in default. An unrecognized value is reported by the daemon on + * stderr and ignored, rather than failing the unlock. + */ + lockOn?: SessionLockPolicy; + /** optional context for the approval panel; see `UnlockDisplayInfo` */ + display?: UnlockDisplayInfo; + /** + * The ciphertexts this unlock is being asked to cover, by key id. + * + * The one part of this request the daemon does NOT treat as decoration. It + * hashes each payload itself, and if the user chooses the narrow breadth on + * the panel the resulting grant is bound to exactly those digests: a later + * `decrypt-v2` carrying anything else is refused and raises a fresh panel. + * + * Payloads, not digests. A digest computed on this side would be a digest + * this side chose, and a grant that trusted it would cover whatever the + * client felt like. + * + * Send everything the grant will open, not just what this batch is about: a + * grant is opened once and ridden by whatever asks next, so a request that + * lists only the first batch would narrow the grant to it and prompt again + * for every value that follows. + */ + items?: Record>; +} + +/** + * Extra context for the approval panel, sent by this client. + * + * Decoration only. The daemon works out who is asking from the connecting + * process itself, and those derived lines are what the panel presents as + * trustworthy; anything here is shown as secondary and can never change which + * keys are unlocked, which scopes are offered, or whether a prompt happens. + * The daemon trims and flattens these values before drawing them. + */ +export interface UnlockDisplayInfo { + /** how many encrypted values each key covers, keyed by key id */ + itemCounts?: Record; + projectName?: string; + projectPath?: string; + /** what each key is being asked to open, keyed by key id */ + keys?: Record; + /** how varlock came to be running; see `UnlockInvocationMode` */ + invocationMode?: UnlockInvocationMode; + /** + * which build of varlock is asking, including a build-type suffix for + * anything that is not a release (`1.17.1-dev`) + * + * A claim, like everything else here. Where varlock runs as JavaScript the + * daemon resolves the package itself and prefers its own answer; the + * standalone binary has no package on disk to read, so this is the only + * source there, and the panel says so when it draws it. + */ + varlockVersion?: string; +} + +/** + * How varlock came to be running, as only the client can know. + * + * The daemon reads the command line off the kernel, which is the trustworthy + * half, but a command line cannot say whether varlock was typed or imported: + * an auto-load spawns the same CLI a person would. So the client says which it + * was, the daemon says what the command was, and the panel keeps the two + * apart. + * + * - `cli`: somebody ran a varlock command + * - `auto-load`: a host process loaded varlock (auto-load, a framework integration) + * - `sdk`: varlock is being used as a library in a host process + */ +export type UnlockInvocationMode = 'cli' | 'auto-load' | 'sdk'; + +/** + * What one key covers, as the client sees it. + * + * Display only, and deliberately so: none of this is bound into the crypto, and + * the daemon never checks it against anything it holds. It exists to answer + * "what do they get" on the panel, which the daemon cannot answer on its own + * because the value names live in the caller's env graph. The panel says so on + * the row it draws them in. + */ +export interface UnlockKeyDisplay { + /** how many encrypted values this key covers, across every source below */ + valueCount?: number; + /** + * Where those values live: one entry per source. + * + * Grouping by key is the whole mechanism. Everything a key protects lands in + * this one list, so an env file and the value cache are siblings rather than + * one being the list and the other a special case, and a source kind added + * later slots in beside them with no change to the shape. + */ + sources?: Array; + /** the vault this key belongs to, once there are vaults to belong to */ + vaultLabel?: string; + /** + * The vault's stable id, which is the line a broad approval may not cross. + * + * Falls back to the vault label, and then to the one implicit local vault + * every key is in today. Client-supplied like the rest of this type, and + * harmless in the only direction it can act: it is compared against the vault + * a live grant was approved under, so disagreeing with yourself costs a + * prompt and can do nothing else. + */ + vaultId?: string; + /** the vault's identity colour, as `#rrggbb` */ + vaultColor?: string; +} + +/** + * What kind of place a key's values sit in. + * + * - `file`: an env file, whose entries are the value names it defined + * - `cache`: varlock's value cache, whose entries are what filled it + */ +export type UnlockSourceKind = 'file' | 'cache'; + +/** One place the values behind a key come from */ +export interface UnlockValueSource { + /** defaults to `file` when the caller does not say */ + kind?: UnlockSourceKind; + /** the file that defined these values; file sources only, and only when known */ + path?: string; + /** + * How many values this source contributes. + * + * Only needed when `entries` does not enumerate them one by one, which is the + * cache's case: it reports its providers and how much each contributed rather + * than listing every cached key. + */ + itemCount?: number; + entries?: Array; +} + +/** One thing inside a source: an env value, or a provider that filled the cache */ +export interface UnlockSourceEntry { + name: string; + /** how many values this entry stands for, when it stands for more than one */ + count?: number; +} + +/** + * What ends an unlock session, short of its TTL running out. + * + * The 12h hard cap and explicit invalidation always apply and are not + * configurable: this only decides which system events erase a session. + * + * - `screenLock`: erased by screen lock and by sleep + * - `sleep`: erased by sleep; survives the screen locking + * - `none`: erased only by TTL expiry, the hard cap, or an explicit lock + */ +export type SessionLockPolicy = 'screenLock' | 'sleep' | 'none'; + +export const SESSION_LOCK_POLICIES: Array = ['screenLock', 'sleep', 'none']; + +/** Used when neither the session nor the machine config says otherwise */ +export const DEFAULT_SESSION_LOCK_POLICY: SessionLockPolicy = 'sleep'; + +/** Where an effective lock policy came from */ +export type SessionLockPolicySource = 'session-override' | 'machine-config' | 'built-in-default'; + +/** + * Machine-wide session settings, read by the daemon from the user-level config + * file (`/config.json`, the same file telemetry settings use). + * Never read from project config: a project must not get to weaken how long this + * machine holds keys. + * + * ```json + * { "sessions": { "lockOn": "sleep" } } + * ``` + * + * The daemon reads it fresh at each unlock, so an edit applies to the next unlock + * with no restart. A missing file or section is not an error. + */ +export interface UserConfigSessionSettings { + lockOn?: SessionLockPolicy; +} + +/** How the daemon satisfied user presence for an unlock */ +export type UnlockPolicy = ( + | 'biometrics' + | 'device-owner' // Touch ID, Apple Watch, or the device password + | 'no-presence-required' // key was created with --no-auth (CI) +); + +/** A grant as the daemon reports it back (never includes key material) */ +export interface SessionGrantInfo extends SessionGrantRef { + identityId: string; + scope: SessionGrantScope; + /** epoch ms */ + grantedAt: number; + /** epoch ms; always set, since every scope is capped */ + expiresAt: number; + /** epoch ms of the last decrypt this grant served; absent until first use */ + lastUsedAt?: number; + /** epoch ms when this session was unlocked */ + sessionUnlockedAt: number; + /** epoch ms when the session's 12h cap runs out */ + sessionExpiresAt: number; + /** which system events erase this session, as resolved at unlock time */ + lockOn: SessionLockPolicy; + /** how long this grant still has, as of when the daemon answered */ + expiresInMs: number; + /** how long the session's 12h cap still has, as of when the daemon answered */ + sessionExpiresInMs: number; + /** how many decrypts this grant has served */ + useCount: number; + /** how much of the key this grant opens */ + breadth: SessionGrantBreadth; + /** + * How many distinct ciphertexts an item-scoped grant currently covers. + * + * Absent on a whole-key grant, which covers a number nobody can count. Grows + * over the grant's life where the value cache is involved: cache entries are + * admitted as the daemon verifies them against the cache file, since the + * cache is never item scoped. + */ + coveredItemCount?: number; + /** + * The vault this grant was approved over. + * + * A broad approval reaches other keys only inside the vaults the panel + * showed; a key that now reports a different vault asks again. `local` until + * there are vaults. + */ + vaultId: string; +} + +export interface UnlockSessionResult { + sessionId: string; + policy: UnlockPolicy; + /** the effective lock policy for this session */ + lockOn: SessionLockPolicy; + /** which of the three sources decided it */ + lockOnSource: SessionLockPolicySource; + grants: Array; + /** + * Whether the user was actually shown the approval panel. False when every key + * asked for was already covered by a live grant, or when the key carries no + * presence gate at all. + */ + prompted: boolean; +} + +/** + * `request-approval` payload: put a question on the daemon's panel and report + * the answer. + * + * Generic and stateless. No key operation is attached and the daemon records + * nothing, so the caller keeps its own account of what it was allowed to do. + * Wording is the caller's, but the daemon trims it and draws it as secondary to + * the requester lines it derived itself. + */ +export interface RequestApprovalRequest { + title: string; + descriptionLines?: Array; + /** extra caller-supplied context lines, e.g. the request being approved */ + contextLines?: Array; + /** which scopes the panel may offer; defaults to `once` alone */ + allowedScopes?: Array; + defaultScope?: SessionGrantScope; + /** run a user-presence check after the approve click, on top of the panel */ + requireBiometric?: boolean; + /** label for the confirm button; defaults to "Approve" */ + confirmLabel?: string; +} + +export interface RequestApprovalResult { + decision: 'approved' | 'denied'; + scope: SessionGrantScope; + /** present only when an approved decision chose the `duration` scope */ + durationMs?: number; +} + +/** Error codes the daemon attaches to a malformed `request-approval` */ +export type ApprovalRequestErrorCode = 'APPROVAL_MISSING_TITLE' | 'APPROVAL_NO_SCOPES'; + +/** `list-sessions` result: every live grant the daemon is holding */ +export interface ListSessionsResult { + sessions: Array; +} + +/** + * `invalidate-session` payload. + * + * Omitting both fields drops every grant, which is what today's argument-less + * `invalidate-session` already does. Naming a session drops that session's + * grants; naming both drops exactly one grant. + */ +export interface InvalidateSessionRequest { + sessionId?: string; + keyId?: string; +} + +export interface InvalidateSessionResult { + /** how many grants were dropped */ + invalidated: number; +} + +/** + * `decrypt-v2` payload: decrypt identity-encrypted payloads under a grant. + * + * `keyId` is the device key the identity is wrapped to, not the key the payload + * was encrypted with. There is no implicit unlock: without a live grant the + * daemon refuses (`NO_SESSION_GRANT`) and the caller runs `unlock-session`. + * + * Payloads come as a batch, since a whole env file resolves at once, and the + * batch is one grant use: a `once` grant covers the call however many payloads + * it carried. `sessionId` is not sent; the daemon resolves it from the peer. + */ +export interface DecryptV2Request { + keyId: string; + ciphertexts: Array; + identityId?: string; +} + +export interface DecryptV2Result { + plaintexts: Array; + /** the grant that served this call, after its use was charged */ + grant: SessionGrantInfo; +} + +/** Error codes the daemon attaches to identity session failures */ +export type IdentitySessionErrorCode = ( + | 'NO_SESSION_GRANT' // nothing unlocked for this (session x key) + | 'SESSION_GRANT_EXPIRED' // the grant or its session cap ran out + // the grant is live but item scoped, and this batch carries a ciphertext it + // was not approved over. Not a failure: unlock again and the panel asks. + | 'GRANT_ITEM_NOT_COVERED' + | 'SESSION_KEY_MISSING' // daemon no longer holds the key (restarted, or locked) + | 'NO_SESSION_IDENTITY' // the caller's session could not be identified + | 'NO_KEYS_REQUESTED' // the unlock named no key, so there was nothing to open + | 'APPROVAL_DENIED' // the user was shown the panel and said no + | 'NO_UI' // no screen to ask on (SSH, headless); tell the user in the terminal + | 'BIOMETRIC_FAILED' + | 'IDENTITY_NOT_FOUND' + | 'IDENTITY_MALFORMED' + | 'IDENTITY_VERSION_UNSUPPORTED' + | 'IDENTITY_NO_WRAP_FOR_KEY' +); + +/** + * Protocol version this build of varlock expects from the daemon. + * + * 1 (reported as an absent `protocolVersion`) is a daemon predating identity + * sessions. 2 speaks the identity session ops. 3 draws the approval panel, which + * means unlock-session can answer APPROVAL_DENIED or NO_UI, and request-approval + * exists. A client that needs any of those can compare against this to tell a + * stale daemon from one that speaks them. + */ +export const DAEMON_PROTOCOL_VERSION = 3; + +/** `ping` result */ +export interface DaemonPingResult { + pong: boolean; + /** whether this session already holds a cached biometric context */ + sessionWarm: boolean; + /** the session identity the daemon resolved for this process, if any */ + sessionId?: string; + /** absent on daemons older than the identity session ops, which means 1 */ + protocolVersion: number; +} + /** Metadata about a keychain item (no secret values) */ export interface KeychainItemMeta { service: string; @@ -64,10 +507,21 @@ export interface KeychainSetResult { updated: boolean; } +/** Per-key metadata reported by a native binary */ +export interface NativeKeyDetail { + keyId: string; + /** Should decrypts of this key require user-presence verification when a gate is available? */ + requireAuth: boolean; + protection?: string; + createdAt?: string; +} + /** Result from the status command of a native binary */ export interface NativeStatusResult { backend: string; hardwareBacked: boolean; biometricAvailable: boolean; keys: Array; + /** Present only on binaries that report per-key metadata */ + keyDetails?: Array; } diff --git a/packages/varlock/src/lib/local-encrypt/unlock-inventory.ts b/packages/varlock/src/lib/local-encrypt/unlock-inventory.ts new file mode 100644 index 000000000..bd07e03ef --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/unlock-inventory.ts @@ -0,0 +1,146 @@ +/** + * What one unlock covers, gathered before anything asks for it. + * + * A grant is opened once and then spent by whatever asks next, so the batch + * that happens to ask first must not be the only thing the panel describes. + * Two encrypted values in `.env.local` and a populated value cache are opened + * by two different callers at two different moments, and whichever got there + * first used to define the whole panel: the user approved a session on partial + * information, which is precisely what the panel exists to prevent. + * + * This is where the callers meet. Each one declares what it holds as soon as it + * knows (the env graph at the end of its load, the value cache when it becomes + * the run's store), and the unlock reads the union. Nothing is delayed waiting + * for a declaration: a source that has not spoken up yet is simply not listed, + * because under-promising is the only safe direction on a panel someone is + * about to approve. + * + * Display only, like every other line the daemon draws from a caller: none of + * it is bound into the crypto, the daemon checks none of it, and being wrong + * changes nothing but the wording of a row. + */ + +import type { UnlockValueSource } from './types'; + +/** One encrypted value, and the file that defined it */ +export type DeclaredEncryptedValue = { + keyId: string; + valueName?: string; + /** the file as the panel should name it, or undefined when it is not known */ + sourceFile?: string; + /** + * The value's own ciphertext. + * + * The one thing declared here that is not display. It goes to the daemon as + * a payload, the daemon hashes it, and an item-scoped grant is bound to that + * digest. Declaring it up front is what makes the narrow choice usable: a + * grant narrowed to the batch that happened to ask first would refuse every + * value that rides it afterwards, so the whole run's values are named before + * anything resolves. + */ + ciphertext?: string; +}; + +/** keyId -> (file heading -> the values it defined), in first-seen order */ +const declaredFiles = new Map>(); +/** keyId -> what the value cache on that key holds */ +const declaredCaches = new Map(); +/** + * keyId -> the ciphertexts a grant on that key will be asked to open. + * + * Files only. The value cache is deliberately absent: it is never item scoped + * (its entries are machine-written and rewritten constantly, so narrowing to + * them would prompt on every provider refresh), and the daemon covers it by + * reading its own cache file rather than by anything sent from here. + */ +const declaredCiphertexts = new Map>(); + +/** + * Declare the encrypted values a graph load found, replacing whatever the last + * load declared. + * + * Replacing rather than accumulating is what keeps a long-lived process (a dev + * server reloading its env) honest: a value that has since been deleted must + * stop being listed as something the next unlock hands over. + */ +export function declareEncryptedFileValues(values: Array) { + declaredFiles.clear(); + declaredCiphertexts.clear(); + for (const { + keyId, valueName, sourceFile, ciphertext, + } of values) { + if (!keyId || !valueName) continue; + if (ciphertext) { + let items = declaredCiphertexts.get(keyId); + if (!items) { + items = new Set(); + declaredCiphertexts.set(keyId, items); + } + items.add(ciphertext); + } + let byFile = declaredFiles.get(keyId); + if (!byFile) { + byFile = new Map(); + declaredFiles.set(keyId, byFile); + } + // Values whose file is unknown are grouped together and listed under no + // heading, the same way a batch lists them. + const groupKey = sourceFile ?? ''; + let source = byFile.get(groupKey); + if (!source) { + source = { kind: 'file', path: sourceFile, entries: [] }; + byFile.set(groupKey, source); + } + if (!source.entries!.some((entry) => entry.name === valueName)) { + source.entries!.push({ name: valueName }); + } + } +} + +/** + * Declare what the value cache on a key holds, or drop it with no source. + * + * Dropping matters as much as declaring: a run whose cache is disabled or + * memory-backed must not inherit a line from the run before it, since that + * grant will never open a cache file. + */ +export function declareCacheInventory(keyId: string, source?: UnlockValueSource) { + if (!source) declaredCaches.delete(keyId); + else declaredCaches.set(keyId, source); +} + +/** Forget every cache declaration, so a run can declare only the store it uses */ +export function clearDeclaredCacheInventories() { + declaredCaches.clear(); +} + +/** + * Everything declared for one key, files first and the cache last. + * + * The order is fixed here rather than left to whoever declared first, so the + * panel reads the same way whichever source triggers the unlock. + */ +export function unlockInventoryForKey(keyId: string): Array { + const sources = [...(declaredFiles.get(keyId)?.values() ?? [])]; + const cache = declaredCaches.get(keyId); + if (cache) sources.push(cache); + return sources; +} + +/** + * The ciphertexts a grant on this key will be asked to open, so far. + * + * What an item-scoped approval would be bound to, once the daemon has hashed + * them. Empty when nothing declared itself, and the panel then offers no narrow + * choice rather than one that would open nothing. + */ +export function unlockItemsForKey(keyId: string): Array { + return [...(declaredCiphertexts.get(keyId) ?? [])]; +} + +/** Forget everything declared (used by tests and the lock flows) */ +export function clearUnlockInventory() { + declaredFiles.clear(); + declaredCaches.clear(); + declaredCiphertexts.clear(); +} diff --git a/packages/varlock/src/lib/local-encrypt/unlock-preferences.test.ts b/packages/varlock/src/lib/local-encrypt/unlock-preferences.test.ts new file mode 100644 index 000000000..aef275aaf --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/unlock-preferences.test.ts @@ -0,0 +1,100 @@ +/** + * Forgetting remembered unlock narrowings from this side. + * + * The daemon writes the file; this only ever deletes rows out of it. So the + * tests are about not deleting more than asked, and about doing nothing at all + * when there is nothing to do. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; + +let userVarlockDir: string; + +vi.mock('../user-config-dir', () => ({ + getUserVarlockDir: () => userVarlockDir, +})); + +async function loadModule() { + vi.resetModules(); + return import('./unlock-preferences'); +} + +/** The daemon's own row key: project path, a NUL, then the key id */ +function row(projectPath: string, keyId: string) { + return `${projectPath}\u0000${keyId}`; +} + +function writeFileWith(rows: Record) { + fs.writeFileSync( + path.join(userVarlockDir, 'unlock-preferences.json'), + JSON.stringify({ version: 1, projects: rows }, null, 2), + ); +} + +function readRows(): Record { + const raw = fs.readFileSync(path.join(userVarlockDir, 'unlock-preferences.json'), 'utf-8'); + return JSON.parse(raw).projects; +} + +beforeEach(() => { + userVarlockDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vl-prefs-')); +}); + +afterEach(() => { + fs.rmSync(userVarlockDir, { recursive: true, force: true }); +}); + +describe('forgetting remembered unlock choices', () => { + it('forgets one project and leaves the others alone', async () => { + writeFileWith({ + [row('/code/acme', 'varlock-default')]: { breadth: 'listed', approvedBefore: true }, + [row('/code/acme', 'other-key')]: { scope: 'once', approvedBefore: true }, + [row('/code/elsewhere', 'varlock-default')]: { breadth: 'listed', approvedBefore: true }, + }); + + const { forgetUnlockPreferences } = await loadModule(); + expect(forgetUnlockPreferences({ projectPath: '/code/acme' })).toBe(2); + expect(Object.keys(readRows())).toEqual([row('/code/elsewhere', 'varlock-default')]); + }); + + it('forgets everything when no project is named', async () => { + writeFileWith({ + [row('/code/acme', 'varlock-default')]: { breadth: 'listed', approvedBefore: true }, + [row('/code/elsewhere', 'varlock-default')]: { breadth: 'listed', approvedBefore: true }, + }); + + const { forgetUnlockPreferences } = await loadModule(); + expect(forgetUnlockPreferences()).toBe(2); + expect(readRows()).toEqual({}); + }); + + it('reports nothing to forget rather than creating a file', async () => { + const { forgetUnlockPreferences, unlockPreferencesPath } = await loadModule(); + expect(forgetUnlockPreferences()).toBe(0); + expect(fs.existsSync(unlockPreferencesPath())).toBe(false); + }); + + it('leaves a file it cannot parse exactly as it found it', async () => { + const filePath = path.join(userVarlockDir, 'unlock-preferences.json'); + fs.writeFileSync(filePath, 'half a write'); + + const { forgetUnlockPreferences } = await loadModule(); + expect(forgetUnlockPreferences()).toBe(0); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('half a write'); + }); + + it('does not rewrite the file when the named project has nothing in it', async () => { + writeFileWith({ [row('/code/acme', 'varlock-default')]: { breadth: 'listed', approvedBefore: true } }); + const filePath = path.join(userVarlockDir, 'unlock-preferences.json'); + const before = fs.readFileSync(filePath, 'utf-8'); + + const { forgetUnlockPreferences } = await loadModule(); + expect(forgetUnlockPreferences({ projectPath: '/code/nothing-here' })).toBe(0); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/varlock/src/lib/local-encrypt/unlock-preferences.ts b/packages/varlock/src/lib/local-encrypt/unlock-preferences.ts new file mode 100644 index 000000000..04eab5422 --- /dev/null +++ b/packages/varlock/src/lib/local-encrypt/unlock-preferences.ts @@ -0,0 +1,64 @@ +/** + * The narrowings this Mac remembers from unlock panels. + * + * Written by the daemon, which is the side that saw the answer. Read here only + * to forget them: the file lives in the user's own varlock directory, so a + * `varlock lock --forget-preferences` is a file edit rather than a round trip + * through a daemon that may not even be running. + * + * Choosing the broad default on the panel already forgets a narrowing, which is + * the way most people will do it. This is for the case where you want them gone + * without waiting to be asked again. + * + * The format is owned by `UnlockPreferences.swift`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { getUserVarlockDir } from '../user-config-dir'; + +const FILE_NAME = 'unlock-preferences.json'; +/** Rows are keyed `\0` */ +const ROW_SEPARATOR = '\u0000'; + +export function unlockPreferencesPath(): string { + return path.join(getUserVarlockDir(), FILE_NAME); +} + +/** + * Forget remembered narrowings. + * + * Omitting `projectPath` forgets every one on the machine. Returns how many + * rows were dropped, so the caller can say something true about a file that + * was already empty. + */ +export function forgetUnlockPreferences(opts?: { projectPath?: string }): number { + const filePath = unlockPreferencesPath(); + let parsed: any; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + // No file, or one nothing can read. Either way there is nothing to forget, + // and rewriting a file we could not parse would throw away more than asked. + return 0; + } + const rows = (parsed?.projects ?? {}) as Record; + const keys = Object.keys(rows); + if (keys.length === 0) return 0; + + const kept: Record = {}; + let forgotten = 0; + for (const key of keys) { + const rowProject = key.split(ROW_SEPARATOR)[0]; + if (!opts?.projectPath || opts.projectPath === rowProject) forgotten += 1; + else kept[key] = rows[key]; + } + if (forgotten === 0) return 0; + + fs.writeFileSync( + filePath, + `${JSON.stringify({ ...parsed, projects: kept }, null, 2)}\n`, + { mode: 0o600 }, + ); + return forgotten; +} diff --git a/packages/varlock/src/lib/test/exec-sync-varlock.test.ts b/packages/varlock/src/lib/test/exec-sync-varlock.test.ts index ae00b19e4..87b995448 100644 --- a/packages/varlock/src/lib/test/exec-sync-varlock.test.ts +++ b/packages/varlock/src/lib/test/exec-sync-varlock.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach, } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; import { integrationTelemetryEnv, execSyncVarlock } from '../exec-sync-varlock'; @@ -13,9 +15,15 @@ describe('execSyncVarlock integration telemetry', () => { beforeEach(() => { vi.mocked(execSync).mockClear(); vi.mocked(execFileSync).mockClear(); + // These cases are about the env handed to the child, which is shared by + // both ways of finding varlock. Pin them to the PATH lookup by making the + // local install invisible, so they do not quietly change meaning with + // whatever node_modules the suite happens to run inside. + vi.spyOn(fs, 'existsSync').mockReturnValue(false); }); afterEach(() => { + vi.mocked(fs.existsSync).mockRestore(); delete process.env.__VARLOCK_INTEGRATION; }); @@ -94,3 +102,42 @@ describe('execSyncVarlock integration telemetry', () => { ); }); }); + +describe('execSyncVarlock picks which varlock to run', () => { + beforeEach(() => { + vi.mocked(execSync).mockClear(); + vi.mocked(execFileSync).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('runs the project\'s own varlock rather than one on PATH', () => { + // Only node_modules/.bin/varlock under the search root exists. + vi.spyOn(fs, 'existsSync').mockImplementation( + (candidate) => String(candidate).includes(`node_modules${path.sep}.bin`), + ); + + execSyncVarlock('load', { cwd: path.join(path.sep, 'proj') }); + + expect(execFileSync).toHaveBeenCalledWith( + path.join(path.sep, 'proj', 'node_modules', '.bin', 'varlock'), + ['load'], + expect.anything(), + ); + // A globally installed varlock must not answer for the project's own: it + // is a different version, and which one ran would depend on whether the + // process happened to be started by a package manager script. + expect(execSync).not.toHaveBeenCalled(); + }); + + it('falls back to PATH when the project has no varlock installed', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + + execSyncVarlock('load', { cwd: path.join(path.sep, 'proj') }); + + expect(execFileSync).not.toHaveBeenCalled(); + expect(execSync).toHaveBeenCalledWith('varlock load', expect.anything()); + }); +}); diff --git a/packages/varlock/src/lib/varlock-version.ts b/packages/varlock/src/lib/varlock-version.ts new file mode 100644 index 000000000..95f045e16 --- /dev/null +++ b/packages/varlock/src/lib/varlock-version.ts @@ -0,0 +1,22 @@ +import packageJson from '../../package.json'; + +/** + * Which build of varlock this is, including the build type when it is not a + * release: `1.17.1`, or `1.17.1-dev`. + * + * The suffix is not cosmetic. A dev or preview build is not the artifact the + * release pipeline produced, and that is exactly the sort of thing worth + * noticing on an approval prompt, so anything that reports a version reports + * this one rather than the bare number from package.json. + * + * `__VARLOCK_BUILD_TYPE__` is substituted at build time and does not exist when + * a source file is run directly, which some tests do: reading it bare would + * throw a ReferenceError at import time, in a module whose only job is to name + * a version. So it is read defensively, and a source tree with no build behind + * it reports the bare package version. + */ +const buildType = typeof __VARLOCK_BUILD_TYPE__ === 'undefined' ? 'release' : __VARLOCK_BUILD_TYPE__; + +export const VARLOCK_VERSION = buildType === 'release' + ? packageJson.version + : `${packageJson.version}-${buildType}`; diff --git a/packages/varlock/test/cli-lazy-loading.test.ts b/packages/varlock/test/cli-lazy-loading.test.ts index 7375acfa8..482a666ea 100644 --- a/packages/varlock/test/cli-lazy-loading.test.ts +++ b/packages/varlock/test/cli-lazy-loading.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, relative, @@ -8,6 +8,7 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const PKG_DIR = join(__dirname, '..'); const ENTRY = join(PKG_DIR, 'dist/cli/cli-executable.mjs'); +const ENTRY_SRC = join(PKG_DIR, 'src/cli/cli-executable.ts'); /** * The CLI entry must only ever parse command *specs* at startup - every command @@ -24,7 +25,11 @@ const ENTRY = join(PKG_DIR, 'dist/cli/cli-executable.mjs'); * entry would reintroduce exactly that, so the assertion has to be made against * the artifact. * - * `test:ci` dependsOn `build` in turbo.json, so dist is present and current here. + * `test:ci` dependsOn `build` in turbo.json, so dist is present and current when + * this runs through turbo. Invoking vitest directly (including + * `bun run --filter varlock test:ci`, which does not go through turbo) skips that + * build, so the staleness guard below covers the case where dist predates the + * source this compares it against. */ /** Static (non-dynamic) relative imports of a built chunk. */ @@ -90,6 +95,28 @@ describe('CLI startup bundle boundaries', () => { return; } + // The last check reads the registration list out of the entry's *source* and + // looks for a matching chunk in the *built* entry, so a dist older than that + // source is being compared against a build that predates it. A command added + // since the last build then looks like one that was never made lazy, which + // sends you hunting a bug that is not there. Say what actually happened. + // + // Only this one source file is checked, since it is the only one the + // comparison reads: editing anything else in src does not invalidate it, and + // making every edit turn this red would just be noise. Running through turbo + // cannot trip it, because `test:ci` dependsOn `build` and a restored cache + // writes its outputs fresh. Running vitest directly against a stale dist can. + if (statSync(ENTRY).mtimeMs < statSync(ENTRY_SRC).mtimeMs) { + it('requires a current build', () => { + throw new Error([ + `${relative(PKG_DIR, ENTRY)} is older than ${relative(PKG_DIR, ENTRY_SRC)},`, + 'so this would check the command list against a stale bundle.', + 'Run `bun run build` (or `bun run test:ci` from the repo root, which builds first).', + ].join('\n')); + }); + return; + } + const { chunks, missing } = staticClosure(ENTRY); const unmapped = chunks.filter((c) => sourcesOf(c) === null).map((c) => relative(PKG_DIR, c)).sort(); const eagerSources = new Set(chunks.flatMap((c) => sourcesOf(c) ?? []));