From 504f0196120fd1281a97e3f0d9cb6f8469a09c94 Mon Sep 17 00:00:00 2001 From: Bee <302257494+thtbee@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:18:43 +0530 Subject: [PATCH] Unlock with phone: design, draft protocol, and the core's vault ADR 0009 (proposed) designs unlocking the locked PC with the phone's fingerprint: a credential provider that returns the user's password, which the PC keeps only sealed under a key the phone holds wrapped by a non-exportable, biometric-bound Keystore key. Neither the PC's disk nor the phone alone can recover it. It covers the options Windows allows, the threat model, the guards (LAN only, single-use requests, rate limit, unlock only, off by default) and open questions for the owner. docs/protocol/unlock.md drafts the messages. core::unlock has the parts that can be tested on their own: sealing and opening the password (AES-256-GCM via ring, bound to the PC, phone and Windows user), secrets wiped on drop and never printed, and the request rules. Nothing is wired to the network, and no credential provider exists yet: that needs a VM and an external review. --- core/nectarlink-core/src/lib.rs | 1 + core/nectarlink-core/src/unlock.rs | 379 +++++++++++++++++++++++++++++ docs/adr/0009-unlock-with-phone.md | 127 ++++++++++ docs/adr/README.md | 1 + docs/protocol/unlock.md | 70 ++++++ 5 files changed, 578 insertions(+) create mode 100644 core/nectarlink-core/src/unlock.rs create mode 100644 docs/adr/0009-unlock-with-phone.md create mode 100644 docs/protocol/unlock.md diff --git a/core/nectarlink-core/src/lib.rs b/core/nectarlink-core/src/lib.rs index 0002ecbb..f0c0778b 100644 --- a/core/nectarlink-core/src/lib.rs +++ b/core/nectarlink-core/src/lib.rs @@ -39,6 +39,7 @@ mod store; pub mod timeline; pub mod toggles; mod transfer; +pub mod unlock; pub mod usb; pub mod webcam; diff --git a/core/nectarlink-core/src/unlock.rs b/core/nectarlink-core/src/unlock.rs new file mode 100644 index 00000000..13baa0b4 --- /dev/null +++ b/core/nectarlink-core/src/unlock.rs @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Unlock the PC with the phone, the PC's half (docs/adr/0009-unlock-with-phone.md; +//! proposed, not wired up). The user's Windows password is kept only sealed +//! under a key `K` the PC doesn't keep: the phone holds `K`, wrapped by a +//! Keystore key that needs the owner's fingerprint for every use, and returns +//! it for one unlock at a time. +//! +//! This module is the part that can be tested on its own: sealing and opening +//! the password (AES-256-GCM, bound to the PC, the phone and the Windows user), +//! and the rules for unlock requests: single use, a short expiry, only the +//! enrolled phone may answer, and a lockout after repeated failures. + +use std::{ + collections::{HashMap, VecDeque}, + time::{Duration, Instant}, +}; + +use nectarlink_protocol::DeviceId; +use ring::{ + aead::{AES_256_GCM, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey}, + rand::{SecureRandom, SystemRandom}, +}; +use serde::{Deserialize, Serialize}; + +/// Bytes in `K`. +pub const KEY_LEN: usize = 32; +/// How long the phone has to answer a request. +pub const REQUEST_TTL: Duration = Duration::from_secs(30); +/// Failed unlocks allowed within [`FAILURE_WINDOW`] before a lockout. +pub const MAX_FAILURES: usize = 5; +pub const FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60); +pub const LOCKOUT: Duration = Duration::from_secs(15 * 60); +/// Separates this use of the key from any other. +const DOMAIN: &[u8] = b"nectarlink-unlock-v1\0"; + +/// Secret bytes (`K`, or the password): overwritten when dropped, never printed. +pub struct Secret(Vec); + +impl Secret { + pub fn new(bytes: Vec) -> Secret { + Secret(bytes) + } + + pub fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl Drop for Secret { + fn drop(&mut self) { + self.0.fill(0); + // Best effort without unsafe code: black_box keeps the wipe from being + // optimized away as a store nobody reads. + std::hint::black_box(&self.0); + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Secret({} bytes)", self.0.len()) + } +} + +/// What a sealed password belongs to: opening it for any other PC, phone or +/// user fails, even with the right key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding { + pub pc: DeviceId, + pub phone: DeviceId, + /// The Windows user's security identifier (`S-1-5-21-…`). + pub user: String, +} + +impl Binding { + fn aad(&self) -> Vec { + let user = self.user.as_bytes(); + let mut aad = Vec::with_capacity(DOMAIN.len() + 64 + 4 + user.len()); + aad.extend_from_slice(DOMAIN); + aad.extend_from_slice(&self.pc.0); + aad.extend_from_slice(&self.phone.0); + aad.extend_from_slice(&(user.len() as u32).to_be_bytes()); + aad.extend_from_slice(user); + aad + } +} + +/// The password, sealed under `K`: what the PC keeps on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sealed { + pub nonce: [u8; NONCE_LEN], + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum VaultError { + #[error("no secure random numbers")] + Random, + #[error("the key isn't 32 bytes")] + KeyLength, + /// The key is wrong, or the sealed password was changed, or it belongs + /// to another PC, phone or user. + #[error("can't open the sealed password")] + Open, +} + +/// A new random `K`, for enrolling. +pub fn new_key() -> Result { + let mut key = vec![0u8; KEY_LEN]; + SystemRandom::new().fill(&mut key).map_err(|_| VaultError::Random)?; + Ok(Secret(key)) +} + +fn aead_key(key: &Secret) -> Result { + if key.expose().len() != KEY_LEN { + return Err(VaultError::KeyLength); + } + let unbound = UnboundKey::new(&AES_256_GCM, key.expose()).map_err(|_| VaultError::KeyLength)?; + Ok(LessSafeKey::new(unbound)) +} + +/// Seals `password` under `key` for `binding`, with a fresh random nonce. +pub fn seal(password: &Secret, key: &Secret, binding: &Binding) -> Result { + let aead = aead_key(key)?; + let mut nonce = [0u8; NONCE_LEN]; + SystemRandom::new().fill(&mut nonce).map_err(|_| VaultError::Random)?; + let mut buffer = password.expose().to_vec(); + let sealed = aead.seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(binding.aad()), + &mut buffer, + ); + if sealed.is_err() { + // Don't leave the plaintext copy behind. + drop(Secret(buffer)); + return Err(VaultError::Open); + } + Ok(Sealed { nonce, ciphertext: buffer }) +} + +/// Opens `sealed` with `key` for `binding`. +pub fn open(sealed: &Sealed, key: &Secret, binding: &Binding) -> Result { + let aead = aead_key(key)?; + let mut buffer = sealed.ciphertext.clone(); + let opened = + aead.open_in_place(Nonce::assume_unique_for_key(sealed.nonce), Aad::from(binding.aad()), &mut buffer); + match opened { + Ok(plain) => { + let len = plain.len(); + buffer.truncate(len); + Ok(Secret(buffer)) + } + Err(_) => { + drop(Secret(buffer)); + Err(VaultError::Open) + } + } +} + +// ---- Requests ---- + +/// One unlock request's ID: random, so it can't be guessed. +pub type RequestId = [u8; 16]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub phone: DeviceId, + pub user: String, + pub expires: Instant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refused { + /// Too many failures; unlocking with the phone works again at `until`. + LockedOut { + until: Instant, + }, + /// Not a request this PC made, or one already answered. + Unknown, + Expired, + /// An answer from a device other than the one asked. + WrongDevice, + Random, +} + +/// The PC's unlock requests and its record of failures. +#[derive(Debug, Default)] +pub struct Requests { + pending: HashMap, + failures: VecDeque, + locked_until: Option, +} + +impl Requests { + pub fn new() -> Requests { + Requests::default() + } + + /// Whether unlocking with the phone is locked out at `now`, and until when. + pub fn locked_out(&self, now: Instant) -> Option { + self.locked_until.filter(|until| now < *until) + } + + /// Starts a request to `phone` for `user`, unless locked out. + pub fn begin(&mut self, phone: DeviceId, user: &str, now: Instant) -> Result { + if let Some(until) = self.locked_out(now) { + return Err(Refused::LockedOut { until }); + } + self.pending.retain(|_, r| now < r.expires); + let mut id = [0u8; 16]; + SystemRandom::new().fill(&mut id).map_err(|_| Refused::Random)?; + self.pending.insert(id, Request { phone, user: user.to_owned(), expires: now + REQUEST_TTL }); + Ok(id) + } + + /// Takes the answer to `id`, from `from`: each request can be answered + /// once, before it expires, and only by the phone it was sent to. What it + /// returns is then opened with the vault; report how that went with + /// [`succeeded`](Self::succeeded) or [`failed`](Self::failed). + pub fn answer(&mut self, id: &RequestId, from: DeviceId, now: Instant) -> Result { + if let Some(until) = self.locked_out(now) { + self.pending.remove(id); + return Err(Refused::LockedOut { until }); + } + let request = self.pending.remove(id).ok_or(Refused::Unknown)?; + if now >= request.expires { + return Err(Refused::Expired); + } + if request.phone != from { + self.failed(now); + return Err(Refused::WrongDevice); + } + Ok(request) + } + + /// An unlock failed (the key didn't open the password, or a wrong device + /// answered). Too many within the window lock it out for a while. + pub fn failed(&mut self, now: Instant) { + while self.failures.front().is_some_and(|at| now.duration_since(*at) >= FAILURE_WINDOW) { + self.failures.pop_front(); + } + self.failures.push_back(now); + if self.failures.len() >= MAX_FAILURES { + self.locked_until = Some(now + LOCKOUT); + self.failures.clear(); + self.pending.clear(); + } + } + + /// An unlock worked: earlier failures no longer count. + pub fn succeeded(&mut self) { + self.failures.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn binding() -> Binding { + Binding { pc: DeviceId([1; 32]), phone: DeviceId([2; 32]), user: "S-1-5-21-1-2-3-1001".into() } + } + + fn password() -> Secret { + Secret::new(b"correct horse battery staple".to_vec()) + } + + #[test] + fn a_sealed_password_opens_only_with_its_key_and_binding() { + let key = new_key().unwrap(); + let sealed = seal(&password(), &key, &binding()).unwrap(); + assert_ne!(sealed.ciphertext.as_slice(), password().expose(), "not stored in the clear"); + assert_eq!(open(&sealed, &key, &binding()).unwrap().expose(), password().expose()); + + let other_key = new_key().unwrap(); + assert_eq!(open(&sealed, &other_key, &binding()).unwrap_err(), VaultError::Open); + for other in [ + Binding { pc: DeviceId([9; 32]), ..binding() }, + Binding { phone: DeviceId([9; 32]), ..binding() }, + Binding { user: "S-1-5-21-1-2-3-1002".into(), ..binding() }, + ] { + assert_eq!(open(&sealed, &key, &other).unwrap_err(), VaultError::Open, "{other:?}"); + } + } + + #[test] + fn a_changed_sealed_password_doesnt_open() { + let key = new_key().unwrap(); + let mut sealed = seal(&password(), &key, &binding()).unwrap(); + sealed.ciphertext[0] ^= 1; + assert_eq!(open(&sealed, &key, &binding()).unwrap_err(), VaultError::Open); + let mut sealed = seal(&password(), &key, &binding()).unwrap(); + sealed.nonce[0] ^= 1; + assert_eq!(open(&sealed, &key, &binding()).unwrap_err(), VaultError::Open); + } + + #[test] + fn each_seal_uses_a_new_nonce_and_keys_must_be_32_bytes() { + let key = new_key().unwrap(); + let a = seal(&password(), &key, &binding()).unwrap(); + let b = seal(&password(), &key, &binding()).unwrap(); + assert_ne!(a.nonce, b.nonce); + assert_ne!(a.ciphertext, b.ciphertext); + assert_eq!( + seal(&password(), &Secret::new(vec![0; 16]), &binding()).unwrap_err(), + VaultError::KeyLength + ); + } + + #[test] + fn secrets_are_never_printed() { + assert_eq!(format!("{:?}", password()), "Secret(28 bytes)"); + } + + #[test] + fn a_request_is_answered_once_in_time_by_its_phone() { + let now = Instant::now(); + let phone = DeviceId([2; 32]); + let mut requests = Requests::new(); + let id = requests.begin(phone, "S-1", now).unwrap(); + let request = requests.answer(&id, phone, now + Duration::from_secs(5)).unwrap(); + assert_eq!((request.phone, request.user.as_str()), (phone, "S-1")); + assert_eq!(requests.answer(&id, phone, now).unwrap_err(), Refused::Unknown, "only once"); + + let late = requests.begin(phone, "S-1", now).unwrap(); + assert_eq!(requests.answer(&late, phone, now + REQUEST_TTL).unwrap_err(), Refused::Expired); + + let stolen = requests.begin(phone, "S-1", now).unwrap(); + assert_eq!(requests.answer(&stolen, DeviceId([7; 32]), now).unwrap_err(), Refused::WrongDevice); + assert_eq!( + requests.answer(&stolen, phone, now).unwrap_err(), + Refused::Unknown, + "used up by the wrong device" + ); + + assert_eq!(requests.answer(&[0; 16], phone, now).unwrap_err(), Refused::Unknown); + } + + #[test] + fn repeated_failures_lock_it_out_for_a_while() { + let start = Instant::now(); + let phone = DeviceId([2; 32]); + let mut requests = Requests::new(); + for i in 0..MAX_FAILURES - 1 { + requests.failed(start + Duration::from_secs(i as u64)); + } + assert!(requests.begin(phone, "S-1", start).is_ok(), "not yet"); + let pending = requests.begin(phone, "S-1", start).unwrap(); + requests.failed(start + Duration::from_secs(10)); + let at = start + Duration::from_secs(11); + assert!(matches!(requests.begin(phone, "S-1", at), Err(Refused::LockedOut { .. }))); + assert!( + matches!(requests.answer(&pending, phone, at), Err(Refused::LockedOut { .. })), + "pending requests are dropped too" + ); + let after = start + Duration::from_secs(10) + LOCKOUT; + assert!(requests.begin(phone, "S-1", after).is_ok(), "works again after the lockout"); + } + + #[test] + fn old_failures_and_a_success_dont_count() { + let start = Instant::now(); + let phone = DeviceId([2; 32]); + let mut requests = Requests::new(); + for i in 0..MAX_FAILURES - 1 { + requests.failed(start + Duration::from_secs(i as u64)); + } + // Long after: the earlier ones fell out of the window. + requests.failed(start + FAILURE_WINDOW + Duration::from_secs(60)); + assert!(requests.begin(phone, "S-1", start + FAILURE_WINDOW + Duration::from_secs(61)).is_ok()); + + let mut requests = Requests::new(); + for i in 0..MAX_FAILURES - 1 { + requests.failed(start + Duration::from_secs(i as u64)); + } + requests.succeeded(); + requests.failed(start + Duration::from_secs(10)); + assert!(requests.begin(phone, "S-1", start + Duration::from_secs(11)).is_ok()); + } +} diff --git a/docs/adr/0009-unlock-with-phone.md b/docs/adr/0009-unlock-with-phone.md new file mode 100644 index 00000000..60205b44 --- /dev/null +++ b/docs/adr/0009-unlock-with-phone.md @@ -0,0 +1,127 @@ +# ADR 0009: Unlock the PC with the phone + +- **Status:** Proposed (needs the owner's decision and an external security review before any of it ships) +- **Date:** 2026-10-19 + +## Context + +People want to unlock their locked PC by touching the fingerprint sensor on +their phone, the way Phone Link's "Dynamic Lock" locks it. `docs/PLAN.md` +§3.10 lists it as a dedicated, post-1.0 workstream because it puts code in the +most sensitive place on Windows: the sign-in screen. + +Two questions decide the design: + +1. **What does Windows accept at the lock screen?** Only a credential: a + password, a smart-card certificate, or Windows Hello. A third-party app + can't simply tell Windows "unlock". +2. **Where does that credential live, and who can get it?** Whatever we keep + must be useless to someone who steals the PC's disk, malware running as the + user, and anyone without the phone and its owner's fingerprint. + +### Ways Windows allows it + +| Approach | How | Fit | +|---|---|---| +| Custom credential provider + stored password | An `ICredentialProvider` DLL in LogonUI returns `KERB_INTERACTIVE_UNLOCK_LOGON` with the user's password | Works for local and Microsoft accounts that have a password. Most third-party "unlock with phone" tools do this. We must store the password, so how it's stored decides the security. | +| Windows Hello companion device framework (`SecondaryAuthenticationFactor`) | Microsoft's own API for companion devices | Microsoft lists it as deprecated; to verify on current Windows 11 before ruling it out entirely. Would avoid storing a password. | +| Certificate / virtual smart card logon | A credential provider presenting a certificate | Needs a domain and a certificate authority; not for home PCs. | + +## Decision (proposed) + +A credential provider that returns the user's password, where the password +can only be recovered with the phone and the user's fingerprint, and only +to unlock a session that's already signed in (not the first sign-in after a +restart). + +### Split key + +- **Enrollment** (the user turns it on, signed in, in Settings, and types + their Windows password once): + 1. The PC makes a random 256-bit key `K` and encrypts the password with + AES-256-GCM under `K`. Only that ciphertext is kept on the PC, bound + (as associated data) to this PC, this phone and this Windows user. + 2. The PC sends `K` to the phone over the paired session. The phone wraps it + with an AES-GCM key held in Android Keystore (StrongBox when there is + one) that requires strong biometrics for every use and is invalidated + when a new fingerprint is enrolled. The phone returns the wrapped `K` + and keeps nothing else. + 3. The PC stores the wrapped `K` beside the ciphertext and forgets `K`. +- **Unlock**: the lock screen offers "Unlock with ". The PC sends a + request (single use, 30-second expiry, naming the PC and the user) with the + wrapped `K`. The phone shows a biometric prompt naming the PC and the user; + on success Keystore unwraps `K` and the phone returns it. The PC decrypts + the password (the GCM tag proves `K` is right), hands it to LogonUI, and + wipes `K` and the password from memory. + +What each side holds: + +| Holder | Has | Alone, it gets | +|---|---|---| +| PC disk | password ciphertext, wrapped `K` | nothing: `K` never touches the PC's disk, and the wrapping key never leaves the phone | +| Phone | the non-exportable wrapping key | nothing: it never sees the password | +| Network | an encrypted, mutually authenticated session (ADR 0003) | nothing | + +### Guards + +- **Only on the local network**, never over relays or mobile data, and only + with a live session to the enrolled phone. Optionally, BLE proximity (the + beacon already in `ble_beacon`). +- **Rate limit:** 5 failed attempts in 15 minutes locks the feature for 15 + minutes and notifies the user on both devices. +- **Revocable:** unpairing, turning the feature off, changing the Windows + password (which makes the ciphertext useless; the PC asks to re-enroll), + or a new fingerprint on the phone (the Keystore key is invalidated) all end + it. +- **Unlock only:** the credential provider answers `CPUS_UNLOCK_WORKSTATION`, + not `CPUS_LOGON`. Unlocking needs NectarLink running in the locked session, + which also means a restart always asks for the real password once. +- **Off by default**, behind a clear explanation, and absent from builds until + the review is done. + +### Where the pieces run + +- **Core (`nectarlink-core`, Rust):** the protocol, the vault (encrypting, + decrypting, binding) and the request rules above. Pure code, fully tested. + The first part of this is in `core/nectarlink-core/src/unlock.rs`. +- **Phone (Kotlin):** the Keystore key, the biometric prompt with a + `CryptoObject`, and the protocol handler. +- **Credential provider (`desktop/credprov`, C++):** a small COM DLL in + LogonUI. It knows nothing about phones: it asks the user's NectarLink, over + a named pipe whose ACL admits only that user's NectarLink, for "the + credential for user X", and serializes the answer. Kept small so it's easy + to review. +- **Installation:** registering a credential provider is a machine-wide + change (HKLM, administrator). The installer would do it only when the user + turns the feature on, and remove it when it's off. + +## Threat model + +| Threat | Mitigation | +|---|---| +| Stolen or copied PC disk | Ciphertext without `K`; `K` is only on the phone, wrapped by a non-exportable key | +| Stolen phone | Needs the owner's biometric; a new fingerprint invalidates the key | +| Someone at the locked PC with a stolen, unlocked phone | Still needs the biometric for each unlock | +| Replay of an old response | Single-use request IDs, a 30-second expiry, and the session's own encryption | +| A different paired device answering | Responses only from the enrolled phone's identity, bound into the ciphertext's associated data | +| Malware running as the user on the PC | Out of scope for any unlock feature (it already has the session), but it must not learn the password: `K` and the password exist only in memory, briefly, inside NectarLink; the pipe to the credential provider admits only NectarLink. Residual risk: malware that reads NectarLink's memory at that moment. To be reviewed. | +| Bugs in the credential provider locking the user out | It only adds a tile; the normal password tile stays. Tested in a VM with recovery snapshots before any real machine. | +| Brute force via the lock screen | Rate limit and lockout above | + +## Open questions for the owner + +1. Is storing the password (split as above) acceptable, or should we first + confirm whether Windows Hello's companion device API still works on + Windows 11 24H2 and later? +2. PIN-only and passwordless Microsoft accounts have no usable password; we + would tell those users it isn't available. Acceptable? +3. Who does the external review, and does the credential provider need code + signing first (SignPath)? + +## Consequences + +- A security-critical component that needs its own tests, a VM test plan, and + an external review before release. +- More install surface: a DLL registered machine-wide when enabled. +- The core and protocol parts can be built and tested now, independently of + the credential provider. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6e5b3265..ed3aac73 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,3 +10,4 @@ | 0006 | [Kotlin + Jetpack Compose for the Android app](0006-kotlin-compose-android.md) | Accepted | | 0007 | [Licensing](0007-licensing.md) | Accepted | | 0008 | [Platforms and distribution](0008-platforms-and-distribution.md) | Accepted | +| 0009 | [Unlock the PC with the phone](0009-unlock-with-phone.md) | Proposed | diff --git a/docs/protocol/unlock.md b/docs/protocol/unlock.md new file mode 100644 index 00000000..e873bd30 --- /dev/null +++ b/docs/protocol/unlock.md @@ -0,0 +1,70 @@ +# Unlock the PC with the phone (draft) + +> Status: **proposal**, not implemented on the wire. See +> [ADR 0009](../adr/0009-unlock-with-phone.md) for the design, the threat model +> and the open questions. Builds on [protocol v0](v0.md). License: CC BY 4.0. + +A phone lets a paired PC unlock its locked Windows session after the phone's +owner confirms with a strong biometric. The PC keeps the user's Windows +password only sealed under a key `K`; the phone keeps `K` only wrapped by a +non-exportable Keystore key that needs a biometric for each use. + +## 1. Capabilities and toggle + +| Capability | Offered by | Meaning | +|---|---|---| +| `unlock.key` | Phone | Can hold a wrapped key for a PC and release it after a strong biometric | +| `unlock.pc` | PC | Can unlock its locked session with a phone | + +Both devices have an `unlock` per-device toggle, **off by default**. Every +message below is refused with `DENIED` when it's off on the receiving side, and +only accepted on a direct local path (LAN or USB), never over a relay. + +## 2. Messages + +On the control stream. + +``` +t = "unlock.enroll" id = n b = { pc_name: text, user_name: text, user: text, key: bytes(32) } +t = "unlock.wrapped" re = n b = { wrapped: bytes } +t = "unlock.request" id = n b = { request: bytes(16), user_name: text, wrapped: bytes } +t = "unlock.release" re = n b = { request: bytes(16), key: bytes(32) } +t = "unlock.forget" b = { user?: text } +``` + +### 2.1 `unlock.enroll` (PC to phone) + +Sent once the user has turned it on in the PC's Settings and typed their +Windows password there. `user` is the Windows security identifier, `user_name` +what the phone shows. The phone asks its owner ("Let unlock with +your fingerprint for ?"); after a strong biometric it wraps `key` +with its Keystore key and answers `unlock.wrapped`. Errors: `DENIED` when +declined or off, `UNSUPPORTED` without a strong biometric. + +The PC seals the password under `key` (AES-256-GCM, associated data binding the +PC, the phone and `user`), keeps the sealed password and `wrapped`, and +forgets `key`. + +### 2.2 `unlock.request` (PC to phone) + +Sent when the user picks "Unlock with " on the lock screen. `request` is +random and single use; the phone must answer within 30 seconds. The phone +shows its biometric prompt ("Unlock for ?"), unwraps +`wrapped`, and answers `unlock.release` with the same `request` and `key`. +Errors: `DENIED` (declined or off), `NOT_FOUND` (the Keystore key is gone, +for example after a new fingerprint: the PC asks the user to enroll again). + +The PC opens the sealed password with `key`. A key that doesn't open it counts +as a failure; five failures in 15 minutes lock the feature for 15 minutes. + +### 2.3 `unlock.forget` (either way) + +Ends it: the phone deletes its Keystore key for this PC (or for `user`), the PC +deletes the sealed password and the wrapped key. Sent when either side turns +the toggle off or unpairs. + +## 3. Privacy and logging + +`key`, `wrapped` and the password are never logged, shown or written anywhere +but where section 2 says. The phone's prompt always names the PC and the user, +so a request can't be passed off as something else.