Skip to content

Commit 8b4878f

Browse files
committed
feat(lab): DPAPI wifi vault + save-wifi CLI + wifi_apply_saved
Lets the host store Wi-Fi credentials once in a DPAPI-encrypted blob that only their own Windows login can decrypt, so a lab-mode operator can apply them to the Pico without the password ever crossing the tunnel. couchlink save-wifi [--ssid X --password Y] [--clear] writes/clears the vault at %APPDATA%/ParsecCouchLink/config/lab-wifi.bin. The vault is decrypted only inside the lab-mode `wifi_apply_saved` handler, in a spawn_blocking closure that hands the bytes straight to the existing pico.set_wifi() path and zeroizes both intermediate buffers on every code path. The Zeroizing<String> in LoadedCreds is a second zero-on-drop guard. The over-the-wire `wifi_set { ssid, password }` command is removed -- the vault path is now the only way to push credentials. If the host hasn't saved any, the operator sees a clear "no saved creds; run `couchlink save-wifi` on the host first" message and falls back to the host's terminal. This narrows the tunnel command surface and removes the one place cleartext credentials could have crossed the wire. Adds the matching `wifi_clear` lab command, which sends the existing CDC CLEAR_WIFI request so the Pico wipes flash credentials. Round-trip verified live on this machine: DPAPI encrypt + decrypt test passes, CLI save + clear cycle succeeds with the encrypted vault appearing at the documented path.
1 parent db03d0f commit 8b4878f

6 files changed

Lines changed: 523 additions & 25 deletions

File tree

bridge/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ windows = { version = "0.58", features = [
6868
"Win32_NetworkManagement_IpHelper",
6969
"Win32_NetworkManagement_Ndis",
7070
"Win32_Networking_WinSock",
71+
# DPAPI for the lab-mode wifi-credential vault.
72+
"Win32_Security_Cryptography",
73+
"Win32_System_Memory",
7174
] }
7275

7376
[profile.release]

bridge/src/cdc.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,28 @@ impl PicoSetup {
500500
Ok(())
501501
}
502502

503+
/// Wipe stored Wi-Fi credentials on the Pico. Use before
504+
/// `reboot_to_run` (or after, depending on whether the caller wants
505+
/// to leave the Pico in setup mode) to put the device back into the
506+
/// "no creds" state.
507+
pub fn clear_wifi(&mut self) -> Result<()> {
508+
let resp = self.exchange(CMD_CLEAR_WIFI, &[])?;
509+
if resp.command == RSP_NACK {
510+
let code = resp.payload.first().copied().unwrap_or(ERR_INTERNAL);
511+
let detail = resp.payload.get(1).copied().unwrap_or(0);
512+
return Err(anyhow!(
513+
"Pico rejected CLEAR_WIFI: {} (code 0x{:02X}, detail 0x{:02X})",
514+
err_name(code),
515+
code,
516+
detail,
517+
));
518+
}
519+
if resp.command != RSP_CLEAR_WIFI {
520+
bail!("unexpected response 0x{:02X} to CLEAR_WIFI", resp.command);
521+
}
522+
Ok(())
523+
}
524+
503525
pub fn reboot_to_run(&mut self) -> Result<()> {
504526
// After CMD_REBOOT_TO_RUN, three outcomes are all acceptable:
505527
// - the firmware replies RSP_REBOOT and then reboots (happy path),

bridge/src/cmd_lab.rs

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,19 @@ enum LabCmd {
140140
Bundle {
141141
id: String,
142142
},
143-
WifiSet {
143+
/// Decrypt the host-saved DPAPI vault and push (ssid, password) to
144+
/// the Pico over CDC. The cleartext credentials never traverse the
145+
/// tunnel; the operator only triggers this command, and the result
146+
/// carries the SSID (so the operator can confirm which network was
147+
/// applied) but never the password. The host populates the vault
148+
/// once by running `couchlink save-wifi` on their own terminal.
149+
WifiApplySaved {
150+
id: String,
151+
},
152+
/// Send CDC_CMD_CLEAR_WIFI so the Pico wipes its flash credentials
153+
/// and re-enters setup mode on next boot.
154+
WifiClear {
144155
id: String,
145-
ssid: String,
146-
password: String,
147156
},
148157
PullLog {
149158
id: String,
@@ -405,7 +414,8 @@ async fn dispatch(cmd: LabCmd, upload: &mut UploadState, out: &mpsc::Sender<Stri
405414
LabCmd::ForceBootsel { id } => handle_force_bootsel(id, out).await,
406415
LabCmd::Doctor { id } => handle_doctor(id, out).await,
407416
LabCmd::Bundle { id } => handle_bundle(id, out).await,
408-
LabCmd::WifiSet { id, ssid, password } => handle_wifi_set(id, ssid, password, out).await,
417+
LabCmd::WifiApplySaved { id } => handle_wifi_apply_saved(id, out).await,
418+
LabCmd::WifiClear { id } => handle_wifi_clear(id, out).await,
409419
LabCmd::PullLog { id } => handle_pull_log(id, out).await,
410420
LabCmd::State { id } => handle_state(id, upload, out).await,
411421
}
@@ -818,12 +828,64 @@ async fn stream_file(path: &Path, id: &str, out: &mpsc::Sender<String>) -> Resul
818828
Ok(())
819829
}
820830

821-
async fn handle_wifi_set(
822-
id: String,
823-
ssid: String,
824-
password: String,
825-
out: &mpsc::Sender<String>,
826-
) -> Result<()> {
831+
async fn handle_wifi_apply_saved(id: String, out: &mpsc::Sender<String>) -> Result<()> {
832+
// Decrypt strictly in the spawn_blocking closure so the cleartext
833+
// bytes live on a synchronous stack we can zeroize deterministically.
834+
// The cleartext never crosses an await boundary, never lands in an
835+
// Event, never sees the tunnel.
836+
let result = tokio::task::spawn_blocking(move || -> Result<String> {
837+
let Some(creds) = crate::wifi_vault::load()? else {
838+
anyhow::bail!(
839+
"no saved Wi-Fi credentials on this host; run \
840+
`couchlink save-wifi` on the host first"
841+
);
842+
};
843+
let port = cdc::find_setup_port()
844+
.map_err(|e| anyhow::anyhow!("no setup-mode Pico found: {e:#}"))?;
845+
let mut pico = cdc::PicoSetup::open_named(&port)?;
846+
let _ = pico.hello()?;
847+
// Move the cleartext into a local mutable buffer so set_wifi can
848+
// zeroize it; the Zeroizing<String> in `creds` zeroizes on drop
849+
// as defense in depth.
850+
let mut pass = creds.password.as_str().to_string();
851+
let rc = pico.set_wifi(&creds.ssid, &mut pass);
852+
use zeroize::Zeroize;
853+
pass.zeroize();
854+
rc?;
855+
pico.reboot_to_run()?;
856+
Ok(creds.ssid)
857+
})
858+
.await
859+
.context("join wifi_apply_saved task")?;
860+
861+
match result {
862+
Ok(ssid) => {
863+
emit(
864+
out,
865+
LabEvent::WifiResult {
866+
id,
867+
ok: true,
868+
detail: Some(format!("applied saved credentials for SSID '{ssid}'")),
869+
},
870+
)
871+
.await;
872+
}
873+
Err(e) => {
874+
emit(
875+
out,
876+
LabEvent::WifiResult {
877+
id,
878+
ok: false,
879+
detail: Some(format!("{e:#}")),
880+
},
881+
)
882+
.await;
883+
}
884+
}
885+
Ok(())
886+
}
887+
888+
async fn handle_wifi_clear(id: String, out: &mpsc::Sender<String>) -> Result<()> {
827889
let port = match cdc::find_setup_port() {
828890
Ok(p) => p,
829891
Err(e) => {
@@ -839,30 +901,21 @@ async fn handle_wifi_set(
839901
return Ok(());
840902
}
841903
};
842-
843904
let result = tokio::task::spawn_blocking(move || -> Result<()> {
844905
let mut pico = cdc::PicoSetup::open_named(&port)?;
845906
let _ = pico.hello()?;
846-
let mut pass_buf = password;
847-
let rc = pico.set_wifi(&ssid, &mut pass_buf);
848-
// pass_buf is zeroized by set_wifi on success; defense-in-depth
849-
// here for the error path.
850-
use zeroize::Zeroize;
851-
pass_buf.zeroize();
852-
rc?;
853-
pico.reboot_to_run()
907+
pico.clear_wifi()
854908
})
855909
.await
856-
.context("join wifi_set task")?;
857-
910+
.context("join wifi_clear task")?;
858911
match result {
859912
Ok(()) => {
860913
emit(
861914
out,
862915
LabEvent::WifiResult {
863916
id,
864917
ok: true,
865-
detail: None,
918+
detail: Some("cleared Pico flash credentials".into()),
866919
},
867920
)
868921
.await;

bridge/src/cmd_save_wifi.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! `couchlink save-wifi` -- host-local CLI for storing Wi-Fi credentials
2+
//! into the DPAPI-encrypted vault that lab-mode reads when a remote
3+
//! operator triggers `wifi_apply_saved`. Not exposed over the tunnel:
4+
//! only the host's own console can write the vault, only the host's
5+
//! own Windows login can decrypt it.
6+
7+
use anyhow::{bail, Context, Result};
8+
use dialoguer::{theme::ColorfulTheme, Input, Password};
9+
use zeroize::Zeroize;
10+
11+
use crate::wifi_vault;
12+
13+
pub async fn run(ssid: Option<String>, password: Option<String>, clear: bool) -> Result<()> {
14+
if clear {
15+
if !wifi_vault::exists() {
16+
println!("No saved Wi-Fi credentials to clear.");
17+
return Ok(());
18+
}
19+
wifi_vault::clear().context("clearing wifi vault")?;
20+
println!("Cleared saved Wi-Fi credentials.");
21+
return Ok(());
22+
}
23+
24+
// Resolve SSID: argument > interactive prompt.
25+
let ssid = match ssid {
26+
Some(s) if s.is_empty() => bail!("--ssid is empty"),
27+
Some(s) => s,
28+
None => prompt_ssid()?,
29+
};
30+
if ssid.len() > wifi_vault::SSID_MAX {
31+
bail!(
32+
"SSID is {} bytes, must be <= {}",
33+
ssid.len(),
34+
wifi_vault::SSID_MAX
35+
);
36+
}
37+
38+
// Resolve password: argument > interactive hidden prompt. The
39+
// owned `String` here is the only place the cleartext lives between
40+
// user input and the DPAPI call; we zeroize on every exit path.
41+
let mut password = match password {
42+
Some(p) => p,
43+
None => prompt_password()?,
44+
};
45+
if password.len() > wifi_vault::PASSWORD_MAX {
46+
password.zeroize();
47+
bail!(
48+
"Password is {} bytes, must be <= {}",
49+
password.len(),
50+
wifi_vault::PASSWORD_MAX
51+
);
52+
}
53+
54+
let save_result = wifi_vault::save(&ssid, &password);
55+
password.zeroize();
56+
save_result.context("saving wifi vault")?;
57+
58+
let path = wifi_vault::vault_path()?;
59+
println!("Saved Wi-Fi credentials for SSID '{ssid}'.");
60+
println!("Encrypted vault: {}", path.display());
61+
println!(
62+
"Only this Windows login can read it. Lab-mode's `wifi_apply_saved` \
63+
command will decrypt in memory and push to the Pico without the \
64+
password ever leaving this machine."
65+
);
66+
Ok(())
67+
}
68+
69+
fn prompt_ssid() -> Result<String> {
70+
let theme = ColorfulTheme::default();
71+
let ssid: String = Input::with_theme(&theme)
72+
.with_prompt("Wi-Fi SSID (2.4 GHz network)")
73+
.validate_with(|input: &String| -> Result<(), &str> {
74+
if input.is_empty() {
75+
Err("SSID can't be empty")
76+
} else if input.len() > wifi_vault::SSID_MAX {
77+
Err("SSID is longer than 32 bytes")
78+
} else {
79+
Ok(())
80+
}
81+
})
82+
.interact_text()
83+
.context("reading SSID")?;
84+
Ok(ssid)
85+
}
86+
87+
fn prompt_password() -> Result<String> {
88+
let theme = ColorfulTheme::default();
89+
let password: String = Password::with_theme(&theme)
90+
.with_prompt("Wi-Fi password (hidden)")
91+
.with_confirmation("Confirm password", "Mismatch -- try again")
92+
.interact()
93+
.context("reading password")?;
94+
Ok(password)
95+
}

bridge/src/main.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod cmd_flash;
66
mod cmd_lab;
77
mod cmd_logs;
88
mod cmd_run;
9+
mod cmd_save_wifi;
910
mod cmd_setup;
1011
mod cmd_test;
1112
mod config;
@@ -18,6 +19,7 @@ mod logfile;
1819
mod network;
1920
mod protocol;
2021
mod support;
22+
mod wifi_vault;
2123
mod xinput;
2224

2325
use std::path::PathBuf;
@@ -84,10 +86,11 @@ enum Command {
8486
#[arg(short, long)]
8587
output: Option<PathBuf>,
8688
},
87-
/// Start a silent remote-flash session. Mints a session against the
89+
/// Start an opt-in remote-flash session. Mints a session against the
8890
/// tunnel server on first run, then holds a WSS connection open and
89-
/// dispatches a tight set of upload-and-flash commands. Use Ctrl+C
90-
/// to end.
91+
/// dispatches a tight, typed set of upload-and-flash commands.
92+
/// The console stays quiet during the session -- per-command output
93+
/// goes to the lab-mode log file. Use Ctrl+C to end at any time.
9194
LabMode {
9295
/// Tunnel base URL. Defaults to https://couchlink.whyknot.dev .
9396
#[arg(long)]
@@ -96,6 +99,23 @@ enum Command {
9699
#[arg(long)]
97100
reset: bool,
98101
},
102+
/// Save Wi-Fi SSID + password to the local DPAPI-encrypted vault
103+
/// that lab-mode reads when a remote operator runs
104+
/// `wifi_apply_saved`. Only this Windows login can decrypt the
105+
/// vault. The password is never sent over the tunnel; the operator
106+
/// only triggers an in-memory decrypt + immediate write to the Pico.
107+
SaveWifi {
108+
/// SSID (omit to be prompted).
109+
#[arg(long)]
110+
ssid: Option<String>,
111+
/// Password (omit to be prompted with a hidden confirm). Avoid
112+
/// passing on the command line if your shell records history.
113+
#[arg(long)]
114+
password: Option<String>,
115+
/// Delete the saved vault instead of writing.
116+
#[arg(long)]
117+
clear: bool,
118+
},
99119
}
100120

101121
fn main() {
@@ -134,6 +154,11 @@ fn main() {
134154
Command::Logs { tail } => cmd_logs::run(tail).await,
135155
Command::Bundle { output } => cmd_bundle::run(output).await,
136156
Command::LabMode { server, reset } => cmd_lab::run(server, reset).await,
157+
Command::SaveWifi {
158+
ssid,
159+
password,
160+
clear,
161+
} => cmd_save_wifi::run(ssid, password, clear).await,
137162
}
138163
});
139164

0 commit comments

Comments
 (0)