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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/local-encrypt-multikey.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 24 additions & 6 deletions packages/encryption-binary-rust/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")}),
}
Expand All @@ -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})
}
Expand Down
90 changes: 88 additions & 2 deletions packages/encryption-binary-rust/src/key_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
}

/// 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.
Expand Down Expand Up @@ -414,7 +437,9 @@ pub fn list_keys() -> Vec<String> {

/// 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<String, String> {
///
/// `require_auth` records the key's presence-gate intent (see [`StoredKey::require_auth`]).
pub fn generate_key(key_id: &str, require_auth: bool) -> Result<String, String> {
let key_pair = crate::crypto::generate_key_pair()?;

// Decode the private key to protect it
Expand All @@ -430,13 +455,37 @@ pub fn generate_key(key_id: &str) -> Result<String, String> {
protected_private_key: protected,
protection,
created_at: now_iso8601(),
require_auth: Some(require_auth),
};

write_stored_key(&stored)?;

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<KeyMeta, String> {
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<KeyMeta> {
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.
///
Expand Down Expand Up @@ -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]
Expand All @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions packages/encryption-binary-rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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!({
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ case "generate-key":
"keyId": keyId,
"publicKey": pubKeyData.base64EncodedString(),
"publicKeyBytes": pubKeyData.count,
"requireAuth": !noAuth,
])
} catch {
jsonError(error.localizedDescription)
Expand All @@ -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

Expand Down Expand Up @@ -166,6 +167,7 @@ case "status":
#endif
}(),
"keys": SecureEnclaveManager.listKeys(),
"keyDetails": SecureEnclaveManager.listKeyDetails(),
])

// MARK: - daemon
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`:

```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
Expand Down
Loading
Loading