use std::path::PathBuf;
use zeroize::Zeroizing;
use windows::core::{IInspectable, Interface, GUID, HRESULT, HSTRING, PCWSTR};
use windows::Security::Credentials::UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
};
use windows::Win32::Foundation::{HWND, LocalFree, HLOCAL};
use windows::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB,
};
use windows::Win32::System::Console::GetConsoleWindow;
use windows_future::IAsyncOperation;
const APP_TAG: &str = "io.quantumencoding.secrets";
const ACCOUNT_MASTER: &str = "vault-master";
const ACCOUNT_INBOX: &str = "inbox-identity";
const CRYPTPROTECT_UI_FORBIDDEN: u32 = 0x1;
const MAGIC: &[u8] = b"QSWK1\0";
const FLAG_PRESENCE: u8 = 0b0000_0001;
pub fn keyring_dir() -> PathBuf {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| dirs::data_local_dir())
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("AppData")
.join("Local")
});
base.join("quantum-encoding").join("secrets").join("keyring")
}
fn safe_name(account: &str) -> String {
let mut out = String::with_capacity(account.len() + 8);
for b in account.as_bytes() {
let c = *b as char;
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
out.push(c);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn account_file(account: &str) -> PathBuf {
keyring_dir().join(format!("{}.dpapi", safe_name(account)))
}
fn entropy(account: &str, flags: u8) -> Vec<u8> {
format!("{APP_TAG}|{account}|{flags}").into_bytes()
}
fn blob(data: &[u8]) -> CRYPT_INTEGER_BLOB {
CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
}
}
unsafe fn take_blob(out: &mut CRYPT_INTEGER_BLOB) -> Vec<u8> { unsafe {
let v = if out.pbData.is_null() {
Vec::new()
} else {
std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec()
};
if !out.pbData.is_null() {
let _ = LocalFree(Some(HLOCAL(out.pbData as *mut core::ffi::c_void)));
out.pbData = std::ptr::null_mut();
out.cbData = 0;
}
v
}}
fn explain_dpapi_failure(op: &str, e: &windows::core::Error) -> String {
format!(
"{op} failed: {e}\n\
Windows DPAPI unlocks its user key from the credential in your logon session.\n\
If you reached this machine over ssh with a PUBLIC KEY, that session has no\n\
such credential and DPAPI cannot work — this is expected, not a broken vault.\n\
Use SECRETS_PASSPHRASE, an interactive passphrase prompt, or have the\n\
interactive desktop user hand over a lease (`secrets lease create`)."
)
}
fn protect(plain: &[u8], entropy: &[u8]) -> Result<Vec<u8>, String> {
let din = blob(plain);
let ent = blob(entropy);
let mut out = CRYPT_INTEGER_BLOB::default();
unsafe {
CryptProtectData(
&din,
PCWSTR::null(),
Some(&ent),
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
.map_err(|e| explain_dpapi_failure("CryptProtectData", &e))?;
Ok(take_blob(&mut out))
}
}
fn unprotect(cipher: &[u8], entropy: &[u8]) -> Result<Zeroizing<Vec<u8>>, String> {
let din = blob(cipher);
let ent = blob(entropy);
let mut out = CRYPT_INTEGER_BLOB::default();
unsafe {
CryptUnprotectData(
&din,
None,
Some(&ent),
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
.map_err(|e| explain_dpapi_failure("CryptUnprotectData", &e))?;
Ok(Zeroizing::new(take_blob(&mut out)))
}
}
const IID_USER_CONSENT_VERIFIER_INTEROP: GUID =
GUID::from_u128(0x39E050C3_4E74_441A_8DC0_B81104DF949C);
#[repr(C)]
struct InteropVtbl {
query_interface:
unsafe extern "system" fn(*mut core::ffi::c_void, *const GUID, *mut *mut core::ffi::c_void) -> HRESULT,
add_ref: unsafe extern "system" fn(*mut core::ffi::c_void) -> u32,
release: unsafe extern "system" fn(*mut core::ffi::c_void) -> u32,
get_iids:
unsafe extern "system" fn(*mut core::ffi::c_void, *mut u32, *mut *mut GUID) -> HRESULT,
get_runtime_class_name:
unsafe extern "system" fn(*mut core::ffi::c_void, *mut *mut core::ffi::c_void) -> HRESULT,
get_trust_level: unsafe extern "system" fn(*mut core::ffi::c_void, *mut i32) -> HRESULT,
request_verification_for_window_async: unsafe extern "system" fn(
*mut core::ffi::c_void,
HWND,
*mut core::ffi::c_void,
*const GUID,
*mut *mut core::ffi::c_void,
) -> HRESULT,
}
pub fn presence_availability() -> Result<(), String> {
let a = UserConsentVerifier::CheckAvailabilityAsync()
.and_then(|op| op.join())
.map_err(|e| format!("could not query Windows Hello availability: {e}"))?;
match a {
UserConsentVerifierAvailability::Available => Ok(()),
UserConsentVerifierAvailability::DeviceNotPresent => {
Err("no Windows Hello device is present (no PIN, fingerprint or face enrolled)".into())
}
UserConsentVerifierAvailability::NotConfiguredForUser => {
Err("Windows Hello is not configured for this user — enrol a PIN or biometric in \
Settings > Accounts > Sign-in options"
.into())
}
UserConsentVerifierAvailability::DisabledByPolicy => {
Err("Windows Hello is disabled by policy on this machine".into())
}
UserConsentVerifierAvailability::DeviceBusy => {
Err("the Windows Hello device is busy — try again".into())
}
other => Err(format!("Windows Hello unavailable (availability code {})", other.0)),
}
}
fn require_presence(reason: &str) -> Result<(), String> {
presence_availability()?;
let msg = HSTRING::from(reason);
let hwnd = unsafe { GetConsoleWindow() };
let result = if hwnd.is_invalid() {
UserConsentVerifier::RequestVerificationAsync(&msg)
.and_then(|op| op.join())
.map_err(|e| format!("Windows Hello prompt failed: {e}"))?
} else {
request_verification_for_window(hwnd, &msg)?
};
match result {
UserConsentVerificationResult::Verified => Ok(()),
UserConsentVerificationResult::Canceled => {
Err("Windows Hello: cancelled — access denied".into())
}
UserConsentVerificationResult::RetriesExhausted => {
Err("Windows Hello: too many failed attempts — access denied".into())
}
UserConsentVerificationResult::DeviceNotPresent => {
Err("Windows Hello: no verification device present".into())
}
UserConsentVerificationResult::NotConfiguredForUser => {
Err("Windows Hello: not configured for this user".into())
}
UserConsentVerificationResult::DisabledByPolicy => {
Err("Windows Hello: disabled by policy".into())
}
UserConsentVerificationResult::DeviceBusy => Err("Windows Hello: device busy".into()),
other => Err(format!("Windows Hello: unverified (result code {})", other.0)),
}
}
fn request_verification_for_window(
hwnd: HWND,
msg: &HSTRING,
) -> Result<UserConsentVerificationResult, String> {
let factory: IInspectable = windows::core::factory::<UserConsentVerifier, IInspectable>()
.map_err(|e| format!("Windows Hello factory unavailable: {e}"))?;
let mut interop: *mut core::ffi::c_void = std::ptr::null_mut();
unsafe {
let hr = (Interface::vtable(&factory).base.QueryInterface)(
factory.as_raw(),
&IID_USER_CONSENT_VERIFIER_INTEROP,
&mut interop,
);
if hr.is_err() || interop.is_null() {
return Err(format!("IUserConsentVerifierInterop unavailable: {hr:?}"));
}
}
let mut raw: *mut core::ffi::c_void = std::ptr::null_mut();
unsafe {
let vtbl = *(interop as *mut *mut InteropVtbl);
let hr = ((*vtbl).request_verification_for_window_async)(
interop,
hwnd,
std::mem::transmute_copy(msg),
&IAsyncOperation::<UserConsentVerificationResult>::IID,
&mut raw,
);
if hr.is_err() || raw.is_null() {
((*vtbl).release)(interop);
return Err(format!("Windows Hello prompt could not be raised: {hr:?}"));
}
let op: IAsyncOperation<UserConsentVerificationResult> = std::mem::transmute(raw);
let out = op.join().map_err(|e| format!("Windows Hello prompt failed: {e}"));
((*vtbl).release)(interop);
out
}
}
fn ensure_dir() -> Result<(), String> {
let d = keyring_dir();
std::fs::create_dir_all(&d).map_err(|e| format!("creating {}: {e}", d.display()))?;
crate::winacl::restrict_to_owner_warn(&d);
Ok(())
}
fn write_account(account: &str, secret: &str, presence: bool) -> Result<(), String> {
ensure_dir()?;
let flags = if presence { FLAG_PRESENCE } else { 0 };
let cipher = protect(secret.as_bytes(), &entropy(account, flags))?;
let mut body = Vec::with_capacity(MAGIC.len() + 1 + cipher.len());
body.extend_from_slice(MAGIC);
body.push(flags);
body.extend_from_slice(&cipher);
let path = account_file(account);
let tmp = path.with_extension("dpapi.tmp");
std::fs::write(&tmp, &body).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("replacing {}: {e}", path.display()))?;
crate::winacl::restrict_to_owner_warn(&path);
Ok(())
}
fn load_envelope(account: &str) -> Result<Option<(u8, Vec<u8>)>, String> {
let path = account_file(account);
let raw = match std::fs::read(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("reading {}: {e}", path.display())),
};
if raw.len() < MAGIC.len() + 1 || &raw[..MAGIC.len()] != MAGIC {
return Err(format!("{} is not a secrets keyring file", path.display()));
}
Ok(Some((raw[MAGIC.len()], raw[MAGIC.len() + 1..].to_vec())))
}
fn read_account(
account: &str,
allow_presence_gated: bool,
reason: Option<&str>,
consent_already_given: bool,
) -> Result<Option<String>, String> {
let Some((flags, cipher)) = load_envelope(account)? else {
return Ok(None);
};
let gated = flags & FLAG_PRESENCE != 0;
if gated {
if !allow_presence_gated {
return Err(format!(
"'{account}' is presence-protected and cannot be read through a tap-free path"
));
}
if !consent_already_given {
require_presence(reason.unwrap_or("Release a secret from your vault"))?;
}
}
let plain = unprotect(&cipher, &entropy(account, flags))?;
String::from_utf8(plain.to_vec())
.map(Some)
.map_err(|_| format!("'{account}' does not hold valid UTF-8"))
}
fn delete_account(account: &str) -> Result<(), String> {
match std::fs::remove_file(account_file(account)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("deleting {account}: {e}")),
}
}
pub fn store(passphrase: &str, strict: bool) -> Result<(), String> {
let _ = strict;
presence_availability().map_err(|e| {
format!("{e}\nWithout Windows Hello the master key would sit behind DPAPI alone, which \
any process running as you could open. Refusing to store it ungated — use \
`secrets unlock --no-presence` if you accept that trade.")
})?;
write_account(ACCOUNT_MASTER, passphrase, true)
}
pub fn store_no_presence(passphrase: &str) -> Result<(), String> {
write_account(ACCOUNT_MASTER, passphrase, false)
}
pub fn read(prompt: &str, strict: bool) -> Result<Option<String>, String> {
let _ = strict;
read_account(ACCOUNT_MASTER, true, Some(prompt), false)
}
pub fn delete() -> Result<(), String> {
delete_account(ACCOUNT_MASTER)
}
pub fn store_inbox_identity(secret: &str) -> Result<(), String> {
write_account(ACCOUNT_INBOX, secret, true)
}
pub fn inbox_identity_exists() -> bool {
account_file(ACCOUNT_INBOX).exists()
}
pub fn read_accounts(accounts: &[&str], strict: bool) -> Result<Vec<Option<String>>, String> {
let _ = strict;
let mut need_consent = false;
for account in accounts {
if let Some((flags, _)) = load_envelope(account)? {
if flags & FLAG_PRESENCE != 0 {
need_consent = true;
break;
}
}
}
if need_consent {
require_presence(&format!(
"Open {} from your secrets vault",
accounts.join(" + ")
))?;
}
let mut out = Vec::with_capacity(accounts.len());
for account in accounts {
out.push(read_account(account, true, None, true)?);
}
Ok(out)
}
pub fn store_plain(account: &str, secret: &str) -> Result<(), String> {
write_account(account, secret, false)
}
pub fn read_plain(account: &str) -> Result<Option<String>, String> {
read_account(account, false, None, false)
}
pub fn delete_plain(account: &str) -> Result<(), String> {
delete_account(account)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_name_escapes_the_ads_colon() {
assert_eq!(safe_name("lease:myapp"), "lease%3Amyapp");
assert_eq!(safe_name("vault-master"), "vault-master");
assert_eq!(safe_name("inbox-identity"), "inbox-identity");
assert_ne!(safe_name("a%3Ab"), safe_name("a:b"));
assert!(!account_file("lease:myapp").to_string_lossy().contains("lease:myapp"));
}
#[test]
fn entropy_separates_accounts_and_flags() {
assert_ne!(entropy("vault-master", 1), entropy("inbox-identity", 1));
assert_ne!(entropy("vault-master", 1), entropy("vault-master", 0));
}
#[test]
fn dpapi_round_trip() {
let ent = entropy("test-account", 0);
match protect(b"round-trip-value", &ent) {
Ok(cipher) => {
assert_ne!(&cipher[..], b"round-trip-value", "ciphertext must not be plaintext");
let plain = unprotect(&cipher, &ent).expect("unprotect own ciphertext");
assert_eq!(&plain[..], b"round-trip-value");
assert!(unprotect(&cipher, &entropy("other-account", 0)).is_err());
}
Err(e) => eprintln!("skipping: DPAPI unavailable in this logon session ({e})"),
}
}
}