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.
222 lines
8.8 KiB
Markdown
222 lines
8.8 KiB
Markdown
# 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
|
|
|
|
1. **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()` and `Disc::scan()`.
|
|
|
|
2. **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 `Unlocker` trait + registry (`register_unlocker`); without one the library
|
|
still rips via the host-certificate AACS handshake.
|
|
|
|
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
|
available. Callers read cleartext sectors without knowing whether the disc
|
|
was encrypted.
|
|
|
|
4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000).
|
|
The library never formats user-facing messages -- applications do that.
|
|
|
|
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
|
policy level. If a profile exists, the library uses it.
|
|
|
|
6. **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 disc
|
|
- `probe_disc()` -- probe disc surface for optimal speeds
|
|
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (1.5 s vs. 30 s)
|
|
- `wait_ready()` -- wait for disc insertion
|
|
- `eject()` -- 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`](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_privates
|
|
- `decrypt_keys()` -- DecryptKeys for content decryption
|
|
- `encrypted: 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](https://github.com/freemkv/freemkv-unlock)** repository, never
|
|
in libfreemkv. See [`drive-access.md`](drive-access.md#drive-unlock-seam) 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.
|