From 4f606ae9a398e2b3602c85eb1f0bbb58f0a3b6e1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:23:38 -0700 Subject: [PATCH] aacs: source OEM host certs from keysource layer Complete the OEM/AACS cert baseline so host certs are a KeySource output, never compiled in. With an unlocker present the OEM route is unused (unlocker_read_volume_id short-circuits); without one, the cert handshake runs when a keysource supplies a host cert and fails gracefully when none does. - KeySource trait gains host_certs() (default empty), reusing the existing aacs::HostCert type. A source holds certs as its second kind of AACS material alongside decryption keys. - ScanOptions gains key_sources so the handshake can collect certs across the app's keysource layer, unioned with DriveCredentials. - do_handshake_cert collects certs via collect_host_certs (credentials + every key source). Zero certs from any source now returns the new graceful Error::AacsNoHostCert (code 7024, sentinel ) instead of silently skipping; resolution still falls back to the path-1 disc-hash -> VUK lookup, which drops the error on a hit. - error.rs: add E_AACS_NO_HOST_CERT / Error::AacsNoHostCert, wired into code(), Display, and the round-trip + sentinel tests. HandshakeResult { volume_id, read_data_key } unchanged: the cert path still yields both the VID and the bus key. --- src/disc/encrypt.rs | 138 +++++++++++++++++++++++++++++++++++++++----- src/disc/mod.rs | 15 +++++ src/error.rs | 25 ++++++++ src/keysource.rs | 20 +++++++ 4 files changed, 183 insertions(+), 15 deletions(-) diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index fda3fe6..9614bb1 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -65,6 +65,23 @@ impl Disc { /// read_data_key for bus decryption must still use the cert path, so an /// unlocker with no OEM VID capability returns `None` and we fall through /// to cert auth unchanged. + /// Collect every AACS host cert the caller carries, from BOTH the explicit + /// [`DriveCredentials`] and the key-source layer + /// ([`crate::KeySource::host_certs`] across each source), unioned. Host certs + /// are keysource-served, never compiled in; this is the one place the OEM + /// cert route gathers them. An empty result is the graceful no-cert signal + /// (the caller turns it into [`Error::AacsNoHostCert`]). + fn collect_host_certs(opts: &ScanOptions) -> Vec { + let mut host_certs: Vec = Vec::new(); + if let Some(c) = &opts.credentials { + host_certs.extend(c.host_certs.iter().cloned()); + } + for src in &opts.key_sources { + host_certs.extend(src.host_certs()); + } + host_certs + } + fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, @@ -105,21 +122,33 @@ impl Disc { } } - // Host certs come from the caller's DriveCredentials (e.g. the keydb's - // host_certs(), sourced app-side) — the library does not load a keydb. - // Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies - // on the path-1 disc-hash → VUK lookup. - let host_certs: &[aacs::HostCert] = match &opts.credentials { - Some(c) if !c.host_certs.is_empty() => &c.host_certs, - _ => { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_no_credentials", - "no drive credentials supplied; cert handshake skipped" - ); - return (None, None); - } - }; + // Host certs are keysource-served, never compiled in. Collect them from + // BOTH places the caller may carry them: + // 1. the explicit `DriveCredentials` (certs the app pre-extracted), and + // 2. the key-source layer (`KeySource::host_certs()` across every + // registered source — the keydb source exposes its `| HC |`/`| HC2 |` + // rows here; an online source whose cert-serving isn't yet designed + // contributes none). + // The two are unioned so either wiring works. With ZERO certs from any + // source the OEM cert route cannot run: we fail GRACEFULLY with + // `AacsNoHostCert` (no panic, no generic failure). Resolution then + // proceeds with VID=zero and relies on the path-1 disc-hash → VUK lookup, + // which drops the error when it hits. + let host_certs = Self::collect_host_certs(opts); + if host_certs.is_empty() { + tracing::warn!( + target: "freemkv::disc", + phase = "handshake_no_host_cert", + "no host cert from credentials or any key source; OEM cert route unavailable" + ); + return ( + None, + Some(Error::AacsNoHostCert { + path: "".into(), + }), + ); + } + let host_certs: &[aacs::HostCert] = &host_certs; let host_cert_count = host_certs.len(); tracing::debug!( @@ -743,4 +772,83 @@ mod tests { // construction requires a live transport; the parsing branches are // exercised through `read_vid_oem`'s callers in integration. // --------------------------------------------------------------- + + // --------------------------------------------------------------- + // Tests: collect_host_certs — the OEM cert route's cert-gathering. + // Unions DriveCredentials with the key-source layer; empty means + // the route fails gracefully (AacsNoHostCert), never panics. + // --------------------------------------------------------------- + + fn fake_cert(tag: u8) -> aacs::HostCert { + aacs::HostCert { + private_key: [tag; 20], + certificate: vec![tag; 92], + private_key_v2: None, + certificate_v2: None, + } + } + + /// A minimal in-test KeySource that yields no keys but a fixed cert list. + struct CertSource(Vec); + impl crate::KeySource for CertSource { + fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option { + None + } + fn host_certs(&self) -> Vec { + self.0.clone() + } + } + + #[test] + fn collect_host_certs_empty_when_no_credentials_no_sources() { + let opts = ScanOptions::default(); + assert!(Disc::collect_host_certs(&opts).is_empty()); + } + + #[test] + fn collect_host_certs_from_credentials_only() { + let opts = ScanOptions { + credentials: Some(crate::DriveCredentials { + host_certs: vec![fake_cert(1)], + }), + ..Default::default() + }; + let certs = Disc::collect_host_certs(&opts); + assert_eq!(certs.len(), 1); + assert_eq!(certs[0].private_key, [1u8; 20]); + } + + #[test] + fn collect_host_certs_from_key_source_only() { + let opts = ScanOptions { + key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))], + ..Default::default() + }; + let certs = Disc::collect_host_certs(&opts); + assert_eq!(certs.len(), 1); + assert_eq!(certs[0].private_key, [2u8; 20]); + } + + /// The two routes union: a cert in credentials AND one in a key source both + /// reach the handshake. + #[test] + fn collect_host_certs_unions_credentials_and_sources() { + let opts = ScanOptions { + credentials: Some(crate::DriveCredentials { + host_certs: vec![fake_cert(1)], + }), + key_sources: vec![ + Box::new(CertSource(vec![fake_cert(2)])), + Box::new(CertSource(vec![])), // a source with no cert (e.g. online stub) + Box::new(CertSource(vec![fake_cert(3)])), + ], + ..Default::default() + }; + let mut tags: Vec = Disc::collect_host_certs(&opts) + .iter() + .map(|c| c.private_key[0]) + .collect(); + tags.sort_unstable(); + assert_eq!(tags, vec![1, 2, 3]); + } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index fb5b90f..2c0c477 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1135,7 +1135,22 @@ pub struct DriveCredentials { pub struct ScanOptions { /// Host credentials for the live-drive AACS handshake. `None` for ISO /// scans, or a live drive where cert auth should be skipped. + /// + /// Host certs may ALSO be supplied through [`Self::key_sources`]: the + /// handshake unifies certs from both, so the app can pass its already-built + /// keysource layer rather than (or in addition to) pre-extracting certs into + /// `DriveCredentials`. Either route is keysource-served — certs are never + /// compiled into the library. pub credentials: Option, + /// The application's key-source layer. The handshake collects host certs + /// across these (via [`crate::KeySource::host_certs`]) for the OEM/AACS + /// cert-auth route, unioned with [`Self::credentials`]. Empty by default — + /// an ISO scan supplies none, and a live-drive caller that pre-extracted + /// certs into `credentials` may leave it empty too. The library still + /// resolves NO keys from these at scan time; they are consulted only for + /// their host certs here (key *resolution* stays out-of-band via + /// `Disc::decrypt_with`). + pub key_sources: Vec>, /// Optional cooperative-cancellation token. When set, long scan-time /// loops (notably the CSS known-plaintext crack, which can scan up to /// 50_000 sectors on a live DVD) poll it and bail out cleanly so a diff --git a/src/error.rs b/src/error.rs index 8f6d3dd..6684dd3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -86,6 +86,7 @@ pub const E_DRIVE_PROFILE_MISSING: u16 = 7020; pub const E_VID_CDB_UNAVAILABLE: u16 = 7021; pub const E_NO_DISC_KEY: u16 = 7022; pub const E_CSS_KEY_MISSING: u16 = 7023; +pub const E_AACS_NO_HOST_CERT: u16 = 7024; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -311,6 +312,18 @@ pub enum Error { /// re-cracked). Muxing would emit scrambled ciphertext, so the caller /// fails fast instead. CSS analogue of [`Error::NoDiscKey`]. CssKeyMissing, + /// The live-drive AACS cert-auth handshake (the OEM/AACS baseline route) + /// could not run because NO host certificate was available from any key + /// source. Host certs are keysource-served, never compiled in, so without + /// a keysource that supplies one the OEM route fails gracefully here — this + /// is the intended outcome, not a panic. Resolution still proceeds with a + /// zero Volume ID and relies on the path-1 disc-hash → VUK lookup, so the + /// error is dropped when that lookup hits. `path` carries the sentinel + /// `` (mirroring [`Error::KeydbLoad`]'s sentinel) so a CLI can + /// render "No Host Certs Found." + AacsNoHostCert { + path: String, + }, // Keydb (8xxx) KeydbConnect { @@ -487,6 +500,7 @@ impl Error { Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE, Error::NoDiscKey { .. } => E_NO_DISC_KEY, Error::CssKeyMissing => E_CSS_KEY_MISSING, + Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, @@ -632,6 +646,7 @@ impl std::fmt::Display for Error { Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status), Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path), Error::KeydbLoad { path } => write!(f, "E{}: {}", self.code(), path), + Error::AacsNoHostCert { path } => write!(f, "E{}: {}", self.code(), path), Error::KeydbUnsupportedScheme { scheme } => { write!(f, "E{}: {}", self.code(), scheme) } @@ -1078,6 +1093,8 @@ mod tests { E_DRIVE_PROFILE_MISSING, E_VID_CDB_UNAVAILABLE, E_NO_DISC_KEY, + E_CSS_KEY_MISSING, + E_AACS_NO_HOST_CERT, E_KEYDB_CONNECT, E_KEYDB_HTTP, E_KEYDB_INVALID, @@ -1478,6 +1495,14 @@ mod tests { "KeydbLoad display must include the sentinel path" ); + let e_no_cert = Error::AacsNoHostCert { + path: "".into(), + }; + assert!( + e_no_cert.to_string().contains(""), + "AacsNoHostCert display must include the sentinel path" + ); + let e_scheme = Error::KeydbUnsupportedScheme { scheme: "ftp".into(), }; diff --git a/src/keysource.rs b/src/keysource.rs index edf3c51..cf119ac 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -11,6 +11,7 @@ //! out of the library while all key *mechanism* (the AACS derivation chain) //! stays in it. +use crate::aacs::HostCert; use crate::disc::Key; /// The public AACS inputs a key source needs to look a disc up. Captured at @@ -92,6 +93,25 @@ pub trait KeySource { fn errored(&self) -> bool { false } + + /// The AACS host certificate(s) this source can supply for the live-drive + /// SCSI mutual-auth handshake (the OEM/AACS baseline route). A host cert is + /// the *second* kind of AACS material a source may hold, distinct from the + /// decryption keys handed out by [`KeySource::next_key`]: it unlocks the + /// authenticated bus so the drive will report the Volume ID and bus key, + /// whereas the keys decrypt content once the disc is read. + /// + /// Returned, never compiled in: a host cert is **perishable** — it can be + /// revoked on a given drive's Host Revocation List (carried forward by newer + /// discs' MKBs), so it must be rotatable, hence served by a source rather + /// than baked into the binary. A source that holds no cert (a mapfile, or an + /// online service whose cert-serving isn't yet designed) returns the empty + /// vec — the default. The handshake collects across every source and tries + /// each candidate; with no candidate from any source the OEM route fails + /// gracefully ([`crate::Error::AacsNoHostCert`]), it never panics. + fn host_certs(&self) -> Vec { + Vec::new() + } } #[cfg(test)]