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 <no host cert>)
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.
This commit is contained in:
+119
-11
@@ -65,6 +65,23 @@ impl Disc {
|
|||||||
/// read_data_key for bus decryption must still use the cert path, so an
|
/// 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
|
/// unlocker with no OEM VID capability returns `None` and we fall through
|
||||||
/// to cert auth unchanged.
|
/// 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<crate::aacs::HostCert> {
|
||||||
|
let mut host_certs: Vec<crate::aacs::HostCert> = 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(
|
fn do_handshake_cert(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
@@ -105,21 +122,33 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host certs come from the caller's DriveCredentials (e.g. the keydb's
|
// Host certs are keysource-served, never compiled in. Collect them from
|
||||||
// host_certs(), sourced app-side) — the library does not load a keydb.
|
// BOTH places the caller may carry them:
|
||||||
// Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies
|
// 1. the explicit `DriveCredentials` (certs the app pre-extracted), and
|
||||||
// on the path-1 disc-hash → VUK lookup.
|
// 2. the key-source layer (`KeySource::host_certs()` across every
|
||||||
let host_certs: &[aacs::HostCert] = match &opts.credentials {
|
// registered source — the keydb source exposes its `| HC |`/`| HC2 |`
|
||||||
Some(c) if !c.host_certs.is_empty() => &c.host_certs,
|
// 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!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "handshake_no_credentials",
|
phase = "handshake_no_host_cert",
|
||||||
"no drive credentials supplied; cert handshake skipped"
|
"no host cert from credentials or any key source; OEM cert route unavailable"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
Some(Error::AacsNoHostCert {
|
||||||
|
path: "<no host cert>".into(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return (None, None);
|
|
||||||
}
|
}
|
||||||
};
|
let host_certs: &[aacs::HostCert] = &host_certs;
|
||||||
|
|
||||||
let host_cert_count = host_certs.len();
|
let host_cert_count = host_certs.len();
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -743,4 +772,83 @@ mod tests {
|
|||||||
// construction requires a live transport; the parsing branches are
|
// construction requires a live transport; the parsing branches are
|
||||||
// exercised through `read_vid_oem`'s callers in integration.
|
// 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<aacs::HostCert>);
|
||||||
|
impl crate::KeySource for CertSource {
|
||||||
|
fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option<crate::disc::Key> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn host_certs(&self) -> Vec<aacs::HostCert> {
|
||||||
|
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<u8> = Disc::collect_host_certs(&opts)
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.private_key[0])
|
||||||
|
.collect();
|
||||||
|
tags.sort_unstable();
|
||||||
|
assert_eq!(tags, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1135,7 +1135,22 @@ pub struct DriveCredentials {
|
|||||||
pub struct ScanOptions {
|
pub struct ScanOptions {
|
||||||
/// Host credentials for the live-drive AACS handshake. `None` for ISO
|
/// Host credentials for the live-drive AACS handshake. `None` for ISO
|
||||||
/// scans, or a live drive where cert auth should be skipped.
|
/// 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<DriveCredentials>,
|
pub credentials: Option<DriveCredentials>,
|
||||||
|
/// 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<Box<dyn crate::KeySource>>,
|
||||||
/// Optional cooperative-cancellation token. When set, long scan-time
|
/// Optional cooperative-cancellation token. When set, long scan-time
|
||||||
/// loops (notably the CSS known-plaintext crack, which can scan up to
|
/// 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
|
/// 50_000 sectors on a live DVD) poll it and bail out cleanly so a
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
|
|||||||
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
|
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
|
||||||
pub const E_NO_DISC_KEY: u16 = 7022;
|
pub const E_NO_DISC_KEY: u16 = 7022;
|
||||||
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
||||||
|
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
@@ -311,6 +312,18 @@ pub enum Error {
|
|||||||
/// re-cracked). Muxing would emit scrambled ciphertext, so the caller
|
/// re-cracked). Muxing would emit scrambled ciphertext, so the caller
|
||||||
/// fails fast instead. CSS analogue of [`Error::NoDiscKey`].
|
/// fails fast instead. CSS analogue of [`Error::NoDiscKey`].
|
||||||
CssKeyMissing,
|
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
|
||||||
|
/// `<no host cert>` (mirroring [`Error::KeydbLoad`]'s sentinel) so a CLI can
|
||||||
|
/// render "No Host Certs Found."
|
||||||
|
AacsNoHostCert {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
KeydbConnect {
|
KeydbConnect {
|
||||||
@@ -487,6 +500,7 @@ impl Error {
|
|||||||
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
||||||
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
|
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
|
||||||
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
||||||
|
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
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::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
|
||||||
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
|
||||||
Error::KeydbLoad { 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 } => {
|
Error::KeydbUnsupportedScheme { scheme } => {
|
||||||
write!(f, "E{}: {}", self.code(), scheme)
|
write!(f, "E{}: {}", self.code(), scheme)
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1093,8 @@ mod tests {
|
|||||||
E_DRIVE_PROFILE_MISSING,
|
E_DRIVE_PROFILE_MISSING,
|
||||||
E_VID_CDB_UNAVAILABLE,
|
E_VID_CDB_UNAVAILABLE,
|
||||||
E_NO_DISC_KEY,
|
E_NO_DISC_KEY,
|
||||||
|
E_CSS_KEY_MISSING,
|
||||||
|
E_AACS_NO_HOST_CERT,
|
||||||
E_KEYDB_CONNECT,
|
E_KEYDB_CONNECT,
|
||||||
E_KEYDB_HTTP,
|
E_KEYDB_HTTP,
|
||||||
E_KEYDB_INVALID,
|
E_KEYDB_INVALID,
|
||||||
@@ -1478,6 +1495,14 @@ mod tests {
|
|||||||
"KeydbLoad display must include the sentinel path"
|
"KeydbLoad display must include the sentinel path"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let e_no_cert = Error::AacsNoHostCert {
|
||||||
|
path: "<no host cert>".into(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
e_no_cert.to_string().contains("<no host cert>"),
|
||||||
|
"AacsNoHostCert display must include the sentinel path"
|
||||||
|
);
|
||||||
|
|
||||||
let e_scheme = Error::KeydbUnsupportedScheme {
|
let e_scheme = Error::KeydbUnsupportedScheme {
|
||||||
scheme: "ftp".into(),
|
scheme: "ftp".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! out of the library while all key *mechanism* (the AACS derivation chain)
|
//! out of the library while all key *mechanism* (the AACS derivation chain)
|
||||||
//! stays in it.
|
//! stays in it.
|
||||||
|
|
||||||
|
use crate::aacs::HostCert;
|
||||||
use crate::disc::Key;
|
use crate::disc::Key;
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// 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 {
|
fn errored(&self) -> bool {
|
||||||
false
|
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<HostCert> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user