Skip to content

Commit 7a31401

Browse files
theoephraimclaude
andcommitted
harden native binaries: peer verification, IPC security, and build improvements
- Add peer identity verification and process validation for IPC connections (Swift + Rust) - Strengthen memory protection for key material with zeroize on drop - Add entitlements file for macOS sandbox/hardened runtime - Update build scripts for universal binary support - Fix CI workflow to use build:universal command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6a2d419 commit 7a31401

15 files changed

Lines changed: 304 additions & 51 deletions

File tree

.github/workflows/build-native-macos.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ jobs:
102102
# Compile (cached), bundle with mode-specific metadata, and sign
103103
- name: Build, bundle, and sign
104104
run: |
105-
bun run --filter @varlock/encryption-binary-swift build:swift \
105+
bun run --filter @varlock/encryption-binary-swift build:universal \
106106
-- --mode ${{ inputs.mode }} --version ${{ inputs.version }} --sign "$APPLE_SIGNING_IDENTITY"
107107
108108
- name: Verify binary

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use p256::{
2222
PublicKey, SecretKey,
2323
};
2424
use sha2::Sha256;
25+
use zeroize::Zeroize;
2526

2627
const PAYLOAD_VERSION: u8 = 0x01;
2728
const HKDF_SALT: &[u8] = b"varlock-ecies-v1";
@@ -107,7 +108,11 @@ pub fn encrypt(public_key_base64: &str, plaintext: &[u8]) -> Result<String, Stri
107108

108109
// AES-256-GCM encrypt
109110
let cipher = Aes256Gcm::new_from_slice(&aes_key)
110-
.map_err(|e| format!("AES key init failed: {e}"))?;
111+
.map_err(|e| {
112+
aes_key.zeroize();
113+
format!("AES key init failed: {e}")
114+
})?;
115+
aes_key.zeroize(); // Cipher has its own copy
111116

112117
let mut nonce_bytes = [0u8; NONCE_LENGTH];
113118
rand::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes);
@@ -166,11 +171,15 @@ pub fn decrypt(
166171
}
167172

168173
// Import private key from PKCS8 DER
169-
let private_key_der = BASE64
174+
let mut private_key_der = BASE64
170175
.decode(private_key_base64)
171176
.map_err(|e| format!("Invalid private key base64: {e}"))?;
172177
let secret_key = SecretKey::from_pkcs8_der(&private_key_der)
173-
.map_err(|e| format!("Invalid PKCS8 private key: {e}"))?;
178+
.map_err(|e| {
179+
private_key_der.zeroize();
180+
format!("Invalid PKCS8 private key: {e}")
181+
})?;
182+
private_key_der.zeroize(); // No longer needed — SecretKey has its own copy
174183

175184
// Import ephemeral public key
176185
let ephemeral_point = p256::EncodedPoint::from_bytes(ephemeral_pub_raw)
@@ -204,7 +213,12 @@ pub fn decrypt(
204213
// AES-256-GCM decrypt
205214
// aes-gcm expects ciphertext || tag concatenated (same as wire format after header)
206215
let cipher = Aes256Gcm::new_from_slice(&aes_key)
207-
.map_err(|e| format!("AES key init failed: {e}"))?;
216+
.map_err(|e| {
217+
aes_key.zeroize();
218+
format!("AES key init failed: {e}")
219+
})?;
220+
aes_key.zeroize(); // Cipher has its own copy — zeroize ours
221+
208222
let nonce = Nonce::from_slice(nonce_bytes);
209223

210224
let plaintext = cipher

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,14 @@ fn handle_decrypt(
208208
match key_store::load_key(key_id) {
209209
Ok((private_key_der, public_key_b64)) => {
210210
let secure_key = crate::secure_mem::SecureBytes::new(private_key_der);
211-
let private_key_b64 = base64::Engine::encode(
212-
&base64::engine::general_purpose::STANDARD,
213-
secure_key.as_slice(),
211+
let private_key_b64 = crate::secure_mem::SecureString::new(
212+
base64::Engine::encode(
213+
&base64::engine::general_purpose::STANDARD,
214+
secure_key.as_slice(),
215+
),
214216
);
215217

216-
let result = match crypto::decrypt(&private_key_b64, &public_key_b64, ciphertext_b64) {
218+
let result = match crypto::decrypt(private_key_b64.as_str(), &public_key_b64, ciphertext_b64) {
217219
Ok(plaintext_bytes) => {
218220
match String::from_utf8(plaintext_bytes) {
219221
Ok(plaintext) => {

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

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,42 @@ impl IpcServer {
5656
/// Start the IPC server. This blocks the calling thread.
5757
#[cfg(unix)]
5858
pub fn start(&self) -> Result<(), String> {
59-
// Clean up stale socket
60-
let _ = std::fs::remove_file(&self.socket_path);
61-
62-
// Ensure parent directory exists
59+
// Ensure parent directory exists with restricted permissions
6360
if let Some(parent) = std::path::Path::new(&self.socket_path).parent() {
6461
std::fs::create_dir_all(parent)
6562
.map_err(|e| format!("Failed to create socket directory: {e}"))?;
63+
// Restrict directory to owner only (0700) — prevents other users
64+
// from listing socket files or key filenames
65+
use std::os::unix::fs::PermissionsExt;
66+
let _ = std::fs::set_permissions(
67+
parent,
68+
std::fs::Permissions::from_mode(0o700),
69+
);
6670
}
6771

72+
// Acquire exclusive lock before touching the socket file.
73+
// Prevents a TOCTOU race where a malicious process could create a fake
74+
// socket between our unlink() and bind(), intercepting client connections.
75+
let lock_path = format!("{}.lock", self.socket_path);
76+
let lock_fd = {
77+
use std::os::unix::fs::OpenOptionsExt;
78+
std::fs::OpenOptions::new()
79+
.read(true)
80+
.write(true)
81+
.create(true)
82+
.mode(0o600)
83+
.open(&lock_path)
84+
.map_err(|e| format!("Failed to create lock file: {e}"))?
85+
};
86+
use std::os::unix::io::AsRawFd;
87+
let lock_result = unsafe { libc::flock(lock_fd.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
88+
if lock_result != 0 {
89+
return Err("Another daemon instance holds the lock".into());
90+
}
91+
92+
// Safe to remove stale socket now — we hold the lock
93+
let _ = std::fs::remove_file(&self.socket_path);
94+
6895
let listener = UnixListener::bind(&self.socket_path)
6996
.map_err(|e| format!("Socket bind failed: {e}"))?;
7097

@@ -96,6 +123,12 @@ impl IpcServer {
96123
let on_activity = self.on_activity.clone();
97124
let running = self.running.clone();
98125

126+
// Verify the connecting process is a trusted varlock binary
127+
if !verify_unix_client(&stream) {
128+
eprintln!("Rejected connection from untrusted process");
129+
continue;
130+
}
131+
99132
// Get peer TTY identity
100133
let tty_id = get_peer_tty_id(&stream);
101134

@@ -116,8 +149,9 @@ impl IpcServer {
116149
}
117150
}
118151

119-
// Cleanup
152+
// Cleanup socket and lock
120153
let _ = std::fs::remove_file(&self.socket_path);
154+
let _ = std::fs::remove_file(format!("{}.lock", self.socket_path));
121155
Ok(())
122156
}
123157

@@ -227,6 +261,7 @@ impl Drop for IpcServer {
227261
fn drop(&mut self) {
228262
self.stop();
229263
let _ = std::fs::remove_file(&self.socket_path);
264+
let _ = std::fs::remove_file(format!("{}.lock", self.socket_path));
230265
}
231266
}
232267

@@ -309,6 +344,60 @@ fn send_response(stream: &mut impl Write, id: Option<&str>, response: &Value) ->
309344
Ok(())
310345
}
311346

347+
// ── Unix client process verification ─────────────────────────────
348+
349+
/// Verify that the connecting client process is a trusted varlock binary.
350+
/// Uses peer credentials to get the client PID, then reads /proc/<pid>/exe.
351+
#[cfg(target_os = "linux")]
352+
fn verify_unix_client(stream: &UnixStream) -> bool {
353+
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
354+
use std::os::fd::AsFd;
355+
356+
let creds = match getsockopt(&stream.as_fd(), PeerCredentials) {
357+
Ok(c) => c,
358+
Err(_) => return false,
359+
};
360+
361+
let pid = creds.pid();
362+
if pid <= 0 {
363+
return false;
364+
}
365+
366+
// Read the executable path via /proc/<pid>/exe symlink
367+
let exe_path = match std::fs::read_link(format!("/proc/{pid}/exe")) {
368+
Ok(p) => p,
369+
Err(_) => return false,
370+
};
371+
372+
let exe_name = exe_path
373+
.file_name()
374+
.and_then(|n| n.to_str())
375+
.unwrap_or("");
376+
377+
let allowed = [
378+
"varlock-local-encrypt",
379+
"varlock",
380+
"node",
381+
"bun",
382+
];
383+
384+
let is_trusted = allowed.iter().any(|name| exe_name == *name);
385+
if !is_trusted {
386+
eprintln!(
387+
"Untrusted client process: PID={pid}, exe={}",
388+
exe_path.display()
389+
);
390+
}
391+
is_trusted
392+
}
393+
394+
/// macOS: no /proc filesystem, skip process verification for now.
395+
/// The Unix socket 0600 permissions still restrict to the owning user.
396+
#[cfg(all(unix, not(target_os = "linux")))]
397+
fn verify_unix_client(_stream: &UnixStream) -> bool {
398+
true
399+
}
400+
312401
// ── Peer TTY identity (Linux) ────────────────────────────────────
313402

314403
#[cfg(target_os = "linux")]

packages/encryption-binary-rust/src/key_store/mod.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,19 @@ pub fn generate_key(key_id: &str) -> Result<String, String> {
268268
created_at: now_iso8601(),
269269
};
270270

271-
// Write to disk
271+
// Write to disk — restrict directory permissions to owner only
272272
let dir = get_key_store_dir();
273273
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create key store: {e}"))?;
274+
#[cfg(unix)]
275+
{
276+
use std::os::unix::fs::PermissionsExt;
277+
// Set key store directory to 0700 — prevents other users from listing key files
278+
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
279+
// Also restrict parent directories
280+
if let Some(parent) = dir.parent() {
281+
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
282+
}
283+
}
274284

275285
let path = get_key_file_path(key_id);
276286
let json = serde_json::to_string_pretty(&stored)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,9 @@ fn cmd_decrypt(args: &[String]) {
181181
};
182182

183183
let secure_key = secure_mem::SecureBytes::new(private_key_der);
184-
let private_key_b64 = BASE64.encode(secure_key.as_slice());
184+
let private_key_b64 = secure_mem::SecureString::new(BASE64.encode(secure_key.as_slice()));
185185

186-
match crypto::decrypt(&private_key_b64, &public_key_b64, &data_b64) {
186+
match crypto::decrypt(private_key_b64.as_str(), &public_key_b64, &data_b64) {
187187
Ok(plaintext_bytes) => {
188188
let plaintext = match String::from_utf8(plaintext_bytes) {
189189
Ok(s) => s,

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ impl SecureBytes {
1515
/// Create a SecureBytes from existing data. Locks the memory region.
1616
pub fn new(data: Vec<u8>) -> Self {
1717
if !data.is_empty() {
18-
lock_memory(data.as_ptr(), data.len());
18+
lock_memory(data.as_ptr(), data.capacity());
1919
}
2020
Self { inner: data }
2121
}
@@ -27,15 +27,39 @@ impl SecureBytes {
2727

2828
impl Drop for SecureBytes {
2929
fn drop(&mut self) {
30-
let ptr = self.inner.as_ptr();
31-
let len = self.inner.len();
32-
33-
// Zeroize the contents
30+
// Zeroize first while memory is still valid and locked
31+
let cap = self.inner.capacity();
3432
self.inner.zeroize();
3533

36-
// Unlock the memory region
37-
if len > 0 {
38-
unlock_memory(ptr, len);
34+
// Unlock after zeroizing — pointer is still valid (Vec keeps allocation until drop)
35+
if cap > 0 {
36+
unlock_memory(self.inner.as_ptr(), cap);
37+
}
38+
}
39+
}
40+
41+
/// A String wrapper that zeroizes on drop. For derived representations
42+
/// of key material (e.g., base64-encoded private keys).
43+
pub struct SecureString {
44+
inner: String,
45+
}
46+
47+
impl SecureString {
48+
pub fn new(s: String) -> Self {
49+
Self { inner: s }
50+
}
51+
52+
pub fn as_str(&self) -> &str {
53+
&self.inner
54+
}
55+
}
56+
57+
impl Drop for SecureString {
58+
fn drop(&mut self) {
59+
// Safety: zeroize the underlying bytes
60+
unsafe {
61+
let bytes = self.inner.as_bytes_mut();
62+
bytes.zeroize();
3963
}
4064
}
4165
}

packages/encryption-binary-swift/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ Rust is planned for Windows (TPM / Windows Hello) and Linux (TPM2), where the pl
2020

2121
```bash
2222
# Local dev (current arch, dev mode)
23-
bun run build:swift:dev
23+
bun run build:current
2424

2525
# Universal binary (arm64 + x86_64, for CI)
26-
bun run build:swift
26+
bun run build:universal
2727

2828
# With signing and release metadata
29-
bun run build:swift -- --mode release --version 1.2.3 --sign "Developer ID Application: ..."
29+
bun run build:universal -- --mode release --version 1.2.3 --sign "Developer ID Application: ..."
3030
```
3131

3232
Output: `packages/varlock/native-bins/darwin/VarlockEnclave.app`
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.cs.disable-library-validation</key>
6+
<false/>
7+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8+
<false/>
9+
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
10+
<false/>
11+
<key>com.apple.security.cs.disable-executable-page-protection</key>
12+
<false/>
13+
<key>com.apple.security.cs.allow-jit</key>
14+
<false/>
15+
<key>com.apple.security.cs.debugger</key>
16+
<false/>
17+
</dict>
18+
</plist>

packages/encryption-binary-swift/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"private": true,
66
"scripts": {
77
"kill-daemon": "bun run scripts/kill-daemon.ts",
8-
"build:swift": "bun run kill-daemon && bun run scripts/build-swift.ts --universal",
9-
"build:swift:dev": "bun run kill-daemon && bun run scripts/build-swift.ts",
8+
"build:universal": "bun run kill-daemon && bun run scripts/build-swift.ts --universal",
9+
"build:current": "bun run kill-daemon && bun run scripts/build-swift.ts",
1010
"clean": "rm -rf swift/.build"
1111
},
1212
"devDependencies": {

0 commit comments

Comments
 (0)