Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL). The CSS content cipher and Stevenson title-key attack are attributed to their published cryptanalysis (not libdvdcss); all libaacs/libbluray/libdvdread/libdvdnav name references were dropped from comments while keeping the standard format/spec descriptions. Also bumps to 1.3.1.
8.8 KiB
libfreemkv Architecture
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
Rust library with profiles bundled and all SCSI communication handled in-process.
AACS decryption requires an external keydb.cfg (default
~/.config/freemkv/keydb.cfg) — the derivation math is internal, but no AACS key
material is compiled in; DVD CSS player keys are the only compiled-in keys.
Repository: https://github.com/freemkv/libfreemkv License: MIT
Design Principles
-
CLI is dumb. All drive communication, disc parsing, AACS decryption, and format handling live in the library. CLI binaries are thin wrappers that call
Drive::open()andDisc::scan(). -
Firmware-clean core. libfreemkv ships no firmware, no unlock CDBs, and no drive profiles. Drive-unlock logic is plugged in by an external crate through the
Unlockertrait + registry (register_unlocker); without one the library still rips via the host-certificate AACS handshake. -
Transparent AACS. The
ContentReaderdecrypts on the fly when keys are available. Callers read cleartext sectors without knowing whether the disc was encrypted. -
Structured errors, no English. Every error has a numeric code (E1000-E8000). The library never formats user-facing messages -- applications do that.
-
Library-agnostic. No concept of "supported" vs "unsupported" drives at a policy level. If a profile exists, the library uses it.
-
Streams are dumb pipes. Streams read/write PES frames. They don't know about encryption, transport format, or source type. Decrypt is a stream-internal concern; the pipeline just moves frames.
Module Map
libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive Drive — open, identify, init, single-shot read
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
│ ├── unlock Unlocker trait + registry — the pluggable unlock seam
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
│ └── event Event system for drive status callbacks
│
├── Disc Scanning
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
│ ├── mpls MPLS playlist parser — clips, streams, STN table
│ ├── clpi CLPI clip info parser — EP map, sector extents
│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses
│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe)
│
├── Encryption
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
│ ├── css DVD CSS cipher — table-driven, no external keys needed
│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher
│
├── Streaming
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
│ ├── pes PES frame types; the unified pes::Stream (PesStream) read/write trait
│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource
│
├── I/O Primitives
│ ├── halt Halt cancellation token (one Arc<AtomicBool>, cloneable)
│ └── io/ Pipeline<I, R> + Sink trait + WritebackFile (bounded-cache writer)
│
├── Support
│ ├── keydb KEYDB.cfg download, parse, verify, save
│ └── error Error enum with numeric codes E1000-E8000
│
└── lib.rs Public API re-exports
Drive Access Flow
Drive::open(Path::new("/dev/sg4"))
│
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
└─ Drive ready for init/read
After open:
init()-- routes to the matching registered unlocker (if any); otherwise a no-op and the cert handshake carries the discprobe_disc()-- probe disc surface for optimal speedsread(lba, count, buf, recovery)-- single-shot read;recoveryonly selects the per-CDB timeout (1.5 s vs. 30 s)wait_ready()-- wait for disc insertioneject()-- eject tray
Recovery is layered above Drive::read, not inside it. Layer 1
(Disc::patch) handles bad-range retry by replaying the ddrescue mapfile.
Layer 3 (DiscStream::fill_extents adaptive batch sizer) handles in-loop
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
was removed in 0.13.6 — see rip-recovery.md and
the stop-wedge postmortem (2026-04-25).
Disc Scanning Flow
Disc::scan(&mut drive, &ScanOptions)
│
├─ READ CAPACITY Get disc size in sectors
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
├─ For each BDMV/PLAYLIST/*.mpls:
│ ├─ mpls::parse() Extract play items, STN streams
│ └─ For each clip:
│ └─ clpi::parse() EP map → sector extents for the clip's time range
├─ labels::detect() Parse BD-J JARs for stream labels
├─ Detect AACS Check for /AACS directory on disc
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
For DVD:
Disc::scan_dvd(&mut drive, &ScanOptions)
│
├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains
├─ CSS detection Check disc structure flag
└─ CSS key cracking Table-driven, no KEYDB needed
The result is a Disc with:
titles: Vec<DiscTitle>-- sorted by duration, each with streams, sector extents, codec_privatesdecrypt_keys()-- DecryptKeys for content decryptionencrypted: bool-- whether the disc uses AACS/CSS
AACS Decryption
Four key resolution paths, tried in order:
| Path | Method | Speed |
|---|---|---|
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
The AACS handshake (aacs/handshake) performs ECDH key agreement over the
AACS 1.0 160-bit elliptic curve to obtain:
- Volume ID -- needed for VUK derivation (paths 2-4)
- Read Data Key -- needed for AACS 2.0 (UHD) bus decryption
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
ContentReader handles this transparently. Streams that read sectors
(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes.
Error Codes
All errors carry a numeric code for programmatic handling. No user-facing text is baked into the library.
| Range | Category | Examples |
|---|---|---|
| E1xxx | Device errors | DeviceNotFound, DevicePermission |
| E2xxx | Profile errors | UnsupportedDrive, ProfileNotFound, ProfileParse |
| E3xxx | Unlock errors | UnlockFailed, SignatureMismatch, NotUnlocked, NotCalibrated |
| E4xxx | SCSI errors | ScsiError, ScsiTimeout |
| E5xxx | I/O errors | IoError (wraps std::io::Error) |
| E6xxx | Disc format errors | DiscError (UDF, MPLS, CLPI parse failures) |
| E7xxx | AACS errors | AacsError (key resolution, handshake, decryption) |
| E8xxx | KEYDB errors | KeydbError (download, parse, save) |
Platform Support
| Platform | Transport | Status |
|---|---|---|
| Linux | async sg write/poll/read on /dev/sg* |
Supported |
| macOS | IOKit SCSITask | Supported |
| Windows | SPTI (IOCTL_SCSI_PASS_THROUGH_DIRECT) |
Supported |
The ScsiTransport trait abstracts the platform. Adding a new platform requires
implementing execute() for that OS and wiring it into scsi::open().
Drive Unlock
libfreemkv carries no drive-unlock mechanism. The Unlocker trait + registry
(src/unlock.rs) is the seam: an external crate implements Unlocker and
registers it once via register_unlocker(...). At drive-prep the registry is
walked in order and the first unlocker whose matches() is true is asked to
unlock_drive() over the raw ScsiTransport. If none match, the drive is left
untouched and the host-certificate AACS handshake carries the disc.
The implementor owns everything firmware-specific — drive profiles, vendor CDBs,
variant logic. Concrete unlockers live in the separate
freemkv-unlock repository, never
in libfreemkv. See drive-access.md for the
trait definition and routing.
Build
cargo build --release
Produces a Rust library crate. The libc dependency is unix-only (gated).
All three platforms build and pass CI.