Files
libfreemkv/docs/aacs.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

9.5 KiB

AACS Encryption Support

Overview

AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption so disc access is transparent to the application.

There are two major versions:

  • AACS 1.0 -- Used by standard Blu-ray discs.
  • AACS 2.0 / 2.1 -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer on top of the standard content encryption. UHD drives accept AACS 1.0 host credentials for backward compatibility.

All versions use AES-128 for content decryption. The library reads the keys it needs from keydb.cfg, walks the disc's Media Key Block (MKB) to resolve the disc's key, and decrypts the content stream. AACS-encrypted discs therefore require a keydb.cfg; CSS-protected DVDs do not (see the CSS notes in the library docs).

How it works (feature level)

When a disc is scanned, the library:

  1. Reads the disc's AACS key-input files from the /AACS/ directory.
  2. Resolves the disc's key from keydb.cfg — either directly from a per-disc entry, or by walking the MKB with the keys present in the keydb.
  3. Performs the drive-level SCSI authentication handshake needed to obtain the Volume ID and, for UHD, the bus-decryption key.
  4. Decrypts the content stream as titles are read.

A resolved key is verified against actual disc content before it is applied, so a stale or wrong key fails loudly rather than producing silent garbage. If no usable key is available for an AACS-encrypted disc, the library surfaces a specific error (the E70xx family) describing which part of the chain was missing, and a missing keydb.cfg surfaces as Error::KeydbLoad with the sentinel path <no keydb in search paths>.

API Usage

AACS decryption is transparent to the application. Disc::scan() handles everything automatically:

use libfreemkv::{Drive, Disc};
use libfreemkv::disc::ScanOptions;
use std::path::Path;

let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
drive.wait_ready().unwrap();
drive.init().unwrap();
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();

// Check encryption state
if disc.encrypted {
    if let Some(ref aacs) = disc.aacs {
        println!("AACS {}.0", aacs.version);
        println!("Key source: {}", aacs.key_source.name());
        if let Some(mkb_ver) = aacs.mkb_version {
            println!("MKB version: {}", mkb_ver);
        }
    } else {
        println!("Encrypted but keys not available");
    }
}

// Read content -- decryption is applied on read by the DiscStream decorator.
// Live disc does NOT go through the URL resolver: `input("disc://...")` returns
// Error::DiscUrlNotDirect by design.
let keys = disc.decrypt_keys();
let mut stream = DiscStream::new(
    Box::new(drive),
    disc.titles[0].clone(),
    keys,
    batch_sectors,
    disc.titles[0].content_format,
    false, // raw: false → decrypt on read
    None,  // halt
)?;
while let Ok(Some(frame)) = stream.read() {
    // decrypted PES frames
}

The application never calls decryption functions and never manages the drive-level handshake. It DOES own key resolution — see below.

Key resolution is the caller's job

libfreemkv is lookup-free: it resolves no keys and reads no keydb. There is no ScanOptions::with_keydb, and ScanOptions has no keydb field — its only scan input is the optional drive credentials for the live-drive authenticated handshake.

The caller resolves a key out-of-band through a key source and applies it with [Disc::decrypt_with]. freemkv-keysources is the crate that implements the keydb and key-server sources; ScanOptions::key_sources takes them as Box<dyn KeySource>.

AacsState

After a successful scan, disc.aacs contains an AacsState:

Field Type Description
version u8 AACS version (1 or 2)
bus_encryption bool Whether bus encryption is active
mkb_version Option<u32> MKB version from disc
disc_hash String Identifier for the disc's key-input files
key_source KeyOrigin How the disc's key was resolved

keydb.cfg

keydb.cfg is the single source of AACS key material. It is a text file (lines starting with ; or # are comments) holding the host credentials and per-disc entries the library uses to resolve a disc. autorip can auto-download and refresh it from a configured URL. The library does not ship any AACS keys compiled into the binary.

UnitKey::is_default_index test rationale

is_default_index is the public predicate that separates ordinary (index-0) content keys from FMTS forensic index keys (see UnitKey docs; AACS 2.1 IndividualSegment.tbl tagging). A body answering true for everything would present a forensic index key as an ordinary content key -- the caller would decrypt the bulk of the title with a key that only opens 1/32nd of it; answering false for everything would hide every ordinary key.

is_default_index_separates_the_two_constructors is pinned against the two named constructors, which are the contract: UnitKey::new builds the ordinary key, UnitKey::forensic builds an index key for 1..=32.

is_default_index_agrees_with_the_forensic_index_resolver checks the predicate agrees with the one consumer of index_number in the crate: crate::aacs::index_select::resolve_disc_index resolves the disc's forensic index from exactly the keys that are NOT default. If the two disagree, a disc resolves an index whose key the rest of the pipeline treats as ordinary (or vice versa).

src/aacs/mod.rs module notes

KEYDB.cfg line format

| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
| PK | 0x...
| HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>

Spec provenance tags

The crypto in src/aacs/ carries [TAG] §x.y citations back to the published AACS specification (Final Rev 0.953), so each primitive links to the section it implements:

  • [C] -- AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
  • [PR] -- AACS Pre-recorded Video Book (Volume/Title Key layer).
  • [BD] -- AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
  • [RE] -- reverse-engineered from real discs, cited only where the public spec is silent (the 0x86 verify record and the Category-C MKB type values).

AACS key-input path discovery (AacsRole, role_paths, find_hddvd_aacs_dir)

BD and UHD keep their key material under a fixed /AACS/... tree, so those paths are constants. HD DVD keeps the equivalents in a reserved root directory whose NAME is authoring-house-specific -- observed ANY! (Dukes of Hazzard) and AAC! (Freedom / Memory-Tech), each with a <name>!_BAK mirror -- and whose title-key file is NOT always VTKF000.AACS (Freedom ships VTKF090.AACS + VTKF100.AACS). So the HD DVD files are DISCOVERED from the parsed UDF tree (find_hddvd_aacs_dir + role_paths), never hardcoded.

Each key role (AacsRole) resolves to an ordered candidate list -- the BD/UHD constants first, then whatever the HD DVD directory actually holds -- which every reader walks with read_first, first-that-reads. No reader ever branches on disc type: a BD/UHD disc has the /AACS/ files so those win; an HD DVD has none of them, so it falls through to the discovered entries. Centralised so resolve_vid_only, read_aacs_inputs, read_mkb_content, and read_aacs_version can never silently diverge the disc_hash / MKB / VID that another reader feeds a key service.

find_hddvd_aacs_dir identifies the HD DVD AACS directory structurally, NOT by a hardcoded name: the root child directory whose name ends in ! (so the <name>!_BAK backup mirror, which also ends in a non-! char, is not mistaken for it) and which contains MKBROM.AACS. Observed real names: ANY! (Dukes of Hazzard), AAC! (Freedom). A BD/UHD disc has no such directory -> None.

role_paths builds the ordered candidate list: fixed BD/UHD paths first, then the discovered HD DVD files. For AacsRole::UnitKey every VTKF*.AACS in the directory is appended in sorted name order -- a disc may carry more than one variant (Freedom: VTKF090 + VTKF100), not just VTKF000.

read_first walks an AACS role's candidate paths and returns the first that reads; read performs the actual per-path read (full file or bounded prefix), so callers share the same first-present walk regardless of read style. Returns Error::AacsNoKeys if no candidate is present. Generic over the path element (&str or owned String) so it accepts the Vec<String> that role_paths builds from the discovered HD DVD directory.

Test: a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory

The !-suffix and the MKBROM.AACS presence test are BOTH required -- the discovery is a conjunction, not a disjunction. The existing fixtures only ever present a directory that satisfies both (AAC! with MKBROM.AACS) alongside one that satisfies neither (AAC!_BAK -- which contains MKBROM.AACS but is ALSO reached only after the real dir), so either half of the conjunction could be dropped and the same directory would still be found. This test's fixture has a directory that satisfies the name half and NOT the contents half: it must not be picked. If it were, the HD DVD path would resolve MKBROM.AACS, CONTENT_CERT.AACS and the title-key file under a directory that holds none of them -- the disc reports "no AACS key files" and never rips.