From bcf1393e5241d54bd80f0a564cc4eeda817147ae Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 7 Jul 2026 11:39:50 -0700 Subject: [PATCH] feat(local-encrypt): multiple keys with per-key biometric opt-in/out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for multiple device-local encryption keys, each with its own presence-gate behavior, and lets projects/values choose which key to use. - `varlock keys` (list/create) — create named keys; `--no-auth` opts a key out of presence verification for unattended use (servers/CI) - Per-key `requireAuth` recorded in key metadata across all backends (Rust StoredKey field, Swift SE sidecar since the ACL can't be read back); legacy keys normalize to "requires auth" - decrypt routing honors the per-key flag: a no-auth key takes the one-shot path and never warms the biometric session for auth-required keys - `@defaultLocalKey` root decorator sets the project default key; per-value override via `varlock("local:...", key="...")`; `varlock encrypt --key-id` emits the key= arg only when it differs from the default - the default key also encrypts the disk cache (per-project cache file + inherited presence behavior) - device-bound decrypt errors now hint when a referenced key is missing Docs: local-encryption guide "Choosing a key", @defaultLocalKey reference, `varlock keys` + `encrypt --key-id` in the CLI reference. --- .bumpy/local-encrypt-multikey.md | 5 + packages/encryption-binary-rust/src/daemon.rs | 30 +++- .../src/key_store/mod.rs | 90 +++++++++++- packages/encryption-binary-rust/src/main.rs | 12 +- .../VarlockEnclave/SecureEnclaveManager.swift | 54 ++++++- .../swift/Sources/VarlockEnclave/main.swift | 12 +- .../content/docs/guides/local-encryption.mdx | 40 +++++ .../content/docs/reference/cli-commands.mdx | 29 ++++ .../docs/reference/root-decorators.mdx | 20 +++ packages/varlock/src/cli/cli-executable.ts | 2 + .../src/cli/commands/encrypt.command.ts | 72 ++++++--- .../varlock/src/cli/commands/keys.command.ts | 139 ++++++++++++++++++ .../varlock/src/env-graph/lib/decorators.ts | 15 ++ .../varlock/src/env-graph/lib/env-graph.ts | 35 ++++- .../env-graph/test/default-local-key.test.ts | 58 ++++++++ packages/varlock/src/lib/cache/cache-store.ts | 8 + .../local-encrypt/builtin-resolver.test.ts | 81 ++++++++++ .../src/lib/local-encrypt/builtin-resolver.ts | 89 ++++++++--- .../varlock/src/lib/local-encrypt/index.ts | 75 ++++++++-- .../varlock/src/lib/local-encrypt/types.ts | 11 ++ 20 files changed, 814 insertions(+), 63 deletions(-) create mode 100644 .bumpy/local-encrypt-multikey.md create mode 100644 packages/varlock/src/cli/commands/keys.command.ts create mode 100644 packages/varlock/src/env-graph/test/default-local-key.test.ts diff --git a/.bumpy/local-encrypt-multikey.md b/.bumpy/local-encrypt-multikey.md new file mode 100644 index 000000000..73b61eded --- /dev/null +++ b/.bumpy/local-encrypt-multikey.md @@ -0,0 +1,5 @@ +--- +varlock: minor +--- + +Add multiple local-encryption keys: create keys with per-key biometric opt-in/out (varlock keys create [--no-auth]), select a project default via @defaultLocalKey, or per-value with key=. The chosen key also encrypts the disk cache. diff --git a/packages/encryption-binary-rust/src/daemon.rs b/packages/encryption-binary-rust/src/daemon.rs index ba3bc2b7b..6af6c0699 100644 --- a/packages/encryption-binary-rust/src/daemon.rs +++ b/packages/encryption-binary-rust/src/daemon.rs @@ -199,12 +199,25 @@ fn handle_decrypt( .and_then(|v| v.as_str()) .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); + // Per-key presence-gate intent: keys generated with --no-auth opt out of + // user-presence verification even when a gate is available on the machine. + // Legacy keys (no requireAuth field) keep the historical prompt-when-available behavior. + let key_requires_auth = key_store::get_key_meta(key_id) + .map(|m| m.require_auth) + .unwrap_or(true); + // Check if biometric verification is needed + let session_was_warm = sm.lock().map(|s| s.is_session_warm(tty_id)).unwrap_or(false); + let needs_bio = key_requires_auth + && sm.lock().map(|s| s.needs_biometric(tty_id)).unwrap_or(false); + + // Track whether presence was actually verified in THIS call — a decrypt that + // skipped verification due to key policy must NOT warm the session, or a + // no-auth key decrypt would let a later auth-required key skip its prompt. + let mut presence_verified = false; if needs_bio { match verify_user_presence() { - Ok(true) => {} // Verified — proceed + Ok(true) => presence_verified = true, // Verified — proceed Ok(false) => return json!({"error": "User verification cancelled"}), Err(e) => return json!({"error": format!("Biometric verification failed: {e}")}), } @@ -225,9 +238,14 @@ fn handle_decrypt( Ok(plaintext_bytes) => { match String::from_utf8(plaintext_bytes) { Ok(plaintext) => { - // Mark session as warm - if let Ok(mut session) = sm.lock() { - session.mark_session_warm(tty_id); + // Mark/extend the warm session — but only when presence was + // actually proven (this call or an earlier one in the session). + // A decrypt that skipped verification due to key policy must + // not grant warmth to auth-required keys. + if presence_verified || session_was_warm { + if let Ok(mut session) = sm.lock() { + session.mark_session_warm(tty_id); + } } json!({"result": plaintext}) } diff --git a/packages/encryption-binary-rust/src/key_store/mod.rs b/packages/encryption-binary-rust/src/key_store/mod.rs index 12fdd1c5e..140897be0 100644 --- a/packages/encryption-binary-rust/src/key_store/mod.rs +++ b/packages/encryption-binary-rust/src/key_store/mod.rs @@ -89,6 +89,29 @@ pub struct StoredKey { /// How the private key is protected pub protection: Protection, pub created_at: String, + /// Per-key presence-gate intent: should decrypts of this key require + /// user-presence verification (polkit / Windows Hello) when a gate is + /// available on the machine? + /// + /// This is a *policy tag* consulted by varlock's own decrypt routing — + /// on Windows/Linux the underlying seal is not presence-bound, so it is + /// consent-grade, not a cryptographic guarantee. + /// + /// `None` (legacy keys, pre-dating this field) behaves as `true`: + /// prompt whenever the machine has a gate — the historical behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_auth: Option, +} + +/// Public (non-secret) metadata about a stored key, for status/list output. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyMeta { + pub key_id: String, + pub protection: Protection, + /// Effective presence-gate intent (legacy `None` normalized to `true`) + pub require_auth: bool, + pub created_at: String, } /// Information about what key protection is available on this platform. @@ -414,7 +437,9 @@ 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 { +/// +/// `require_auth` records the key's presence-gate intent (see [`StoredKey::require_auth`]). +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 +455,7 @@ pub fn generate_key(key_id: &str) -> Result { protected_private_key: protected, protection, created_at: now_iso8601(), + require_auth: Some(require_auth), }; write_stored_key(&stored)?; @@ -437,6 +463,29 @@ pub fn generate_key(key_id: &str) -> Result { Ok(key_pair.public_key) } +/// Read a key's public metadata (no secret material). Legacy keys without +/// a `requireAuth` field normalize to `require_auth: true`. +pub fn get_key_meta(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(KeyMeta { + key_id: stored.key_id, + protection: stored.protection, + require_auth: stored.require_auth.unwrap_or(true), + created_at: stored.created_at, + }) +} + +/// List metadata for all stored keys (skips unreadable/corrupted files). +pub fn list_key_meta() -> Vec { + list_keys() + .iter() + .filter_map(|key_id| get_key_meta(key_id).ok()) + .collect() +} + /// Re-wrap an existing key with the best available protection (e.g. DPAPI → NCrypt). /// Returns the new protection type. Public key and key id are preserved. /// @@ -596,7 +645,7 @@ fn is_leap_year(y: i64) -> bool { #[cfg(test)] mod tests { - use super::{protection_rank, scalar, Protection}; + use super::{protection_rank, scalar, Protection, StoredKey}; use crate::crypto; #[test] @@ -605,6 +654,43 @@ mod tests { assert_eq!(json, "\"ncrypt\""); } + #[test] + fn legacy_key_file_without_require_auth_deserializes_as_none() { + // Key files written before the requireAuth field must load cleanly + // and normalize to "requires auth" (the historical behavior). + let json = r#"{ + "keyId": "varlock-default", + "publicKey": "cHVi", + "protectedPrivateKey": "cHJpdg==", + "protection": "dpapi", + "createdAt": "2024-01-01T00:00:00Z" + }"#; + let stored: StoredKey = serde_json::from_str(json).unwrap(); + assert_eq!(stored.require_auth, None); + assert!(stored.require_auth.unwrap_or(true)); + } + + #[test] + fn require_auth_round_trips_and_is_omitted_when_none() { + let stored = StoredKey { + key_id: "k".into(), + public_key: "cHVi".into(), + protected_private_key: String::new(), + protection: Protection::None, + created_at: "2024-01-01T00:00:00Z".into(), + require_auth: Some(false), + }; + let json = serde_json::to_string(&stored).unwrap(); + assert!(json.contains("\"requireAuth\":false")); + let back: StoredKey = serde_json::from_str(&json).unwrap(); + assert_eq!(back.require_auth, Some(false)); + + // None must be omitted entirely (keeps legacy readers happy) + let legacy_style = StoredKey { require_auth: None, ..stored }; + let json = serde_json::to_string(&legacy_style).unwrap(); + assert!(!json.contains("requireAuth")); + } + #[test] fn scalar_helpers_round_trip_via_mod() { let kp = crypto::generate_key_pair().unwrap(); diff --git a/packages/encryption-binary-rust/src/main.rs b/packages/encryption-binary-rust/src/main.rs index e7cef066a..8447a463d 100644 --- a/packages/encryption-binary-rust/src/main.rs +++ b/packages/encryption-binary-rust/src/main.rs @@ -77,14 +77,19 @@ fn json_success(result: serde_json::Value) -> ! { fn cmd_generate_key(args: &[String]) { let key_id = get_key_id(args); + // --no-auth: record that decrypts of this key should NOT require user-presence + // verification even when a gate (polkit / Windows Hello) is available. + // 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), @@ -114,7 +119,8 @@ fn cmd_rewrap_key(args: &[String]) { fn cmd_list_keys() { let keys = key_store::list_keys(); - json_success(json!({"keys": keys})); + let key_details = key_store::list_key_meta(); + json_success(json!({"keys": keys, "keyDetails": key_details})); } fn cmd_key_exists(args: &[String]) { @@ -221,6 +227,7 @@ fn cmd_decrypt(args: &[String]) { fn cmd_status() { let info = key_store::get_platform_info(); let keys = key_store::list_keys(); + let key_details = key_store::list_key_meta(); #[allow(unused_mut)] let mut result = json!({ @@ -231,6 +238,7 @@ fn cmd_status() { "platform": std::env::consts::OS, "arch": std::env::consts::ARCH, "keys": keys, + "keyDetails": key_details, }); // Include setup hints for optional features. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift index f79db9509..cfeaa5a02 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/SecureEnclaveManager.swift @@ -27,6 +27,13 @@ final class SecureEnclaveManager { return keyStorePath + "/\(keyId).keydata" } + /// Sidecar metadata file for a key. The Secure Enclave access-control flags + /// can't be read back from a key's data representation, so we persist the + /// choice made at generation time (currently just `requireAuth`). + private static func metaFilePath(for keyId: String) -> String { + return keyStorePath + "/\(keyId).meta.json" + } + // MARK: - Key Management /// Create a new Secure Enclave P-256 key. @@ -87,12 +94,29 @@ final class SecureEnclaveManager { ofItemAtPath: filePath ) + // Persist generation-time metadata (the SE ACL is write-only, so this + // sidecar is the only record of whether the key requires presence). + let meta: [String: Any] = [ + "keyId": keyId, + "requireAuth": requireAuth, + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + if let metaData = try? JSONSerialization.data(withJSONObject: meta) { + let metaPath = metaFilePath(for: keyId) + try? metaData.write(to: URL(fileURLWithPath: metaPath)) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: metaPath + ) + } + return Data(privateKey.publicKey.x963Representation) } - /// Delete a key by removing its data representation file. + /// Delete a key by removing its data representation file (and metadata sidecar). static func deleteKey(keyId: String) -> Bool { let filePath = keyFilePath(for: keyId) + try? FileManager.default.removeItem(atPath: metaFilePath(for: keyId)) do { try FileManager.default.removeItem(atPath: filePath) return true @@ -101,6 +125,34 @@ final class SecureEnclaveManager { } } + /// Does this key require user-presence verification on decrypt? + /// Legacy keys (no metadata sidecar) were always generated with the + /// `.userPresence` ACL flag, so they normalize to `true`. + static func keyRequiresAuth(keyId: String) -> Bool { + guard let data = FileManager.default.contents(atPath: metaFilePath(for: keyId)), + let meta = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let requireAuth = meta["requireAuth"] as? Bool else { + return true + } + return requireAuth + } + + /// Public (non-secret) metadata for all keys, for status/list-keys output. + static func listKeyDetails() -> [[String: Any]] { + return listKeys().map { keyId in + var detail: [String: Any] = [ + "keyId": keyId, + "requireAuth": keyRequiresAuth(keyId: keyId), + ] + if let data = FileManager.default.contents(atPath: metaFilePath(for: keyId)), + let meta = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let createdAt = meta["createdAt"] as? String { + detail["createdAt"] = createdAt + } + return detail + } + } + /// List key IDs by scanning the key store directory. static func listKeys() -> [String] { let dir = keyStorePath diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 3cf10dc67..5c4a31caf 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -63,6 +63,7 @@ case "generate-key": "keyId": keyId, "publicKey": pubKeyData.base64EncodedString(), "publicKeyBytes": pubKeyData.count, + "requireAuth": !noAuth, ]) } catch { jsonError(error.localizedDescription) @@ -79,7 +80,7 @@ case "delete-key": case "list-keys": let keys = SecureEnclaveManager.listKeys() - jsonSuccess(["keys": keys]) + jsonSuccess(["keys": keys, "keyDetails": SecureEnclaveManager.listKeyDetails()]) // MARK: - key-exists @@ -166,6 +167,7 @@ case "status": #endif }(), "keys": SecureEnclaveManager.listKeys(), + "keyDetails": SecureEnclaveManager.listKeyDetails(), ]) // MARK: - daemon @@ -230,7 +232,13 @@ case "daemon": let keyId = (payload["keyId"] as? String) ?? defaultKeyId do { - let context = try sessionManager.getAuthenticatedContext(sessionId: sessionId) + // Keys generated with --no-auth have no .userPresence ACL flag and + // opt out of the explicit presence prompt. Skipping the session + // context here also means a no-auth decrypt never warms the + // session for auth-required keys. + let context = SecureEnclaveManager.keyRequiresAuth(keyId: keyId) + ? try sessionManager.getAuthenticatedContext(sessionId: sessionId) + : nil let decrypted = try SecureEnclaveManager.decrypt( payload: ciphertext, keyId: keyId, 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 20680981b..95561c3fe 100644 --- a/packages/varlock-website/src/content/docs/guides/local-encryption.mdx +++ b/packages/varlock-website/src/content/docs/guides/local-encryption.mdx @@ -121,6 +121,46 @@ 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." +## Choosing a key + +Most projects use a single key and never think about this — a default key (`varlock-default`) is created automatically on first use. But you can create additional named keys, each with its own presence behavior, and select which one a project or value uses. + +Create keys with [`varlock keys`](/reference/cli-commands/#keys): + +```bash +varlock keys # list keys and their presence settings +varlock keys create deploy # prompts for presence on decrypt (where a gate exists) +varlock keys create ci --no-auth # always decrypts unattended (servers / CI) +``` + +A key's presence behavior is decided at creation and fixed for its life: + +- **default** — decrypting prompts for presence wherever a gate is available (Touch ID on macOS, Windows Hello, polkit/PAM on Linux). Where no gate exists, it decrypts unattended. +- **`--no-auth`** — never prompts, anywhere. Still hardware-backed at rest (e.g. Secure Enclave / TPM), but chosen explicitly for unattended use instead of inferred from "no gate is set up". This is the clean way to say "this key is for a server." + +On macOS the `--no-auth` choice is enforced by the Secure Enclave key itself; on Windows and Linux it's a policy varlock's own decrypt flow honors (consent-grade, matching the trade-off above). + +### Selecting which key to use + +Set a project-wide default with the [`@defaultLocalKey`](/reference/root-decorators/#defaultlocalkey) root decorator in your schema: + +```env-spec title=".env.schema" +# @defaultLocalKey=ci +# --- +DATABASE_URL=varlock("local:...") # decrypted with the "ci" key +``` + +Values encrypted with the project default stay as bare `varlock("local:...")` references. To use a different key for a single value, pass a `key=` arg — this is written automatically when you `varlock encrypt --key-id `: + +```env-spec +DATABASE_URL=varlock("local:...") # project default key +DEPLOY_TOKEN=varlock("local:...", key="deploy") # explicit override +``` + +The default key also encrypts the [disk cache](/reference/root-decorators/#cache), so a `--no-auth` project key gives you an unattended cache, and a presence-gated one gives you a gated cache. + +Because a value is encrypted to one device's key, keys don't move between machines — a reference that names a key the current device doesn't have will fail to decrypt with a hint to re-encrypt or create the key. + ## Platform details & setup ### macOS diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index d9e5ef44d..8351e5325 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -401,6 +401,7 @@ varlock encrypt [options] **Options:** - `--file`: Path to a `.env` file — encrypts all sensitive plaintext values in-place +- `--key-id`: Encryption key id to use (defaults to the project's [`@defaultLocalKey`](/reference/root-decorators/#defaultlocalkey), or `varlock-default`). See [`varlock keys`](#keys). **Examples:** ```bash @@ -413,6 +414,9 @@ varlock encrypt < secret.txt # Encrypt all sensitive plaintext values in a .env file varlock encrypt --file .env.local + +# Encrypt with a specific key (emits a key= arg so it round-trips at load) +printf '%s' "$SECRET" | varlock encrypt --key-id ci ``` 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: @@ -436,6 +440,31 @@ Instead of encrypting values ahead of time, you can use `varlock(prompt)` as a p +
+### `varlock keys` ||keys|| + +Manage the device-local encryption keys behind [`varlock("local:...")`](/reference/functions/#varlock) values and the encrypted cache. Keys are device-bound (Secure Enclave / TPM / DPAPI) and never leave the machine. + +Each key records whether decrypting requires **presence verification** (Touch ID / Windows Hello / polkit), chosen at creation and fixed for the life of the key. See the [local encryption guide](/guides/local-encryption/#choosing-a-key). + +```bash +varlock keys # list keys (same as `varlock keys list`) +varlock keys list # list keys and their presence-gate settings +varlock keys create # create a key that prompts on decrypt +varlock keys create --no-auth # create a key for unattended use (servers/CI) +``` + +**`varlock keys create` options:** +- `--no-auth`: Opt the key out of presence verification, so it always decrypts unattended. On macOS the key is still Secure Enclave–backed at rest; on Windows/Linux the presence gate is skipped by varlock's own decrypt flow. + +:::note +Presence settings are fixed at creation. To change whether a key prompts, create a new key and re-encrypt the values that use it. A value is encrypted to one device's key, so keys don't transfer between machines. +::: + +Point a project at a key with the [`@defaultLocalKey`](/reference/root-decorators/#defaultlocalkey) root decorator, or select one per-value with `varlock("local:...", key="...")`. + +
+
### `varlock reveal` ||reveal|| diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 2ae7dab50..2116ed88f 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -110,6 +110,26 @@ OTHER_BAR= # not sensitive (explicit) ```
+
+### `@defaultLocalKey` +**Value type:** `string` (a [local encryption](/guides/local-encryption/) key id) + +Sets the default device-local encryption key used by [`varlock("local:...")`](/reference/functions/#varlock) values that don't specify their own `key=` arg, and by the [encrypted disk cache](/reference/root-decorators/#cache). Defaults to `varlock-default`. + +Must be a **static** string — it is read before any values resolve, so it cannot reference other items. The key itself is device-bound and is created with [`varlock keys create`](/reference/cli-commands/#keys) (or automatically on first use). + +Values encrypted with the project default key are written as bare `varlock("local:...")` references (the key comes from this decorator), keeping them portable within the project. A value encrypted with a different key carries an explicit `key=` arg. + +```env-spec +# @defaultLocalKey=ci +# --- +# decrypted with the "ci" key: +DATABASE_URL=varlock("local:...") +# decrypted with a different key (explicit override): +OTHER=varlock("local:...", key="deploy") +``` +
+
### `@disable` **Value type:** `boolean` diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 51ff0f933..fa4e54f4d 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -33,6 +33,7 @@ import { commandSpec as auditCommandSpec } from './commands/audit.command'; import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.command'; import { commandSpec as cacheCommandSpec } from './commands/cache.command'; import { commandSpec as keychainCommandSpec } from './commands/keychain.command'; +import { commandSpec as keysCommandSpec } from './commands/keys.command'; // import { commandSpec as loginCommandSpec } from './commands/login.command'; // import { commandSpec as pluginCommandSpec } from './commands/plugin.command'; @@ -78,6 +79,7 @@ subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, asy subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async () => await import('./commands/generate-key.command'))); subCommands.set('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.command'))); subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => await import('./commands/keychain.command'))); +subCommands.set('keys', buildLazyCommand(keysCommandSpec, async () => await import('./commands/keys.command'))); // subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command'))); // subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command'))); diff --git a/packages/varlock/src/cli/commands/encrypt.command.ts b/packages/varlock/src/cli/commands/encrypt.command.ts index e2cce8a99..102772df3 100644 --- a/packages/varlock/src/cli/commands/encrypt.command.ts +++ b/packages/varlock/src/cli/commands/encrypt.command.ts @@ -15,6 +15,7 @@ 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 } from '../../lib/local-encrypt/builtin-resolver'; import { writeBackValue } from '../../lib/local-encrypt/write-back'; export const commandSpec = define({ @@ -23,12 +24,7 @@ export const commandSpec = define({ args: { 'key-id': { type: 'string', - description: 'Encryption key ID', - default: 'varlock-default', - // Hidden until multi-key round-trips: the varlock("local:...") reference does not - // encode a keyId, and the load-time resolver always decrypts with the default key, - // so encrypting with a non-default key produces values that cannot be loaded back. - hidden: true, + description: 'Encryption key id (defaults to the project\'s @defaultLocalKey, or "varlock-default")', }, file: { type: 'string', @@ -42,14 +38,32 @@ 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. +The encryption key defaults to the project's @defaultLocalKey root decorator (or +"varlock-default"). Passing a different --key-id emits a reference with an explicit +key= arg so it round-trips at load time. + 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 --key-id ci # Encrypt with a specific key (see \`varlock keys\`) `.trim(), }); -async function encryptFile(keyId: string, filePath: string) { +/** + * Read the project's default local key (@defaultLocalKey) — best-effort, since + * `varlock encrypt` can be run outside any project. + */ +async function getProjectDefaultKeyId(): Promise { + try { + const envGraph = await loadVarlockEnvGraph(); + return envGraph.defaultLocalKeyId; + } catch { + return 'varlock-default'; + } +} + +async function encryptFile(explicitKeyId: string | undefined, filePath: string) { const resolvedPath = path.resolve(filePath); if (!fs.existsSync(resolvedPath)) { throw new CliExitError(`File not found: ${resolvedPath}`); @@ -59,6 +73,12 @@ async function encryptFile(keyId: string, filePath: string) { const envGraph = await loadVarlockEnvGraph(); await envGraph.resolveEnvValues(); + const projectDefaultKeyId = envGraph.defaultLocalKeyId; + const keyId = explicitKeyId ?? projectDefaultKeyId; + // only a non-default explicit key needs to be recorded in the written reference + const referenceKeyId = keyId !== projectDefaultKeyId ? keyId : undefined; + await localEncrypt.ensureKey(keyId); + // Find the data source matching the target file const targetSource = envGraph.sortedDataSources.find( (s) => s instanceof FileBasedDataSource && s.fullPath === resolvedPath, @@ -121,7 +141,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(ciphertext, referenceKeyId), resolvedPath); if (result.updated) { encryptedCount++; @@ -133,9 +153,31 @@ async function encryptFile(keyId: string, filePath: string) { } export const commandFn: TypedGunshiCommandFn = async (ctx) => { - const keyId = String(ctx.values['key-id'] || 'varlock-default'); + const explicitKeyId = ctx.values['key-id'] ? String(ctx.values['key-id']) : undefined; + if (explicitKeyId && !localEncrypt.isValidKeyId(explicitKeyId)) { + throw new CliExitError(`"${explicitKeyId}" is not a valid key id`, { + suggestion: localEncrypt.KEY_ID_REQUIREMENTS_MESSAGE, + }); + } const backend = localEncrypt.getBackendInfo(); + const filePath = ctx.values.file; + + // --file mode: encrypt all sensitive plaintext values in a .env file. + // It loads the graph itself (to read the project default key + sensitivity), so + // hand off before doing the single-value key/ensureKey work below. + if (filePath) { + await encryptFile(explicitKeyId, filePath); + return; + } + + // Single-value mode. Resolve the effective key: explicit --key-id, else the + // project's @defaultLocalKey (best-effort — encrypt may run outside a project). + const projectDefaultKeyId = await getProjectDefaultKeyId(); + const keyId = explicitKeyId ?? projectDefaultKeyId; + // only a non-default explicit key needs to be recorded in the emitted reference + const referenceKeyId = keyId !== projectDefaultKeyId ? keyId : undefined; + try { await localEncrypt.ensureKey(keyId); } catch (err) { @@ -145,7 +187,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = ); } - console.log(`Using ${backend.type} backend (${backend.hardwareBacked ? 'hardware-backed' : 'file-based'})`); + console.log(`Using ${backend.type} backend (${backend.hardwareBacked ? 'hardware-backed' : 'file-based'})${keyId !== 'varlock-default' ? ` with key "${keyId}"` : ''}`); // Hardware-backed but no presence gate → decryption is unattended (headless/CI hosts). if (backend.hardwareBacked && !backend.biometricAvailable) { @@ -161,14 +203,6 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = ); } - const filePath = ctx.values.file; - - // --file mode: encrypt all sensitive plaintext values in a .env file - if (filePath) { - await encryptFile(keyId, filePath); - return; - } - // Single-value mode — read from stdin if piped, otherwise prompt interactively. // Avoids putting secrets in shell history (e.g. `echo $SECRET | varlock encrypt`). console.log(''); @@ -216,5 +250,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(ciphertext, referenceKeyId)}`); }; diff --git a/packages/varlock/src/cli/commands/keys.command.ts b/packages/varlock/src/cli/commands/keys.command.ts new file mode 100644 index 000000000..831f40ab8 --- /dev/null +++ b/packages/varlock/src/cli/commands/keys.command.ts @@ -0,0 +1,139 @@ +import ansis from 'ansis'; +import { define } from 'gunshi'; + +import * as localEncrypt from '../../lib/local-encrypt'; +import { trackCommand } from '../helpers/telemetry'; +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; +import { CliExitError } from '../helpers/exit-error'; + +function printKeyList() { + const backend = localEncrypt.getBackendInfo(); + const keys = localEncrypt.listKeyDetails(); + + console.log(`Backend: ${backend.type} (${backend.hardwareBacked ? 'hardware-backed' : 'file-based'})`); + console.log(`Presence gate on this machine: ${backend.biometricAvailable ? 'available' : 'not available'}`); + console.log(''); + + if (!keys.length) { + console.log(ansis.gray('No local encryption keys yet — one is created automatically on first use,')); + console.log(ansis.gray('or create one explicitly with `varlock keys create `')); + return; + } + + const gateLabelFor = (key: { requireAuth: boolean }): string => { + if (backend.type === 'file') return ansis.gray('n/a (file backend has no gate)'); + if (!key.requireAuth) return ansis.gray('unattended (created with --no-auth)'); + if (backend.biometricAvailable) return ansis.green('prompts on decrypt'); + return ansis.yellow('prompts on decrypt (no gate on this machine — decrypts unattended)'); + }; + + for (const key of keys) { + const gateLabel = gateLabelFor(key); + console.log(` ${ansis.cyan(key.keyId)}`); + console.log(` presence: ${gateLabel}`); + if (key.protection) console.log(` protection: ${key.protection}`); + if (key.createdAt) console.log(` created: ${key.createdAt}`); + } +} + +// --- `varlock keys list` ------------------------------------------------------ + +const listCommand = define({ + name: 'list', + description: 'List local encryption keys and their presence-gate settings', + run: async () => { + await trackCommand('keys list', { command: 'keys list' }); + printKeyList(); + }, +}); + +// --- `varlock keys create` ------------------------------------------------------ + +const createCommand = define({ + name: 'create', + description: 'Create a new local encryption key', + args: { + name: { + type: 'positional', + description: 'Key id (e.g. "ci", "deploy") — becomes part of the key file name', + }, + 'no-auth': { + type: 'boolean', + description: 'Opt this key out of presence verification (Touch ID / Windows Hello / polkit) — for unattended use (servers, CI)', + }, + }, + examples: ` + varlock keys create deploy # new key, prompts on decrypt where a gate is available + varlock keys create ci --no-auth # new key that always decrypts unattended +`.trim(), + run: async (ctx) => { + await trackCommand('keys create', { command: 'keys create' }); + + const keyId = String(ctx.values.name || ''); + if (!keyId) { + throw new CliExitError('Missing key name', { suggestion: 'Usage: varlock keys create [--no-auth]' }); + } + if (!localEncrypt.isValidKeyId(keyId)) { + throw new CliExitError(`"${keyId}" is not a valid key id`, { + suggestion: localEncrypt.KEY_ID_REQUIREMENTS_MESSAGE, + }); + } + if (localEncrypt.keyExists(keyId)) { + throw new CliExitError(`Key "${keyId}" already exists`, { + suggestion: 'Presence settings are fixed at creation — to change them, create a new key and re-encrypt your values.', + }); + } + + const requireAuth = !ctx.values['no-auth']; + const backend = localEncrypt.getBackendInfo(); + + try { + await localEncrypt.generateKey(keyId, { requireAuth }); + } catch (err) { + throw new CliExitError(`Failed to create key: ${err instanceof Error ? err.message : err}`); + } + + console.log(`Created key ${ansis.cyan(keyId)} (${backend.type}${backend.hardwareBacked ? ', hardware-backed' : ''})`); + if (backend.type === 'file') { + console.log(ansis.gray('The file backend has no presence gate — decrypts are always unattended.')); + } else if (!requireAuth) { + console.log(ansis.gray('This key decrypts unattended (no presence prompt) — suitable for headless/CI use.')); + console.log(ansis.gray('It still protects values at rest, but not against a process already running as your user.')); + } else if (!backend.biometricAvailable) { + console.log(ansis.yellow('This key will prompt for presence when a gate is available, but this machine has none configured — decrypts are unattended here.')); + } + + console.log(''); + console.log('Use it via:'); + console.log(` varlock encrypt --key-id ${keyId} # encrypt a value with this key`); + console.log(` # @defaultLocalKey=${keyId} # or make it the project default (.env.schema header)`); + }, +}); + +// --- `varlock keys` (parent) ----------------------------------------------- + +export const commandSpec = define({ + name: 'keys', + description: 'Manage device-local encryption keys used by varlock("local:...") values', + subCommands: { + list: listCommand, + create: createCommand, + }, + examples: ` +Manage the device-local encryption keys behind varlock("local:...") values and the encrypted cache. + +Keys are device-bound (Secure Enclave / TPM / DPAPI) and never leave the machine. +Each key records whether decrypting requires presence verification (Touch ID / +Windows Hello / polkit) — chosen at creation and fixed for the life of the key. + +Examples: + varlock keys # list keys (same as \`varlock keys list\`) + varlock keys create deploy # create a key that prompts on decrypt + varlock keys create ci --no-auth # create a key for unattended use +`.trim(), +}); + +/** Default `varlock keys` with no subcommand: same as list */ +export const commandFn: TypedGunshiCommandFn = async () => { + printKeyList(); +}; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index d2fa05ec7..b051ce8ab 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -315,6 +315,21 @@ export const builtInRootDecorators: Array> = [ } }, }, + { + // sets the default local encryption key used by varlock("local:...") values + // and the encrypted disk cache. Must be static — it is consumed before any + // values resolve (cache store construction), so it cannot reference other items. + name: 'defaultLocalKey', + process: async (decVal) => { + if (!decVal.isStatic || typeof decVal.staticValue !== 'string' || !decVal.staticValue) { + throw new Error('@defaultLocalKey must be set to a static, non-empty string (a local encryption key id)'); + } + const { isValidKeyId, KEY_ID_REQUIREMENTS_MESSAGE } = await import('../../lib/local-encrypt'); + if (!isValidKeyId(decVal.staticValue)) { + throw new Error(`@defaultLocalKey is not a valid key id - ${KEY_ID_REQUIREMENTS_MESSAGE}`); + } + }, + }, { name: 'disable', }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index f3e62a11e..2054c422e 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -522,14 +522,14 @@ export class EnvGraph { { isWarning: true }, )); } - diskStore = new CacheStore(); + diskStore = new CacheStore(this.defaultLocalKeyId); } this._cacheMode = 'disk'; this._cacheStore = diskStore; } else if (cacheMode === 'auto') { if (!this._cacheStore) { if (this._cacheMode === 'memory') this._cacheStore = new InMemoryCacheStore(); - else if (this._cacheMode === 'disk') this._cacheStore = new CacheStore(); + else if (this._cacheMode === 'disk') this._cacheStore = new CacheStore(this.defaultLocalKeyId); } } } @@ -537,6 +537,25 @@ export class EnvGraph { this._cacheStore = undefined; } + // Thread @defaultLocalKey into the disk cache: the auto-policy store was + // constructed before the schema was parsed (loader.ts), so a project-level + // default key requires swapping it here. Only applies to localEncrypt-backed + // stores — an env-key store (_VARLOCK_CACHE_KEY) keeps precedence, and the + // key never affects memory stores. + { + const defaultLocalKey = this.defaultLocalKeyId; + if (defaultLocalKey !== 'varlock-default') { + const { CacheStore } = await import('../../lib/cache'); + if ( + this._cacheStore instanceof CacheStore + && this._cacheStore.localEncryptKeyId + && this._cacheStore.localEncryptKeyId !== defaultLocalKey + ) { + this._cacheStore = new CacheStore(defaultLocalKey); + } + } + } + // check declared standardVars against the environment // (runs after root decorator processing so decValueResolver.deps is available) for (const plugin of this.plugins) { @@ -946,6 +965,18 @@ export class EnvGraph { } return undefined; } + + /** + * Default local encryption key id for this project — set via the + * `@defaultLocalKey` root decorator (static-only, so it is readable as soon + * as schema files are parsed). Used by varlock("local:...") values without + * an explicit key= arg, and by the encrypted disk cache. + */ + get defaultLocalKeyId(): string { + const dec = this.getRootDec('defaultLocalKey'); + const val = dec?.decValueResolver?.isStatic ? dec.decValueResolver.staticValue : undefined; + return (typeof val === 'string' && val) ? val : 'varlock-default'; + } getRootDecFns(decoratorName: string) { const allDecs: Array = []; const sources = Array.from(this.sortedDataSources).reverse(); diff --git a/packages/varlock/src/env-graph/test/default-local-key.test.ts b/packages/varlock/src/env-graph/test/default-local-key.test.ts new file mode 100644 index 000000000..41551e3d3 --- /dev/null +++ b/packages/varlock/src/env-graph/test/default-local-key.test.ts @@ -0,0 +1,58 @@ +import { + describe, test, expect, +} from 'vitest'; +import outdent from 'outdent'; +import { EnvGraph, DotEnvFileDataSource } from '../index'; + +async function loadSchema(contents: string) { + const g = new EnvGraph(); + await g.setRootDataSource(new DotEnvFileDataSource('.env.schema', { overrideContents: contents })); + await g.finishLoad(); + return g; +} + +function schemaErrors(g: EnvGraph) { + return g.sortedDataSources.flatMap((s) => s.rootDecorators).flatMap((d) => d.errors); +} + +describe('@defaultLocalKey root decorator', () => { + test('defaults to varlock-default when not set', async () => { + const g = await loadSchema(outdent` + FOO=bar + `); + expect(g.defaultLocalKeyId).toBe('varlock-default'); + }); + + test('reads a valid static key id', async () => { + const g = await loadSchema(outdent` + # @defaultLocalKey=ci + # --- + FOO=bar + `); + expect(g.defaultLocalKeyId).toBe('ci'); + expect(schemaErrors(g).filter((e) => !e.isWarning)).toHaveLength(0); + }); + + test('rejects an invalid key id', async () => { + const g = await loadSchema(outdent` + # @defaultLocalKey="bad key" + # --- + FOO=bar + `); + const errs = schemaErrors(g).filter((e) => !e.isWarning); + expect(errs.length).toBeGreaterThan(0); + expect(errs.some((e) => /not a valid key id/.test(e.message))).toBe(true); + }); + + test('rejects a non-static value', async () => { + const g = await loadSchema(outdent` + # @defaultLocalKey=$SOME_REF + # --- + SOME_REF=ci + FOO=bar + `); + const errs = schemaErrors(g).filter((e) => !e.isWarning); + expect(errs.length).toBeGreaterThan(0); + expect(errs.some((e) => /static/.test(e.message))).toBe(true); + }); +}); diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts index 93cd22aa6..a3ba8752e 100644 --- a/packages/varlock/src/lib/cache/cache-store.ts +++ b/packages/varlock/src/lib/cache/cache-store.ts @@ -171,9 +171,17 @@ export class CacheStore { private codec: CacheValueCodec; private static warnedWriteFailure = false; + /** + * Local encryption key id backing this store, or undefined when a custom + * codec is supplied (e.g. the env-key store). Lets the graph swap the + * auto-policy store when `@defaultLocalKey` selects a different key. + */ + readonly localEncryptKeyId?: string; + constructor(keyId: string = 'varlock-default', codec?: CacheValueCodec) { const cacheDir = path.join(getUserVarlockDir(), 'cache'); this.filePath = path.join(cacheDir, `${keyId}.json`); + if (!codec) this.localEncryptKeyId = keyId; this.codec = codec ?? { ensureReady: () => localEncrypt.ensureKey(keyId), encrypt: (plaintext) => localEncrypt.encryptValue(plaintext, keyId), 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..f57eb8292 100644 --- a/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts +++ b/packages/varlock/src/lib/local-encrypt/builtin-resolver.test.ts @@ -119,4 +119,85 @@ describe('VarlockResolver with file fallback', () => { const results = await Promise.all(resolvers.map((r) => r.resolve())); expect(results).toEqual(values); }); + + it('decrypts with an explicit key= arg using the matching key', async () => { + await localEncrypt.ensureKey('ci'); + const plaintext = 'ci-only-secret'; + const ciphertext = await localEncrypt.encryptValue(plaintext, 'ci'); + + const resolver = new VarlockResolver( + [new StaticValueResolver(`local:${ciphertext}`)], + { key: new StaticValueResolver('ci') }, + ); + resolver.process(); + expect(resolver.schemaErrors).toHaveLength(0); + expect(await resolver.resolve()).toBe(plaintext); + }); + + it('a value encrypted with a named key does NOT decrypt without key= (wrong key)', async () => { + await localEncrypt.ensureKey(); // default key + await localEncrypt.ensureKey('ci'); + const ciphertext = await localEncrypt.encryptValue('ci-secret', 'ci'); + + // no key= arg → default key → mismatched → decrypt fails + const resolver = new VarlockResolver([new StaticValueResolver(`local:${ciphertext}`)]); + resolver.process(); + await expect(resolver.resolve()).rejects.toThrow(/Decryption failed/); + }); + + it('rejects an invalid key= arg with a SchemaError', () => { + const resolver = new VarlockResolver( + [new StaticValueResolver('local:whatever')], + { key: new StaticValueResolver('bad key!') }, + ); + resolver.process(); + expect(resolver.schemaErrors.length).toBeGreaterThan(0); + expect(resolver.schemaErrors[0].message).toMatch(/not a valid key id/); + }); + + it('surfaces a device-bound hint when the requested key is missing', async () => { + await localEncrypt.ensureKey(); // only the default key exists + const ciphertext = await localEncrypt.encryptValue('x'); // encrypted with default + // reference asks for a key that does not exist on this device + const resolver = new VarlockResolver( + [new StaticValueResolver(`local:${ciphertext}`)], + { key: new StaticValueResolver('does-not-exist') }, + ); + resolver.process(); + const err = await resolver.resolve().then(() => undefined, (e) => e); + expect(err).toBeDefined(); + expect(err.message).toMatch(/Decryption failed/); + // the actionable "keys are device-bound" hint lands on the error tip, keyed by the missing key id + expect(err.tip).toMatch(/device-bound/); + expect(err.tip).toContain('does-not-exist'); + }); +}); + +describe('buildVarlockReference', () => { + it('omits key= for the default key and includes it for a named key', async () => { + const { buildVarlockReference } = await import('./builtin-resolver'); + expect(buildVarlockReference('CT')).toBe('varlock("local:CT")'); + expect(buildVarlockReference('CT', undefined)).toBe('varlock("local:CT")'); + expect(buildVarlockReference('CT', 'ci')).toBe('varlock("local:CT", key="ci")'); + }); +}); + +describe('local-encrypt key metadata (file backend)', () => { + it('validates key ids', () => { + expect(localEncrypt.isValidKeyId('ci')).toBe(true); + expect(localEncrypt.isValidKeyId('varlock-default')).toBe(true); + expect(localEncrypt.isValidKeyId('deploy_2024.v1')).toBe(true); + expect(localEncrypt.isValidKeyId('bad key')).toBe(false); + expect(localEncrypt.isValidKeyId('../escape')).toBe(false); + expect(localEncrypt.isValidKeyId('')).toBe(false); + }); + + it('lists generated keys (file-backend keys never require presence auth)', async () => { + await localEncrypt.ensureKey('alpha'); + await localEncrypt.ensureKey('beta'); + const details = localEncrypt.listKeyDetails(); + const ids = details.map((d) => d.keyId).sort(); + expect(ids).toEqual(['alpha', 'beta']); + expect(details.every((d) => d.requireAuth === false)).toBe(true); + }); }); diff --git a/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts b/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts index 362a41cdc..f25c42bc1 100644 --- a/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts +++ b/packages/varlock/src/lib/local-encrypt/builtin-resolver.ts @@ -23,6 +23,8 @@ const PLUGIN_ICON = 'mdi:fingerprint'; type VarlockBatchEntry = { kind: 'prompt' | 'decrypt'; + /** effective local encryption key id for this entry */ + keyId: string; resolve: (value: string) => void; reject: (reason: unknown) => void; } & ( @@ -46,18 +48,18 @@ function enqueueBatchEntry(entry: VarlockBatchEntry) { } } -function enqueueDecrypt(ciphertext: string): Promise { +function enqueueDecrypt(ciphertext: string, keyId: string): Promise { return new Promise((resolve, reject) => { enqueueBatchEntry({ - kind: 'decrypt', ciphertext, resolve, reject, + kind: 'decrypt', ciphertext, keyId, 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, }); }); } @@ -79,14 +81,20 @@ async function executeBatch() { return a.kind === 'prompt' ? -1 : 1; }); - // Ensure encryption key exists before processing any items - await localEncrypt.ensureKey(); + // Ensure keys used for encryption (prompt mode) exist before processing. + // Decrypt keys are deliberately NOT auto-created: a value encrypted with a + // missing key can never decrypt, and generating a fresh key here would only + // pollute the key store and turn "key not found" into a confusing crypto error. + const promptKeyIds = new Set(batch.filter((e) => e.kind === 'prompt').map((e) => e.keyId)); + for (const keyId of promptKeyIds) { + await localEncrypt.ensureKey(keyId); + } for (let i = 0; i < batch.length; i++) { const entry = batch[i]; try { if (entry.kind === 'decrypt') { - const plaintext = await localEncrypt.decryptValue(entry.ciphertext); + const plaintext = await localEncrypt.decryptValue(entry.ciphertext, entry.keyId); entry.resolve(plaintext); } else { const result = await entry.execute(); @@ -111,19 +119,34 @@ async function executeBatch() { type VarlockResolverState = { mode: 'decrypt'; payload: string; + /** key id from an explicit key= arg (project/default fallback applied at resolve time) */ + explicitKeyId?: string; } | { mode: 'prompt'; itemKey: string; sourceFilePath: string | undefined; + explicitKeyId?: string; }; +/** Build the reference string written back into env files */ +export function buildVarlockReference(ciphertext: string, explicitKeyId?: string) { + const prefixedCiphertext = `${LOCAL_PREFIX}${ciphertext}`; + // only an explicit key= arg is written back — the project default + // (@defaultLocalKey) is applied at load time, keeping references portable + // within the project + if (explicitKeyId) { + return `varlock("${prefixedCiphertext}", key="${explicitKeyId}")`; + } + return `varlock("${prefixedCiphertext}")`; +} + function writeBackEncryptedValue( itemKey: string, ciphertext: string, sourceFilePath: string | undefined, + explicitKeyId?: string, ) { - const prefixedCiphertext = `${LOCAL_PREFIX}${ciphertext}`; - return writeBackValue(itemKey, `varlock("${prefixedCiphertext}")`, sourceFilePath); + return writeBackValue(itemKey, buildVarlockReference(ciphertext, explicitKeyId), sourceFilePath); } @@ -137,6 +160,21 @@ export const VarlockResolver: typeof Resolver = createResolver @defaultLocalKey root decorator > built-in default + const graphDefaultKeyId = (this as any).envGraph?.defaultLocalKeyId as string | undefined; + const keyId = state.explicitKeyId ?? graphDefaultKeyId ?? 'varlock-default'; + if (state.mode === 'decrypt') { let ciphertext = state.payload; if (ciphertext.startsWith(LOCAL_PREFIX)) { ciphertext = ciphertext.slice(LOCAL_PREFIX.length); } try { - return await enqueueDecrypt(ciphertext); + return await enqueueDecrypt(ciphertext, keyId); } catch (err) { // Re-throw ResolutionErrors (e.g. batch cancellation) as-is if (err instanceof ResolutionError) throw err; const backend = localEncrypt.getBackendInfo(); + const errMsg = err instanceof Error ? err.message : String(err); + const keyMissing = /not found/i.test(errMsg); throw new ResolutionError( - `Decryption failed: ${err instanceof Error ? err.message : err}`, + `Decryption failed: ${errMsg}`, { tip: [ `Backend: ${backend.type} (${backend.hardwareBacked ? 'hardware-backed' : 'file-based'})`, - 'This usually means the value was encrypted with a different key or backend.', + keyMissing + ? `This value was encrypted with key "${keyId}", which does not exist on this device. Local keys are device-bound — create it with \`varlock keys create ${keyId}\` and re-encrypt, or set a new value.` + : 'This usually means the value was encrypted with a different key or backend.', 'Set a new value using `varlock encrypt` or `KEY=varlock(prompt)`.', ].join('\n'), }, @@ -192,8 +240,8 @@ export const VarlockResolver: typeof Resolver = createResolver { + const { itemKey, sourceFilePath, explicitKeyId } = state; + return enqueuePrompt(keyId, async () => { const backend = localEncrypt.getBackendInfo(); // Use daemon's native dialog on macOS Secure Enclave @@ -201,6 +249,7 @@ export const VarlockResolver: typeof Resolver = createResolver>( 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 (older binaries omit it) */ +let cachedKeyDetails: Array | undefined; function detectBackendType(): { type: BackendType; isFileFallback: boolean } { const binaryPath = resolveNativeBinary(); @@ -331,6 +343,7 @@ export function getBackendInfo(): BackendInfo { const status = runNativeBinaryJson(['status']); debug(`getBackendInfo: status result: hardwareBacked=${status.hardwareBacked}, biometricAvailable=${status.biometricAvailable}, backend=${status.backend}, keys=${status.keys?.join(',')}`); cachedStatusKeys = status.keys; + cachedKeyDetails = status.keyDetails; cachedBackendInfo = { type, platform: process.platform, @@ -403,21 +416,62 @@ export function keyExists(keyId: string = DEFAULT_KEY_ID): boolean { return result.exists; } +/** + * Whether decrypts of this key should require user-presence verification + * (when the machine has a gate at all). Unknown keys and keys from older + * binaries without per-key metadata default to true — prompting is the + * safe direction to fail in. + */ +function keyRequiresAuth(keyId: string): boolean { + 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 }> { +export async function generateKey( + keyId: string = DEFAULT_KEY_ID, + opts?: { requireAuth?: boolean }, +): Promise<{ keyId: string; publicKey: string }> { const backend = getBackendInfo(); if (backend.type === 'file') { warnIfFileFallback(backend); + // requireAuth is not applicable to the file backend (it has no presence gate) return fileBackend.generateKey(keyId); } - return runNativeBinaryJson<{ keyId: string; publicKey: string }>(['generate-key', '--key-id', keyId]); + const requireAuth = opts?.requireAuth ?? true; + const args = ['generate-key', '--key-id', keyId]; + if (!requireAuth) args.push('--no-auth'); + const result = runNativeBinaryJson<{ keyId: string; publicKey: string }>(args); + // Keep the status-derived caches coherent so key lookups in this same + // process (keyExists, decrypt routing) see the new key immediately + cachedStatusKeys?.push(keyId); + cachedKeyDetails?.push({ keyId, requireAuth }); + return result; } /** Ensure a key exists, generating one if necessary. */ -export async function ensureKey(keyId: string = DEFAULT_KEY_ID): Promise { +export async function ensureKey( + keyId: string = DEFAULT_KEY_ID, + opts?: { requireAuth?: boolean }, +): Promise { if (!keyExists(keyId)) { - await generateKey(keyId); + await generateKey(keyId, opts); + } +} + +/** + * List all local encryption keys with their metadata. + * Queries the native binary fresh (not the status cache) so CLI listings reflect current state. + */ +export function listKeyDetails(): Array { + const backend = getBackendInfo(); + if (backend.type === 'file') { + // The file backend has no presence gate, so no key ever prompts + return fileBackend.listKeys().map((keyId) => ({ keyId, requireAuth: false })); } + const result = runNativeBinaryJson<{ keys: Array; keyDetails?: Array }>(['list-keys']); + // Older binaries only return key ids — all their keys predate --no-auth + return result.keyDetails ?? result.keys.map((keyId) => ({ keyId, requireAuth: true })); } // ── Encrypt / Decrypt ────────────────────────────────────────────────── @@ -467,9 +521,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). + // Keys generated with --no-auth opt out of the presence gate entirely and + // take the one-shot path below, even when the machine has a gate. // 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(); @@ -533,7 +589,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 (--no-auth) debug('decryptValue: non-biometric one-shot decrypt'); const result = runNativeBinaryJson<{ plaintext: string }>( ['decrypt', '--key-id', keyId, '--data', ciphertext], diff --git a/packages/varlock/src/lib/local-encrypt/types.ts b/packages/varlock/src/lib/local-encrypt/types.ts index a40f05df2..caa38a0a1 100644 --- a/packages/varlock/src/lib/local-encrypt/types.ts +++ b/packages/varlock/src/lib/local-encrypt/types.ts @@ -64,10 +64,21 @@ export interface KeychainSetResult { updated: boolean; } +/** Per-key metadata reported by a native binary (status / list-keys) */ +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 on binaries that support per-key metadata (older binaries omit it) */ + keyDetails?: Array; }