Skip to content

Commit ab2c8ae

Browse files
theoephraimclaude
andcommitted
harden WSL2 security: pipe ACL + stdin data passing + TTY session scoping
- Restrict named pipe to current user via SECURITY_ATTRIBUTES with DACL (prevents other users from connecting to the daemon) - Pass ciphertext and TTY ID via stdin JSON instead of CLI args (prevents exposure in process listings via tasklist/procfs) - Forward TTY ID to daemon for per-terminal biometric session scoping (extracted from /proc/self/fd/0 symlink in WSL2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc10b2d commit ab2c8ae

5 files changed

Lines changed: 195 additions & 20 deletions

File tree

packages/encryption-binary-rust/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ windows = { version = "0.58", features = [
4242
"Win32_Storage_FileSystem",
4343
"Win32_Foundation",
4444
"Win32_System_IO",
45+
# Pipe ACL (restrict to current user)
46+
"Win32_Security",
47+
"Win32_Security_Authorization",
4548
# Windows Hello (UserConsentVerifier)
4649
"Security_Credentials_UI",
4750
"Foundation",

packages/encryption-binary-rust/src/daemon_client.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ const MAX_SPAWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1919

2020
/// Send a decrypt request through the daemon, auto-spawning if needed.
2121
/// Returns the decrypted plaintext.
22+
/// `tty_id` is forwarded to the daemon for per-terminal session scoping.
2223
#[cfg(target_os = "windows")]
23-
pub fn decrypt_via_daemon(ciphertext: &str, key_id: &str) -> Result<String, String> {
24+
pub fn decrypt_via_daemon(ciphertext: &str, key_id: &str, tty_id: Option<&str>) -> Result<String, String> {
2425
// Try connecting to existing daemon first
25-
match try_daemon_decrypt(ciphertext, key_id) {
26+
match try_daemon_decrypt(ciphertext, key_id, tty_id) {
2627
Ok(result) => return Ok(result),
2728
Err(_) => {
2829
// Daemon not running, spawn one
@@ -32,12 +33,12 @@ pub fn decrypt_via_daemon(ciphertext: &str, key_id: &str) -> Result<String, Stri
3233
spawn_daemon()?;
3334

3435
// Retry after spawn
35-
try_daemon_decrypt(ciphertext, key_id)
36+
try_daemon_decrypt(ciphertext, key_id, tty_id)
3637
}
3738

3839
/// Try to connect to the daemon and send a decrypt request.
3940
#[cfg(target_os = "windows")]
40-
fn try_daemon_decrypt(ciphertext: &str, key_id: &str) -> Result<String, String> {
41+
fn try_daemon_decrypt(ciphertext: &str, key_id: &str, tty_id: Option<&str>) -> Result<String, String> {
4142
use windows::Win32::Storage::FileSystem::{
4243
CreateFileW, ReadFile, WriteFile, FlushFileBuffers,
4344
FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_NONE,
@@ -64,15 +65,18 @@ fn try_daemon_decrypt(ciphertext: &str, key_id: &str) -> Result<String, String>
6465
return Err("Failed to open daemon pipe".into());
6566
}
6667

67-
// Build request
68-
let request = json!({
68+
// Build request — include ttyId for per-terminal session scoping
69+
let mut request = json!({
6970
"id": "via-daemon-1",
7071
"action": "decrypt",
7172
"payload": {
7273
"ciphertext": ciphertext,
7374
"keyId": key_id,
7475
},
7576
});
77+
if let Some(tty) = tty_id {
78+
request.as_object_mut().unwrap().insert("ttyId".to_string(), json!(tty));
79+
}
7680

7781
let request_bytes = serde_json::to_vec(&request)
7882
.map_err(|e| format!("Serialization failed: {e}"))?;
@@ -228,6 +232,6 @@ fn pipe_exists() -> bool {
228232
// ── Stub for non-Windows platforms ──────────────────────────────
229233

230234
#[cfg(not(target_os = "windows"))]
231-
pub fn decrypt_via_daemon(_ciphertext: &str, _key_id: &str) -> Result<String, String> {
235+
pub fn decrypt_via_daemon(_ciphertext: &str, _key_id: &str, _tty_id: Option<&str>) -> Result<String, String> {
232236
Err("--via-daemon is only supported on Windows".into())
233237
}

packages/encryption-binary-rust/src/ipc.rs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ impl IpcServer {
139139

140140
let pipe_name = HSTRING::from(&self.socket_path);
141141

142+
// Build a security descriptor that restricts pipe access to the current user only.
143+
// This prevents other users/processes from connecting to the daemon pipe.
144+
let sa = create_current_user_security_attributes()
145+
.map_err(|e| format!("Failed to create pipe security attributes: {e}"))?;
146+
142147
while self.running.load(Ordering::SeqCst) {
143148
// Create a new named pipe instance for each client
144149
let pipe_handle = unsafe {
@@ -150,7 +155,7 @@ impl IpcServer {
150155
65536, // out buffer
151156
65536, // in buffer
152157
0, // default timeout
153-
None, // default security
158+
Some(&sa), // restrict to current user
154159
)
155160
};
156161

@@ -355,6 +360,99 @@ fn get_peer_tty_id(_stream: &UnixStream) -> Option<String> {
355360
None
356361
}
357362

363+
// ── Windows pipe security ───────────────────────────────────────
364+
365+
/// Create a SECURITY_ATTRIBUTES that restricts access to the current user only.
366+
/// This prevents other users from connecting to the daemon's named pipe.
367+
#[cfg(windows)]
368+
fn create_current_user_security_attributes() -> Result<windows::Win32::Security::SECURITY_ATTRIBUTES, String> {
369+
use windows::Win32::Security::{
370+
SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR,
371+
InitializeSecurityDescriptor, SetSecurityDescriptorDacl,
372+
SECURITY_DESCRIPTOR_REVISION,
373+
};
374+
use windows::Win32::Security::Authorization::{
375+
SetEntriesInAclW, EXPLICIT_ACCESS_W, SET_ACCESS,
376+
TRUSTEE_W, TRUSTEE_IS_SID, TRUSTEE_TYPE,
377+
NO_INHERITANCE, TRUSTEE_FORM,
378+
};
379+
use windows::Win32::Security::{
380+
GetTokenInformation, TokenUser, TOKEN_USER, TOKEN_QUERY,
381+
};
382+
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
383+
use windows::Win32::Foundation::GENERIC_ALL;
384+
385+
unsafe {
386+
// Get current process token
387+
let mut token = windows::Win32::Foundation::HANDLE::default();
388+
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)
389+
.map_err(|e| format!("OpenProcessToken failed: {e}"))?;
390+
391+
// Get token user (contains the SID)
392+
let mut token_info_len = 0u32;
393+
let _ = GetTokenInformation(token, TokenUser, None, 0, &mut token_info_len);
394+
let mut token_info = vec![0u8; token_info_len as usize];
395+
GetTokenInformation(
396+
token,
397+
TokenUser,
398+
Some(token_info.as_mut_ptr() as *mut _),
399+
token_info_len,
400+
&mut token_info_len,
401+
).map_err(|e| format!("GetTokenInformation failed: {e}"))?;
402+
403+
let token_user = &*(token_info.as_ptr() as *const TOKEN_USER);
404+
let user_sid = token_user.User.Sid;
405+
406+
// Build an ACL with a single entry: GENERIC_ALL for the current user
407+
let mut ea = EXPLICIT_ACCESS_W {
408+
grfAccessPermissions: GENERIC_ALL.0,
409+
grfAccessMode: SET_ACCESS,
410+
grfInheritance: NO_INHERITANCE,
411+
Trustee: TRUSTEE_W {
412+
TrusteeForm: TRUSTEE_IS_SID,
413+
TrusteeType: TRUSTEE_TYPE(0), // TRUSTEE_IS_UNKNOWN
414+
ptstrName: windows::core::PWSTR(user_sid.0 as *mut u16),
415+
pMultipleTrustee: std::ptr::null_mut(),
416+
MultipleTrusteeOperation: TRUSTEE_FORM(0),
417+
},
418+
};
419+
420+
let mut acl = std::ptr::null_mut();
421+
let err = SetEntriesInAclW(Some(&[ea]), None, &mut acl);
422+
if err.0 != 0 {
423+
return Err(format!("SetEntriesInAclW failed: error {}", err.0));
424+
}
425+
426+
// Create a security descriptor with this DACL
427+
let sd_layout = std::alloc::Layout::new::<SECURITY_DESCRIPTOR>();
428+
let sd_ptr = std::alloc::alloc_zeroed(sd_layout) as *mut SECURITY_DESCRIPTOR;
429+
if sd_ptr.is_null() {
430+
return Err("Failed to allocate security descriptor".into());
431+
}
432+
433+
InitializeSecurityDescriptor(
434+
sd_ptr as *mut _,
435+
SECURITY_DESCRIPTOR_REVISION,
436+
).map_err(|e| format!("InitializeSecurityDescriptor failed: {e}"))?;
437+
438+
SetSecurityDescriptorDacl(
439+
sd_ptr as *mut _,
440+
true,
441+
Some(acl as *const _),
442+
false,
443+
).map_err(|e| format!("SetSecurityDescriptorDacl failed: {e}"))?;
444+
445+
// Note: sd_ptr and acl are intentionally leaked — they must live for the
446+
// lifetime of the pipe server. The OS frees them on process exit.
447+
448+
Ok(SECURITY_ATTRIBUTES {
449+
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
450+
lpSecurityDescriptor: sd_ptr as *mut _,
451+
bInheritHandle: false.into(),
452+
})
453+
}
454+
}
455+
358456
// ── Windows named pipe client handling ───────────────────────────
359457

360458
#[cfg(windows)]
@@ -417,8 +515,13 @@ fn handle_windows_client(
417515

418516
let id = message.get("id").and_then(|v| v.as_str()).map(|s| s.to_string());
419517

518+
// On Windows, use client-reported ttyId from the message (set by --via-daemon callers)
519+
let effective_tty_id = tty_id.clone().or_else(|| {
520+
message.get("ttyId").and_then(|v| v.as_str()).map(|s| s.to_string())
521+
});
522+
420523
let response = if let Some(ref handler) = handler {
421-
handler(message, tty_id.clone())
524+
handler(message, effective_tty_id)
422525
} else {
423526
serde_json::json!({"error": "No handler"})
424527
};

packages/encryption-binary-rust/src/main.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,36 @@ fn cmd_encrypt(args: &[String]) {
137137
fn cmd_decrypt(args: &[String]) {
138138
let key_id = get_key_id(args);
139139

140-
let data_b64 = match get_arg(args, "--data") {
141-
Some(d) => d,
142-
None => json_error("Missing --data argument (base64-encoded ciphertext)"),
140+
// --data-stdin reads a JSON payload from stdin: {"data":"...","ttyId":"..."}
141+
// This prevents ciphertext and session identity from being visible in process listings.
142+
let (data_b64, stdin_tty_id) = if args.contains(&"--data-stdin".to_string()) {
143+
let mut input = String::new();
144+
std::io::stdin().read_line(&mut input)
145+
.unwrap_or_else(|e| json_error(&format!("Failed to read stdin: {e}")));
146+
let input = input.trim();
147+
// Try parsing as JSON (new format), fall back to plain string (backwards compat)
148+
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(input) {
149+
let data = parsed.get("data").and_then(|v| v.as_str())
150+
.unwrap_or_else(|| json_error("Missing 'data' field in stdin JSON"))
151+
.to_string();
152+
let tty_id = parsed.get("ttyId").and_then(|v| v.as_str()).map(|s| s.to_string());
153+
(data, tty_id)
154+
} else {
155+
(input.to_string(), None)
156+
}
157+
} else {
158+
let data = match get_arg(args, "--data") {
159+
Some(d) => d,
160+
None => json_error("Missing --data or --data-stdin argument"),
161+
};
162+
(data, None)
143163
};
144164

145165
// --via-daemon: route through the daemon for biometric + session caching.
146166
// Used by WSL2 where the TS side can't connect to Windows named pipes directly.
147167
if args.contains(&"--via-daemon".to_string()) {
148-
match daemon_client::decrypt_via_daemon(&data_b64, &key_id) {
168+
let tty_id = stdin_tty_id.as_deref();
169+
match daemon_client::decrypt_via_daemon(&data_b64, &key_id, tty_id) {
149170
Ok(plaintext) => json_success(json!({"plaintext": plaintext})),
150171
Err(e) => json_error(&e),
151172
}
@@ -235,13 +256,15 @@ COMMANDS:
235256
list-keys List all Varlock encryption keys
236257
key-exists [--key-id <id>] Check if a key exists
237258
encrypt --data <base64> [--key-id <id>] Encrypt data (one-shot)
238-
decrypt --data <base64> [--key-id <id>] [--via-daemon] Decrypt data
259+
decrypt --data <base64> [--key-id <id>] [--via-daemon] Decrypt data
260+
decrypt --data-stdin [--key-id <id>] [--via-daemon] Decrypt (data from stdin)
239261
status Check platform capabilities
240262
daemon --socket-path <path> [--pid-path <path>] Start IPC daemon
241263
242264
OPTIONS:
243265
--key-id <id> Key identifier (default: varlock-default)
244266
--data <base64> Base64-encoded data
267+
--data-stdin Read data from stdin as JSON: {"data":"...","ttyId":"..."}
245268
--socket-path <path> Unix socket path for daemon mode
246269
--pid-path <path> PID file path for daemon mode
247270
--via-daemon Route decrypt through daemon for biometric + session caching

packages/varlock/src/lib/local-encrypt/index.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* 4. File-based (pure JS) — universal fallback, no native binary needed
1111
*/
1212

13-
import { execFileSync } from 'node:child_process';
13+
import { execFileSync, spawnSync } from 'node:child_process';
14+
import fs from 'node:fs';
1415
import { resolveNativeBinary } from './binary-resolver';
1516
import { DaemonClient } from './daemon-client';
1617
import * as fileBackend from './file-backend';
@@ -28,6 +29,26 @@ function debug(msg: string) {
2829
}
2930
}
3031

32+
/**
33+
* Get a TTY identifier for session scoping.
34+
* Reads the controlling terminal from /proc/self/fd/0 or falls back to PID.
35+
*/
36+
let _cachedTtyId: string | undefined;
37+
function getSelfTtyId(): string {
38+
if (_cachedTtyId) return _cachedTtyId;
39+
try {
40+
const ttyPath = fs.readlinkSync('/proc/self/fd/0');
41+
if (ttyPath && ttyPath.startsWith('/dev/')) {
42+
_cachedTtyId = ttyPath;
43+
return ttyPath;
44+
}
45+
} catch {
46+
// Not available
47+
}
48+
_cachedTtyId = `pid:${process.pid}`;
49+
return _cachedTtyId;
50+
}
51+
3152
// ── Native binary one-shot commands ────────────────────────────────────
3253

3354
function runNativeBinary(args: Array<string>, opts?: { timeout?: number }): string {
@@ -212,11 +233,32 @@ export async function decryptValue(ciphertext: string, keyId: string = DEFAULT_K
212233
if (backend.biometricAvailable) {
213234
if (isWSL()) {
214235
debug('decryptValue: WSL2 biometric decrypt via --via-daemon');
215-
// Longer timeout: includes daemon spawn + Windows Hello biometric prompt
216-
const result = runNativeBinaryJson<{ plaintext: string }>(
217-
['decrypt', '--key-id', keyId, '--data', ciphertext, '--via-daemon'],
218-
{ timeout: 60_000 },
219-
);
236+
const binaryPath = resolveNativeBinary();
237+
if (!binaryPath) throw new Error('Native binary not found');
238+
// Use spawnSync with stdin to avoid exposing ciphertext or session
239+
// identity in process listings (visible via tasklist/procfs).
240+
// Stdin JSON includes both the data and the TTY ID for session scoping.
241+
const stdinPayload = JSON.stringify({
242+
data: ciphertext,
243+
ttyId: getSelfTtyId(),
244+
});
245+
const proc = spawnSync(binaryPath, ['decrypt', '--key-id', keyId, '--data-stdin', '--via-daemon'], {
246+
input: stdinPayload,
247+
encoding: 'utf-8',
248+
timeout: 60_000,
249+
});
250+
if (proc.error) throw proc.error;
251+
if (proc.status !== 0) {
252+
const output = (proc.stdout || proc.stderr || '').trim();
253+
try {
254+
const parsed = JSON.parse(output);
255+
if (parsed.error) throw new Error(parsed.error);
256+
} catch { /* not JSON */ }
257+
throw new Error(`Decrypt failed (exit ${proc.status}): ${output}`);
258+
}
259+
const result = JSON.parse(proc.stdout.trim());
260+
if (result.error) throw new Error(result.error);
261+
debug(`decryptValue: WSL2 result: ${proc.stdout.trim().slice(0, 100)}`);
220262
return result.plaintext;
221263
}
222264
debug('decryptValue: biometric decrypt via daemon client');

0 commit comments

Comments
 (0)