Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/quick-unlock-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Source boundaries:
| App start, crash or relaunch | Require native Master Password verification before any Quick Unlock, even when an encrypted registration survives. The saved binding never authorizes the process by itself. |
| Seven-day deadline | Rust records a process-local deadline from the last successful native Master Password verification of the current Vault. The continuous clock counts sleep. Expiry, a missing clock, backward time or overflow revokes authorization. Core ticks observe expiry while Locked too. Touch ID never extends the deadline. |
| Ordinary lock, sleep, screen lock or user switch | Keep the encrypted registration, drop the open Vault/decrypted Notes, and invalidate pending native authentication. A later unlock needs another protected private-key operation within the process deadline. |
| Biometric request | Use a fresh LAContext with reuse disabled. Run protected work outside the core mutex, then recheck generation, phase, operation deadline and the exact current file before committing the ordinary session. Late callbacks are discarded. |
| Biometric request | Use a fresh LAContext with reuse disabled, paired with Apple's embedded `LAAuthenticationView` in a native panel inside the protected main window ([#95](https://github.com/nniiovoo/encrypted-note/issues/95)). The system alert would take focus and be cancelled by the app-switch rule, which stays as it is: no focus exemption or process allowlist. Request biometrics only in that view, then perform the real Secure Enclave operation with the same context and further UI refused; the view's success never authorizes anything by itself. A read with no presented view does not start. Closing the panel (Cancel, Escape, focus loss, Lock, the Sharing Guard, expiry) cancels the request and invalidates its context. Run protected work outside the core mutex, then recheck generation, phase, operation deadline and the exact current file before committing the ordinary session. Late callbacks are discarded. |
| Cancel, failed match, lockout, unavailable hardware or malformed file | Stay Locked, drop returned wiping buffers, give a sanitized fallback message and offer native Master Password entry. Never fall back to the Mac login password or clear password-attempt backoff. Invalidated or mismatching registrations require explicit re-enrollment. |
| Changed enrolled fingers | The current-set constraint makes the protected key unavailable. Require native Master Password verification and explicit enrollment; do not recreate a weaker key automatically. |
| Master Password change, recovery reset, Backup/Safety Copy installation | Disable registration and process authorization before the transition, including an installation with the same Vault ID or identical header. Inspecting a copy does not authorize Quick Unlock. |
Expand Down
10 changes: 9 additions & 1 deletion src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ fn build_biometry() {
assert!(status.success(), "archive local biometric bridge");
println!("cargo:rustc-link-search=native={}", out.display());
println!("cargo:rustc-link-lib=static=encrypted_note_biometry");
for framework in ["CryptoKit", "Security", "LocalAuthentication", "Foundation"] {
// LocalAuthenticationEmbeddedUI keeps Touch ID inside the protected window (no system alert).
for framework in [
"CryptoKit",
"Security",
"LocalAuthentication",
"LocalAuthenticationEmbeddedUI",
"AppKit",
"Foundation",
] {
println!("cargo:rustc-link-lib=framework={framework}");
}
swift_rs::SwiftLinker::new("13.0").link();
Expand Down
95 changes: 89 additions & 6 deletions src-tauri/native/biometry.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AppKit
import CryptoKit
import Foundation
import LocalAuthentication
import LocalAuthenticationEmbeddedUI
import Security

// This file never creates a Keychain item. CryptoKit returns an encrypted, device-bound
Expand All @@ -10,15 +12,34 @@ private let maximumBlobLength = 32 * 1024
private let maximumEnclaveLength = 16 * 1024
private let magic = Data("ENOTE-SE-WRAP-V1".utf8)
private let domain = Data("com.encryptednote.app.quick-unlock.p256.v1".utf8)
private let reason = "unlock your encrypted-note Vault"
private let authenticationTimeout: DispatchTimeInterval = .seconds(120)

private enum BiometryFailure: Error { case cancelled, unavailable, malformed, failed }

private func freshContext() -> LAContext {
let context = LAContext()
context.localizedReason = reason
context.localizedFallbackTitle = ""
context.touchIDAuthenticationAllowableReuseDuration = 0
return context
}

// The one authentication context of a protected read, paired with its embedded view. It is
// claimed once; a read without a paired view never starts, so no system alert can appear.
private final class Paired {
let context: LAContext
var claimed = false
init(_ context: LAContext) { self.context = context }
}

// Cancellation can arrive before registration, during a protected operation, or between
// enrollment and its read-back. Rust releases the id only when the last request handle drops.
private final class Requests: @unchecked Sendable {
static let shared = Requests()
private let lock = NSLock()
private var contexts: [UInt64: LAContext] = [:]
private var paired: [UInt64: Paired] = [:]
private var cancelled: Set<UInt64> = []

func begin(_ id: UInt64) throws -> LAContext {
Expand All @@ -27,10 +48,7 @@ private final class Requests: @unchecked Sendable {
guard id != 0, !cancelled.contains(id), contexts[id] == nil else {
throw BiometryFailure.cancelled
}
let context = LAContext()
context.localizedReason = "unlock your encrypted-note Vault"
context.localizedFallbackTitle = ""
context.touchIDAuthenticationAllowableReuseDuration = 0
let context = freshContext()
contexts[id] = context
return context
}
Expand All @@ -42,20 +60,48 @@ private final class Requests: @unchecked Sendable {
if contexts[id] === context { contexts.removeValue(forKey: id) }
}

// Main thread only (the caller checks). One view per request, created with the exact
// context the later Secure Enclave operation uses.
func pair(_ id: UInt64) -> NSView? {
lock.lock()
defer { lock.unlock() }
guard id != 0, !cancelled.contains(id), paired[id] == nil else { return nil }
let context = freshContext()
let view = LAAuthenticationView(context: context, controlSize: .large)
paired[id] = Paired(context)
return view
}

func claim(_ id: UInt64) throws -> LAContext {
lock.lock()
defer { lock.unlock() }
// No paired view means no embedded presentation: refuse rather than fall back to the
// system alert, which takes focus away from the protected window.
guard !cancelled.contains(id), let entry = paired[id], !entry.claimed else {
throw BiometryFailure.cancelled
}
entry.claimed = true
return entry.context
}

func cancel(_ id: UInt64) {
lock.lock()
cancelled.insert(id)
let context = contexts[id]
let embedded = paired[id]?.context
lock.unlock()
context?.invalidate()
embedded?.invalidate()
}

func release(_ id: UInt64) {
lock.lock()
let context = contexts.removeValue(forKey: id)
let embedded = paired.removeValue(forKey: id)?.context
cancelled.remove(id)
lock.unlock()
context?.invalidate()
embedded?.invalidate()
}

func check(_ id: UInt64) throws {
Expand All @@ -71,6 +117,16 @@ func enoteBiometryCancel(_ id: UInt64) { Requests.shared.cancel(id) }
@_cdecl("enote_biometry_release")
func enoteBiometryRelease(_ id: UInt64) { Requests.shared.release(id) }

// Returns a +1 retained LAAuthenticationView for this request, or nil. Main thread only;
// Rust installs it in the protected window before the read starts and releases it after.
@_cdecl("enote_biometry_view")
func enoteBiometryView(_ id: UInt64) -> UnsafeMutableRawPointer? {
// A Thread check, not MainActor.assumeIsolated: that would link the Swift Concurrency
// runtime, which this static bridge does not ship or load.
guard Thread.isMainThread, let view = Requests.shared.pair(id) else { return nil }
return Unmanaged.passRetained(view).toOpaque()
}

@_cdecl("enote_biometry_available")
func enoteBiometryAvailable() -> Int32 {
guard SecureEnclave.isAvailable else { return 0 }
Expand All @@ -93,6 +149,24 @@ private func status(_ error: Error) -> Int32 {
return 4
}

private final class Outcome: @unchecked Sendable { var error: Error? = BiometryFailure.failed }

// Blocks the calling worker thread (never the main thread, which draws the paired view).
private func authenticate(_ context: LAContext, _ access: SecAccessControl) throws {
let done = DispatchSemaphore(value: 0)
let outcome = Outcome()
context.evaluateAccessControl(access, operation: .useKeyKeyExchange,
localizedReason: reason) { success, error in
outcome.error = success ? nil : (error ?? BiometryFailure.failed)
done.signal()
}
guard done.wait(timeout: .now() + authenticationTimeout) == .success else {
context.invalidate()
throw BiometryFailure.cancelled
}
if let error = outcome.error { throw error }
}

private func randomBytes(_ count: Int) throws -> Data {
var result = Data(count: count)
let outcome = result.withUnsafeMutableBytes {
Expand Down Expand Up @@ -200,8 +274,17 @@ func enoteBiometryOpen(_ id: UInt64, _ input: UnsafePointer<UInt8>?, _ length: I
capacity == payloadLength else { return 3 }
do {
let envelope = try Envelope(Data(bytes: input, count: length))
let context = try Requests.shared.begin(id)
defer { Requests.shared.end(id, context) }
let context = try Requests.shared.claim(id)
defer { context.invalidate() }
guard let access = SecAccessControlCreateWithFlags(nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], nil) else { throw BiometryFailure.failed }
// Biometrics only, presented by the paired view inside the protected window. Success
// here authorizes nothing by itself: only the enclave operation below releases data.
try authenticate(context, access)
try Requests.shared.check(id)
// Any further authentication UI would appear outside the protected window: refuse it.
context.interactionNotAllowed = true
let enclave = try SecureEnclave.P256.KeyAgreement.PrivateKey(
dataRepresentation: envelope.enclave, authenticationContext: context)
guard enclave.publicKey.x963Representation == envelope.enclavePublic else {
Expand Down
16 changes: 15 additions & 1 deletion src-tauri/src/biometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ impl Cancellation {
self.0.id == 0 || self.0.cancelled.load(Ordering::SeqCst)
}

/// The opaque request id that pairs the embedded Touch ID view with this operation.
pub fn request(&self) -> u64 {
self.0.id
}

#[cfg(any(target_os = "macos", test))]
fn check(&self) -> Result<(), Error> {
if self.is_cancelled() {
Expand All @@ -86,7 +91,7 @@ impl Drop for CancellationState {
}

#[cfg(target_os = "macos")]
pub use mac::{availability, enroll, read, remove};
pub use mac::{authentication_view, availability, enroll, read, remove};

#[cfg(not(target_os = "macos"))]
pub fn availability() -> Availability {
Expand Down Expand Up @@ -153,6 +158,15 @@ mod mac {
capacity: isize,
) -> i32;
fn enote_biometry_validate(blob: *const u8, length: isize) -> i32;
fn enote_biometry_view(id: u64) -> *mut std::ffi::c_void;
}

/// Main thread only. A +1 retained `LAAuthenticationView` paired with the context that
/// this request's protected read will use, or null (cancelled, reused or off the main
/// thread). Without it that read refuses to start. The caller owns the reference.
pub fn authentication_view(request: u64) -> *mut std::ffi::c_void {
// SAFETY: an opaque request id; Swift checks the thread and returns an owned object.
unsafe { enote_biometry_view(request) }
}

pub(super) fn cancel(id: u64) {
Expand Down
39 changes: 37 additions & 2 deletions src-tauri/src/native_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,36 @@ pub struct Reveal {
pub mode: RevealMode,
}

/// Touch ID inside the protected window: an explanation, the system's embedded biometric view
/// for one biometry request, and Cancel. It collects nothing and returns no value.
pub struct Biometric {
pub title: String,
pub detail: String,
/// The biometry request whose paired authentication context the embedded view presents.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub request: u64,
}

enum Request {
Entry(Prompt),
Reveal {
reveal: Reveal,
extend: Option<Box<dyn Fn() -> Authorization + Send>>,
},
Biometric(Biometric),
}

enum Callback {
Entry(Completion),
Reveal(Box<dyn FnOnce() + Send>),
Biometric(Box<dyn FnOnce() + Send>),
}

impl Callback {
fn finish(self, result: Result<Reply, PromptError>) {
match self {
Self::Entry(completion) => completion(result),
Self::Reveal(completion) => completion(),
Self::Reveal(completion) | Self::Biometric(completion) => completion(),
}
}
}
Expand All @@ -101,18 +113,20 @@ impl Request {
match self {
Self::Entry(prompt) => prompt.text(),
Self::Reveal { reveal, .. } => (&reveal.title, &reveal.detail),
Self::Biometric(biometric) => (&biometric.title, &biometric.detail),
}
}
fn labels(&self) -> Vec<&str> {
match self {
Self::Entry(prompt) => prompt.labels(),
Self::Reveal { .. } => Vec::new(),
Self::Reveal { .. } | Self::Biometric(_) => Vec::new(),
}
}
fn reply(&self, fields: Vec<Zeroizing<String>>) -> Result<Reply, &'static str> {
match self {
Self::Entry(prompt) => prompt.reply(fields),
Self::Reveal { .. } => Err("Close this native readout to finish."),
Self::Biometric(_) => Err("Use Touch ID, or choose Cancel."),
}
}
}
Expand Down Expand Up @@ -257,6 +271,27 @@ pub fn present_reveal(
}
}

/// Show Touch ID inside this exact main window (Mac only). The completion runs once when the
/// panel closes for any reason; the caller cancels its biometry request then, which also
/// invalidates the paired context, so authentication never continues without this panel.
pub fn present_biometric(
window: &tauri::WebviewWindow,
id: u64,
biometric: Biometric,
activity: Activity,
completion: Box<dyn FnOnce() + Send>,
) -> Result<(), PromptError> {
let request = Request::Biometric(biometric);
let callback = Callback::Biometric(completion);
#[cfg(target_os = "macos")]
return mac::present(window, id, request, activity, callback);
#[cfg(not(target_os = "macos"))]
{
let _ = (window, id, request, activity, callback);
Err(PromptError::Unavailable)
}
}

/// Cancel only this request; cancellation never submits a wrong credential.
pub fn cancel(id: u64) -> bool {
#[cfg(target_os = "macos")]
Expand Down
Loading
Loading