secrets-vault 2.4.0

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
//! Windows session-broker transport: a named pipe that meets the same two
//! requirements the Unix socket does (see `session.rs` for R1/R2 and for the
//! enforcement pipeline, which is shared and lives there — this file decides
//! nothing, it only establishes *who is calling* and moves bytes).
//!
//! ## R1 — only the owning user may connect
//!
//! Unix gets this free from the filesystem: a 0600 socket inside a 0700
//! directory. The Windows pipe namespace is **machine-global** and has no
//! parent directory to hide behind, so R1 takes three separate measures:
//!
//! 1. The pipe is created with a **protected owner-only DACL** (`D:P(A;;GA;;;<sid>)`),
//!    so no other user's process can open it.
//! 2. It is created with `FILE_FLAG_FIRST_PIPE_INSTANCE`, so if the name
//!    already exists the broker REFUSES to start rather than silently becoming
//!    a second instance of someone else's pipe.
//! 3. The CLIENT verifies the pipe's **owner SID** matches its own before
//!    sending anything. This closes the squatting attack the global namespace
//!    creates: a hostile local process can create a pipe with our name *first*
//!    (measure 2 only protects the server), and would then receive our requests
//!    — which carry project and key NAMES — and could answer with forged
//!    values. An owner check on the client is the only thing that detects that,
//!    and unlike a normal broker miss it is reported loudly, because it means
//!    something on this machine is impersonating the vault.
//!
//! The pipe name itself carries the user SID and a digest of the secrets dir,
//! so two users (or two vaults) never contend for one name in the first place.
//!
//! ## R2 — caller identity comes from the kernel
//!
//! `GetNamedPipeClientProcessId` is the analogue of `LOCAL_PEERTOKEN`: the
//! kernel's answer, not the caller's claim. macOS pairs the pid with a
//! pidversion to close the pid-reuse race; Windows has no pidversion, so the
//! equivalent guarantee comes from immediately opening a **handle to the peer
//! process** and holding it for as long as the identity is in use. A pid is not
//! recycled while a handle to its process object is open, so the ancestry walk
//! cannot be aimed at a different process than the one that connected.
//!
//! The same-user check reads the peer's token user SID through that handle
//! (`OpenProcessToken` → `TokenUser`), so it too is kernel-sourced and needs no
//! impersonation — and, importantly, it happens BEFORE a single byte the peer
//! controls has been read, matching the Unix ordering.
//!
//! ## Bounded I/O
//!
//! Every wait is overlapped with a timeout: the accept loop wakes every 200ms
//! to check the lifetime deadline, and per-connection reads/writes are bounded
//! so one wedged client cannot pin the broker until expiry.

use std::path::Path;
use std::time::{Duration, Instant};

use sha2::{Digest, Sha256};
use zeroize::Zeroizing;

use windows::core::{HRESULT, HSTRING, PWSTR};
use windows::Win32::Foundation::{
    CloseHandle, GetLastError, LocalFree, ERROR_IO_PENDING, ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED,
    HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
};
use windows::Win32::Security::Authorization::{
    ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo,
    SDDL_REVISION_1, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{
    GetTokenInformation, TokenUser, OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSID,
    PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::Storage::FileSystem::{
    CreateFileW, ReadFile, WriteFile, FILE_FLAGS_AND_ATTRIBUTES, FILE_GENERIC_READ,
    FILE_GENERIC_WRITE, FILE_SHARE_NONE, OPEN_EXISTING,
};
use windows::Win32::System::Pipes::{
    ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, GetNamedPipeClientProcessId,
    WaitNamedPipeW, NAMED_PIPE_MODE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT,
};
use windows::Win32::System::Threading::{
    CreateEventW, OpenProcess, OpenProcessToken, ResetEvent, WaitForSingleObject,
    PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::System::IO::{CancelIo, GetOverlappedResult, OVERLAPPED};

use crate::session::{
    audit, decode_response, encode_verdict, handle_get, parse_request, Peer, Request,
};

/// `PIPE_ACCESS_DUPLEX` — both directions on one instance.
const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003;
/// Refuse to start if the name is already taken (see R1 measure 2).
const FILE_FLAG_FIRST_PIPE_INSTANCE: u32 = 0x0008_0000;
const FILE_FLAG_OVERLAPPED: u32 = 0x4000_0000;
/// `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION` on the client open.
/// Identification level lets the server *identify* us but NOT impersonate us to
/// third parties — the least authority that still satisfies R2.
const SECURITY_SQOS_PRESENT: u32 = 0x0010_0000;
const SECURITY_IDENTIFICATION: u32 = 0x0001_0000;

const ACCEPT_TICK_MS: u32 = 200;
const IO_TIMEOUT_MS: u32 = 5_000;
const MAX_REQUEST: usize = 1024;
/// How long a client waits for a busy single-instance pipe before giving up and
/// falling through to its normal unlock path.
const BUSY_WAIT_MS: u32 = 3_000;

/// Machine-global rendezvous name. Carries the user SID (so two users never
/// contend) and a digest of the secrets dir (so two vaults on one account get
/// separate brokers, matching the per-`SECRETS_DIR` socket on Unix).
pub fn pipe_name(secrets_dir: &Path) -> String {
    let sid = crate::winacl::current_user_sid().unwrap_or_else(|_| "unknown-sid".to_string());
    // The secrets dir exists by the time any broker runs (it holds vault.qvlt),
    // so canonicalize normally succeeds and both sides agree; the fallback keeps
    // the name well-defined rather than panicking if it does not.
    let canon = std::fs::canonicalize(secrets_dir).unwrap_or_else(|_| secrets_dir.to_path_buf());
    let mut h = Sha256::new();
    h.update(canon.to_string_lossy().to_lowercase().as_bytes());
    let digest = h.finalize();
    let tag: String = digest[..8].iter().map(|b| format!("{b:02x}")).collect();
    format!(r"\\.\pipe\io.quantumencoding.secrets\{sid}\{tag}")
}

// ── handles ──

/// Closes on drop. Also what pins a peer pid against recycling (see R2).
struct OwnedHandle(HANDLE);

impl Drop for OwnedHandle {
    fn drop(&mut self) {
        if !self.0.is_invalid() {
            unsafe {
                let _ = CloseHandle(self.0);
            }
        }
    }
}

/// A manual-reset event plus its OVERLAPPED, reused for every operation on one
/// pipe handle.
struct Ov {
    event: OwnedHandle,
    ov: OVERLAPPED,
}

impl Ov {
    fn new() -> Result<Self, String> {
        let event = unsafe { CreateEventW(None, true, false, None) }
            .map_err(|e| format!("CreateEvent: {e}"))?;
        let mut ov = OVERLAPPED::default();
        ov.hEvent = event;
        Ok(Self {
            event: OwnedHandle(event),
            ov,
        })
    }

    fn reset(&mut self) {
        unsafe {
            let _ = ResetEvent(self.event.0);
        }
        // Offsets must be cleared between operations on a byte-mode pipe.
        self.ov.Anonymous.Anonymous.Offset = 0;
        self.ov.Anonymous.Anonymous.OffsetHigh = 0;
    }
}

fn is_win32(e: &windows::core::Error, code: u32) -> bool {
    e.code() == HRESULT::from_win32(code)
}

/// Wait for a pending overlapped operation. `Ok(Some(n))` completed with n
/// bytes, `Ok(None)` timed out (the operation is cancelled), `Err` failed.
unsafe fn await_io(pipe: HANDLE, ov: &mut Ov, timeout_ms: u32) -> Result<Option<u32>, String> { unsafe {
    if WaitForSingleObject(ov.event.0, timeout_ms) != WAIT_OBJECT_0 {
        let _ = CancelIo(pipe);
        return Ok(None);
    }
    let mut n = 0u32;
    GetOverlappedResult(pipe, &ov.ov, &mut n, false)
        .map_err(|e| format!("GetOverlappedResult: {e}"))?;
    Ok(Some(n))
}}

unsafe fn pipe_read(pipe: HANDLE, ov: &mut Ov, buf: &mut [u8], timeout_ms: u32) -> Option<usize> { unsafe {
    ov.reset();
    match ReadFile(pipe, Some(buf), None, Some(&mut ov.ov)) {
        Ok(()) => {}
        Err(ref e) if is_win32(e, ERROR_IO_PENDING.0) => {}
        Err(_) => return None,
    }
    match await_io(pipe, ov, timeout_ms) {
        Ok(Some(n)) => Some(n as usize),
        _ => None,
    }
}}

unsafe fn pipe_write(pipe: HANDLE, ov: &mut Ov, buf: &[u8], timeout_ms: u32) -> bool { unsafe {
    let mut written = 0usize;
    while written < buf.len() {
        ov.reset();
        match WriteFile(pipe, Some(&buf[written..]), None, Some(&mut ov.ov)) {
            Ok(()) => {}
            Err(ref e) if is_win32(e, ERROR_IO_PENDING.0) => {}
            Err(_) => return false,
        }
        match await_io(pipe, ov, timeout_ms) {
            Ok(Some(0)) | Ok(None) | Err(_) => return false,
            Ok(Some(n)) => written += n as usize,
        }
    }
    true
}}

/// Read one newline-terminated request line, bounded at MAX_REQUEST.
unsafe fn read_line(pipe: HANDLE, ov: &mut Ov) -> Vec<u8> { unsafe {
    let mut out = Vec::with_capacity(64);
    let mut chunk = [0u8; 256];
    while out.len() < MAX_REQUEST {
        match pipe_read(pipe, ov, &mut chunk, IO_TIMEOUT_MS) {
            Some(0) | None => break,
            Some(n) => {
                for &b in &chunk[..n] {
                    if b == b'\n' {
                        return out;
                    }
                    out.push(b);
                    if out.len() >= MAX_REQUEST {
                        return out;
                    }
                }
            }
        }
    }
    out
}}

// ── SIDs ──

fn sid_to_string(sid: PSID) -> Option<String> {
    unsafe {
        let mut s = PWSTR::null();
        ConvertSidToStringSidW(sid, &mut s).ok()?;
        let out = s.to_string().ok();
        let _ = LocalFree(Some(HLOCAL(s.0 as *mut core::ffi::c_void)));
        out
    }
}

/// The user SID of an already-open process handle. Kernel-sourced; no
/// impersonation, so it is available before reading anything from the peer.
fn process_user_sid(proc: HANDLE) -> Option<String> {
    unsafe {
        let mut token = HANDLE::default();
        OpenProcessToken(proc, TOKEN_QUERY, &mut token).ok()?;
        let token = OwnedHandle(token);

        let mut len = 0u32;
        let _ = GetTokenInformation(token.0, TokenUser, None, 0, &mut len);
        if len == 0 {
            return None;
        }
        let mut buf = vec![0u8; len as usize];
        GetTokenInformation(
            token.0,
            TokenUser,
            Some(buf.as_mut_ptr() as *mut core::ffi::c_void),
            len,
            &mut len,
        )
        .ok()?;
        let tu = &*(buf.as_ptr() as *const TOKEN_USER);
        sid_to_string(tu.User.Sid)
    }
}

/// R2: who is on the other end of this connected pipe?
///
/// Returns the peer plus the process handle that PINS its pid — the caller must
/// keep the handle alive for as long as it uses `Peer.pid`, which is what makes
/// the ancestry walk safe against pid recycling.
fn peer_identity(pipe: HANDLE, our_sid: &str) -> Option<(Peer, OwnedHandle)> {
    unsafe {
        let mut pid: u32 = 0;
        GetNamedPipeClientProcessId(pipe, &mut pid).ok()?;

        // Pin the pid IMMEDIATELY. From here the pid cannot name a different
        // process, because the kernel will not recycle it while this handle is
        // open. (This is the guarantee macOS gets from the token's pidversion.)
        let proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
        let proc = OwnedHandle(proc);

        let peer_sid = process_user_sid(proc.0);
        let same_user = peer_sid.as_deref() == Some(our_sid);

        Some((
            Peer {
                pid: pid as i32,
                same_user,
                detail: "win-pipe".to_string(),
            },
            proc,
        ))
    }
}

/// The owner SID of a kernel object — used by the client to detect a squatted
/// pipe (R1 measure 3).
fn object_owner_sid(handle: HANDLE) -> Option<String> {
    unsafe {
        let mut owner = PSID::default();
        let mut psd = PSECURITY_DESCRIPTOR::default();
        let rc = GetSecurityInfo(
            handle,
            SE_KERNEL_OBJECT,
            OBJECT_SECURITY_INFORMATION(OWNER_SECURITY_INFORMATION.0),
            Some(&mut owner),
            None,
            None,
            None,
            Some(&mut psd),
        );
        if rc.is_err() {
            return None;
        }
        let out = sid_to_string(owner);
        if !psd.is_invalid() {
            let _ = LocalFree(Some(HLOCAL(psd.0)));
        }
        out
    }
}

// ── client ──

/// Open the broker pipe and verify it is really ours.
///
/// `Ok(None)` = no broker (the normal case — caller falls through silently).
/// `Err` = a broker-shaped thing exists but FAILED the owner check, i.e.
/// somebody is squatting our pipe name. That is reported, never swallowed.
fn connect_verified(secrets_dir: &Path) -> Result<Option<(OwnedHandle, Ov)>, String> {
    let name = HSTRING::from(pipe_name(secrets_dir));

    // The broker keeps ONE instance (one broker, like one socket), so a second
    // caller arriving mid-request gets ERROR_PIPE_BUSY. Unix would have queued
    // it in the listen backlog; here we must wait explicitly, otherwise a
    // concurrent `exec` silently falls back and costs the user a needless
    // presence prompt. Bounded, and a timeout still degrades to "no broker".
    let handle = loop {
        let h = unsafe {
            CreateFileW(
                &name,
                (FILE_GENERIC_READ | FILE_GENERIC_WRITE).0,
                FILE_SHARE_NONE,
                None,
                OPEN_EXISTING,
                FILE_FLAGS_AND_ATTRIBUTES(
                    FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION,
                ),
                None,
            )
        };
        if std::env::var_os("SECRETS_DEBUG_BROKER").is_some() {
            eprintln!(
                "secrets[broker-debug]: client open {} -> {:?}",
                pipe_name(secrets_dir),
                h.as_ref().err().map(|e| e.code())
            );
        }
        match h {
            Ok(h) if !h.is_invalid() => break OwnedHandle(h),
            Err(ref e) if is_win32(e, ERROR_PIPE_BUSY.0) => {
                // Returns false on timeout; either way, re-try the open once
                // more and give up if it is still busy.
                let waited = unsafe { WaitNamedPipeW(&name, BUSY_WAIT_MS).as_bool() };
                if !waited {
                    return Ok(None);
                }
            }
            // No pipe at all, or anything else — indistinguishable from
            // "no broker", which is a normal condition.
            _ => return Ok(None),
        }
    };

    let ours = crate::winacl::current_user_sid()?;
    match object_owner_sid(handle.0) {
        Some(owner) if owner == ours => {}
        other => {
            return Err(format!(
                "the session-broker pipe is owned by {} , not by you ({ours}).\n\
                 Refusing to send anything to it. Another process on this machine is \
                 squatting the vault's pipe name — treat this as hostile, not as a \
                 broken broker.",
                other.as_deref().unwrap_or("an unknown account")
            ));
        }
    }

    let ov = Ov::new()?;
    Ok(Some((handle, ov)))
}

/// CLIENT: ask a running broker for one value. `None` for every ordinary miss
/// (no broker, denied, expired) so callers fall through to the presence path
/// exactly as on Unix. A squatted pipe warns rather than failing silently.
pub fn request_value(
    secrets_dir: &Path,
    project: &str,
    key: &str,
) -> Option<Zeroizing<Vec<u8>>> {
    let (handle, mut ov) = match connect_verified(secrets_dir) {
        Ok(Some(x)) => x,
        Ok(None) => return None,
        Err(e) => {
            eprintln!("secrets: {e}");
            return None;
        }
    };

    let req = format!("GET {project} {key}\n");
    unsafe {
        if !pipe_write(handle.0, &mut ov, req.as_bytes(), IO_TIMEOUT_MS) {
            return None;
        }
        let mut buf = Zeroizing::new(Vec::new());
        let mut chunk = [0u8; 512];
        loop {
            match pipe_read(handle.0, &mut ov, &mut chunk, IO_TIMEOUT_MS) {
                Some(0) | None => break,
                Some(n) => buf.extend_from_slice(&chunk[..n]),
            }
            if buf.len() > 64 * 1024 {
                return None;
            }
        }
        decode_response(&buf)
    }
}

/// CLIENT: ask a running broker to shut down NOW (`secrets lock` / `rekey`).
/// Best-effort. There is no socket file to unlink — the pipe ceases to exist
/// when the broker process does.
pub fn end(secrets_dir: &Path) {
    if let Ok(Some((handle, mut ov))) = connect_verified(secrets_dir) {
        unsafe {
            let _ = pipe_write(handle.0, &mut ov, b"END\n", IO_TIMEOUT_MS);
        }
    }
}

// ── server ──

/// Create the listening pipe with a protected owner-only DACL (R1 measures 1+2).
fn create_pipe(name: &str, sid: &str) -> Result<OwnedHandle, String> {
    // D:P(A;;GA;;;<sid>) — protected (no inherited ACEs), generic-all, that SID
    // and nobody else. No entry for Administrators or SYSTEM: owner-only is the
    // point. (Both can still take ownership; so can root on Unix.)
    let sddl = HSTRING::from(format!("D:P(A;;GA;;;{sid})"));
    let wide = HSTRING::from(name);

    unsafe {
        let mut psd = PSECURITY_DESCRIPTOR::default();
        ConvertStringSecurityDescriptorToSecurityDescriptorW(&sddl, SDDL_REVISION_1, &mut psd, None)
            .map_err(|e| format!("building pipe security descriptor: {e}"))?;

        let sa = SECURITY_ATTRIBUTES {
            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
            lpSecurityDescriptor: psd.0,
            bInheritHandle: false.into(),
        };

        let h = CreateNamedPipeW(
            &wide,
            FILE_FLAGS_AND_ATTRIBUTES(
                PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED,
            ),
            NAMED_PIPE_MODE(PIPE_TYPE_BYTE.0 | PIPE_WAIT.0 | PIPE_REJECT_REMOTE_CLIENTS.0),
            1, // one instance: one broker, like one socket
            4096,
            4096,
            0,
            Some(&sa),
        );
        let _ = LocalFree(Some(HLOCAL(psd.0)));

        if h == INVALID_HANDLE_VALUE || h.is_invalid() {
            let err = GetLastError();
            return Err(format!(
                "could not create the broker pipe (WIN32_ERROR {}). \
                 If the name is already taken, another broker is running (or something \
                 is squatting it) — `secrets lock` ends a live one.",
                err.0
            ));
        }
        Ok(OwnedHandle(h))
    }
}

/// DAEMON: derive keys, zeroize the passphrase, then answer per-key requests
/// until the lifetime expires or an `END` arrives. Called ONLY by the hidden
/// `__session-serve` subcommand in the detached child; `pass` arrived on the
/// child's stdin (a pipe), never argv/env.
pub fn serve(secrets_dir: &Path, minutes: u64, pass: Zeroizing<String>) -> Result<(), String> {
    use secrets_vault::{v2_salt, MasterSecret, VaultReader};

    let vault_path = secrets_dir.join("vault.qvlt");

    // Fail closed on anything but a healthy v2 vault — a v1 vault must never
    // revive the passphrase-dispenser behavior (spec §8).
    let data = std::fs::read(&vault_path).map_err(|e| format!("read vault: {e}"))?;
    let salt = v2_salt(&data).map_err(|e| format!("not a v2 vault: {e}"))?;
    let master = MasterSecret::derive(&pass, &salt);
    drop(pass); // Zeroizing: the passphrase's lifetime ends HERE (G4).
    let registry_key_z = master.registry_key();
    let registry_key: &[u8; 32] = &registry_key_z;
    // Prove the derivation before serving anything (wrong passphrase → exit).
    VaultReader::open(data, &master).map_err(|e| format!("vault open: {e}"))?;

    let our_sid = crate::winacl::current_user_sid()?;
    let name = pipe_name(secrets_dir);
    // A bind failure is recorded by the caller (main.rs) for every platform, so
    // it lands in session.log exactly once — we are detached and stderr is null,
    // so that log is the only channel back to the human.
    let pipe = create_pipe(&name, &our_sid)?;
    let mut ov = Ov::new()?;
    audit(secrets_dir, &format!("PIPE {name}"));

    audit(secrets_dir, &format!("START lifetime={minutes}m"));
    let deadline = Instant::now() + Duration::from_secs(minutes.saturating_mul(60));

    // Arm the first connect; re-armed after each client disconnects.
    let mut armed = arm_connect(pipe.0, &mut ov)?;

    loop {
        if Instant::now() >= deadline {
            break;
        }
        if !armed {
            armed = arm_connect(pipe.0, &mut ov)?;
            continue;
        }

        // Wake regularly so the lifetime deadline is honoured even with no
        // traffic at all.
        let connected = unsafe {
            match await_io(pipe.0, &mut ov, ACCEPT_TICK_MS) {
                Ok(Some(_)) => true,
                Ok(None) => {
                    // Timed out: CancelIo dropped the pending connect, so re-arm.
                    armed = false;
                    continue;
                }
                Err(_) => {
                    armed = false;
                    continue;
                }
            }
        };
        if !connected {
            continue;
        }

        // R2: kernel-attested identity FIRST, before reading anything the peer
        // controls. `_pin` holds the pid stable for the whole request.
        let identity = peer_identity(pipe.0, &our_sid);
        let (peer, _pin) = match identity {
            Some((p, pin)) => (Some(p), Some(pin)),
            None => (None, None),
        };

        let line_bytes = unsafe { read_line(pipe.0, &mut ov) };
        let line = String::from_utf8_lossy(&line_bytes);

        let mut stop = false;
        match parse_request(&line) {
            Request::End => {
                audit(secrets_dir, "END (requested)");
                stop = true;
            }
            Request::Get { project, key } => {
                let verdict = handle_get(
                    secrets_dir,
                    &vault_path,
                    &master,
                    registry_key,
                    peer,
                    project,
                    key,
                );
                let out = encode_verdict(verdict);
                unsafe {
                    let _ = pipe_write(pipe.0, &mut ov, &out, IO_TIMEOUT_MS);
                }
            }
            Request::LegacyGet => {
                audit(secrets_dir, "LEGACY-GET (empty close)");
            }
            Request::Malformed => unsafe {
                let _ = pipe_write(pipe.0, &mut ov, b"ERR denied\n", IO_TIMEOUT_MS);
            },
        }

        unsafe {
            let _ = DisconnectNamedPipe(pipe.0);
        }
        armed = false;

        if stop {
            break;
        }
    }

    audit(secrets_dir, "STOP");
    Ok(())
}

/// Issue an overlapped `ConnectNamedPipe`. `Ok(true)` = a connect is pending or
/// already satisfied; `Ok(false)` = transient failure, try again.
fn arm_connect(pipe: HANDLE, ov: &mut Ov) -> Result<bool, String> {
    ov.reset();
    unsafe {
        match ConnectNamedPipe(pipe, Some(&mut ov.ov)) {
            // Completed synchronously.
            Ok(()) => {
                let _ = windows::Win32::System::Threading::SetEvent(ov.event.0);
                Ok(true)
            }
            Err(ref e) if is_win32(e, ERROR_IO_PENDING.0) => Ok(true),
            // A client connected in the window between create and connect.
            Err(ref e) if is_win32(e, ERROR_PIPE_CONNECTED.0) => {
                let _ = windows::Win32::System::Threading::SetEvent(ov.event.0);
                Ok(true)
            }
            Err(_) => Ok(false),
        }
    }
}

/// Probe the pipe, verifying it belongs to us.
///
/// A cheap `WaitNamedPipe` existence check is NOT enough here: a squatter's
/// pipe answers it just as readily as ours, which would let `secrets session`
/// report success while our own broker was exiting with "name already taken".
/// So this performs the same owner check the client does. It costs one accept
/// cycle against our own broker (the connection closes without a request, which
/// the serve loop treats as a malformed line and discards) — cheap, and only on
/// the startup path.
pub fn probe(secrets_dir: &Path) -> crate::session::Probe {
    use crate::session::Probe;
    match connect_verified(secrets_dir) {
        Ok(Some(_)) => Probe::Live,
        Ok(None) => Probe::Absent,
        Err(why) => Probe::Foreign(why),
    }
}

/// Unix detaches in the CHILD via `setsid`. Windows cannot: a console process
/// is bound to its console at creation, so detaching is the PARENT's job via
/// `DETACHED_PROCESS` at spawn time (see `main.rs`). Nothing to do here —
/// kept so the two platforms present the same interface.
pub fn detach() {}