Something went wrong. Try again.
A Zig library and CLI for reading Mobipocket (.mobi, .prc, .azw) e-books.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Mobipocket key derivation.//!//! Two schemes exist. The legacy scheme (`encryption_type` 1) stores the book//! key in record zero, obfuscated with a fixed vector, so nothing is needed//! from the caller. The DRM scheme (`encryption_type` 2) stores the key in a//! voucher that opens either with a fixed "reader" key or with a key derived//! from the reading device's serial number.//!//! Only key *derivation* lives here; the cipher itself is in `pc1.zig`.
const std = @import("std");const pc1 = @import("pc1.zig");
pub const Error = error{ InvalidSerial, KeyNotFound,};
/// Opens vouchers that are not bound to a device.pub const internal_reader_key: pc1.Key = .{ 0x72, 0x38, 0x33, 0xb0, 0xb4, 0xf2, 0xe3, 0xca, 0xdf, 0x09, 0x01, 0xd6, 0xe2, 0xe0, 0x3f, 0x96,};
/// Obfuscates the book key stored in record zero by the legacy scheme.pub const internal_reader_key_v1: pc1.Key = "QDCVEPMU675RUBSZ".*;
pub const pid_size = 10;pub const Pid = [pid_size]u8;
/// Serial numbers come in these two lengths; nothing else is accepted.pub const serial_size = 16;pub const serial_long_size = 40;
/// The alphabet PIDs are spelled in. Note the missing 'O' and '0'.const alphabet = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789";
const crc_table: [256]u32 = blk: { @setEvalBranchQuota(20000); var table: [256]u32 = undefined; for (&table, 0..) |*entry, i| { var c: u32 = @intCast(i); for (0..8) |_| c = if (c & 1 != 0) 0xEDB88320 ^ (c >> 1) else c >> 1; entry.* = c; } break :blk table;};
/// The reflected CRC-32 register (polynomial 0xEDB88320) starting from zero/// with no final inversion. This is *not* the usual CRC-32 value: the standard/// one starts the register at 0xFFFFFFFF and inverts at the end. Mobipocket/// uses the bare register, so the two disagree for every input.fn crcRegister(data: []const u8) u32 { var c: u32 = 0; for (data) |b| c = crc_table[(c ^ b) & 0xff] ^ (c >> 8); return c;}
/// The two trailing characters of a PID, which check the first eight.fn pidChecksum(pid: Pid) [2]u8 { var crc = crcRegister(pid[0 .. pid_size - 2]); crc ^= crc >> 16; var out: [2]u8 = undefined; for (&out) |*o| { const b: u8 = @truncate(crc); const pos = (b / alphabet.len) ^ (b % alphabet.len); o.* = alphabet[pos % alphabet.len]; crc >>= 8; } return out;}
/// Derive the PID for a device from its serial number.pub fn pidFromSerial(serial: []const u8) Error!Pid { if (serial.len != serial_size and serial.len != serial_long_size) return error.InvalidSerial;
var pid: Pid = @splat(0); var scratch: [pid_size - 2]u8 = @splat(0); var len = scratch.len; if (serial.len == serial_size) { // Short serials leave a literal '*' in the last body position. len -= 1; pid[len] = '*'; }
const crc = crcRegister(serial); for (serial, 0..) |c, i| scratch[i % len] ^= c; for (0..len) |i| { const shift: u5 = @intCast(24 - 8 * (i & 3)); scratch[i] ^= @as(u8, @truncate(crc >> shift)); const v = scratch[i]; // Caps at 32, so it always lands inside the 34 character alphabet. const pos = (v >> 7) + ((v >> 5 & 3) ^ (v & 0x1f)); pid[i] = alphabet[pos]; }
const checksum = pidChecksum(pid); pid[pid_size - 2] = checksum[0]; pid[pid_size - 1] = checksum[1]; return pid;}
pub fn pidIsValid(pid: Pid) bool { return std.mem.eql(u8, &pidChecksum(pid), pid[pid_size - 2 ..]);}
/// The cipher key a PID unlocks vouchers with.pub fn pidKey(pid: Pid) pc1.Key { var key: pc1.Key = @splat(0); @memcpy(key[0 .. pid_size - 2], pid[0 .. pid_size - 2]); pc1.encrypt(&key, &key, internal_reader_key); return key;}
/// Vouchers record which key opens them as a one byte sum, so the wrong key can/// be skipped without doing the work of decrypting the cookie.pub fn keyChecksum(key: pc1.Key) u8 { var sum: usize = 0; for (key) |b| sum += b; return @truncate(sum);}
pub const voucher_size = 48;pub const cookie_size = 32;
/// One entry in record zero's DRM section.pub const Voucher = struct { verification: u32, size: u32, type: u32, checksum: u8, cookie: [cookie_size]u8,
pub fn parse(bytes: *const [voucher_size]u8) Voucher { return .{ .verification = std.mem.readInt(u32, bytes[0..4], .big), .size = std.mem.readInt(u32, bytes[4..8], .big), .type = std.mem.readInt(u32, bytes[8..12], .big), .checksum = bytes[12], // Three bytes of padding sit between the checksum and the cookie. .cookie = bytes[16..48].*, }; }};
/// What a successfully opened voucher yields.pub const Unlocked = struct { key: pc1.Key, /// Validity window, in minutes since the Unix epoch. `valid_from` of zero /// or `valid_to` of 0xFFFFFFFF means the voucher never expires. This /// library reports the window but does not enforce it. valid_from: u32, valid_to: u32,
pub fn expiresAt(self: Unlocked) ?u32 { if (self.valid_from == 0 or self.valid_to == 0xffff_ffff) return null; return self.valid_to; }};
/// Key types, as stored in the low five bits of the cookie's flags.pub const key_type_device = 1;pub const key_type_internal = 3;
/// Try `key` against one voucher. Returns null if it is not the right key.pub fn openVoucher(voucher: Voucher, key: pc1.Key, key_type: u8) ?Unlocked { if (voucher.checksum != keyChecksum(key)) return null;
var cookie: [cookie_size]u8 = undefined; pc1.decrypt(&cookie, &voucher.cookie, key);
if (std.mem.readInt(u32, cookie[0..4], .big) != voucher.verification) return null; const flags = std.mem.readInt(u32, cookie[4..8], .big); if (flags & 0x1f != key_type) return null;
return .{ .key = cookie[8..24].*, .valid_to = std.mem.readInt(u32, cookie[24..28], .big), .valid_from = std.mem.readInt(u32, cookie[28..32], .big), };}
/// Build a voucher that `key` opens. Used by the tests, and the inverse of/// `openVoucher`.pub fn sealVoucher(book_key: pc1.Key, key: pc1.Key, key_type: u8, verification: u32) [voucher_size]u8 { var cookie: [cookie_size]u8 = @splat(0); std.mem.writeInt(u32, cookie[0..4], verification, .big); std.mem.writeInt(u32, cookie[4..8], key_type, .big); @memcpy(cookie[8..24], &book_key); std.mem.writeInt(u32, cookie[24..28], 0xffff_ffff, .big); std.mem.writeInt(u32, cookie[28..32], 0, .big);
var out: [voucher_size]u8 = @splat(0); std.mem.writeInt(u32, out[0..4], verification, .big); std.mem.writeInt(u32, out[4..8], voucher_size, .big); std.mem.writeInt(u32, out[8..12], key_type, .big); out[12] = keyChecksum(key); pc1.encrypt(out[16..48], &cookie, key); return out;}
// -- Tests -----------------------------------------------------------------//// As in `pc1.zig`, the expected values come from compiling libmobi's reference// routines and dumping their output, so these pin the derivation to a known// good implementation.
const testing = std.testing;
test "crc register matches the reference" { // Deliberately not the standard CRC-32 values; see `crcRegister`. try testing.expectEqual(@as(u32, 0xca6598d0), crcRegister("abc")); try testing.expectEqual(@as(u32, 0x00000000), crcRegister("")); try testing.expectEqual(@as(u32, 0x2dfd2d88), crcRegister("123456789"));}
test "crc register is not the standard crc-32" { // The standard CRC-32/ISO-HDLC check value for "123456789" is 0xCBF43926. // Getting these two confused silently produces wrong PIDs, so assert the // difference rather than leaving it to a comment. try testing.expect(crcRegister("123456789") != 0xcbf43926);}
test "pid derivation matches the reference" { try testing.expectEqualStrings("YFEAZXZ*KZ", &try pidFromSerial("B0011T2ZQA123456")); try testing.expectEqualStrings("YQ8EQXN*IP", &try pidFromSerial("9876543210ABCDEF")); try testing.expectEqualStrings( "VVZEVVZEFQ", &try pidFromSerial("0123456789012345678901234567890123456789"), );}
test "derived pids pass their own checksum" { try testing.expect(pidIsValid(try pidFromSerial("B0011T2ZQA123456"))); try testing.expect(pidIsValid(try pidFromSerial("0123456789012345678901234567890123456789")));
var bad = try pidFromSerial("B0011T2ZQA123456"); bad[0] = if (bad[0] == 'A') 'B' else 'A'; try testing.expect(!pidIsValid(bad));}
test "rejects serials of the wrong length" { try testing.expectError(error.InvalidSerial, pidFromSerial("too short")); try testing.expectError(error.InvalidSerial, pidFromSerial("B0011T2ZQA1234567")); try testing.expectError(error.InvalidSerial, pidFromSerial(""));}
test "pid key matches the reference" { var buf: [16]u8 = undefined; const cases = .{ .{ "B0011T2ZQA123456", "c328c50c52ca079956f8a0283e7a0dca" }, .{ "9876543210ABCDEF", "c33fedd298ce5b0182215edd66da9409" }, .{ "0123456789012345678901234567890123456789", "ccca3d3e82aef34b42514519920e86b3" }, }; inline for (cases) |case| { const key = pidKey(try pidFromSerial(case[0])); try testing.expectEqualSlices(u8, try std.fmt.hexToBytes(&buf, case[1]), &key); }}
test "key checksum matches the reference" { try testing.expectEqual(@as(u8, 0x36), keyChecksum(internal_reader_key)); try testing.expectEqual(@as(u8, 0x9d), keyChecksum(internal_reader_key_v1));}
test "a voucher round trips with the internal key" { const book_key: pc1.Key = "0123456789abcdef".*; const bytes = sealVoucher(book_key, internal_reader_key, key_type_internal, 0xdeadbeef); const voucher = Voucher.parse(&bytes);
try testing.expectEqual(@as(u32, 0xdeadbeef), voucher.verification); try testing.expectEqual(@as(u32, voucher_size), voucher.size);
const opened = openVoucher(voucher, internal_reader_key, key_type_internal) orelse return error.TestVoucherDidNotOpen; try testing.expectEqualSlices(u8, &book_key, &opened.key); try testing.expectEqual(@as(?u32, null), opened.expiresAt());}
test "a voucher round trips with a device key" { const book_key: pc1.Key = "fedcba9876543210".*; const pid = try pidFromSerial("B0011T2ZQA123456"); const device_key = pidKey(pid);
const bytes = sealVoucher(book_key, device_key, key_type_device, 0x12345678); const voucher = Voucher.parse(&bytes);
const opened = openVoucher(voucher, device_key, key_type_device) orelse return error.TestVoucherDidNotOpen; try testing.expectEqualSlices(u8, &book_key, &opened.key);
// The wrong key, and the right key with the wrong type, must both fail. try testing.expectEqual(@as(?Unlocked, null), openVoucher(voucher, internal_reader_key, key_type_internal)); try testing.expectEqual(@as(?Unlocked, null), openVoucher(voucher, device_key, key_type_internal));
// A different device must not open it either. const other = pidKey(try pidFromSerial("9876543210ABCDEF")); try testing.expectEqual(@as(?Unlocked, null), openVoucher(voucher, other, key_type_device));}