Something went wrong. Try again.
A Zig library and CLI for reading Mobipocket (.mobi, .prc, .azw) e-books.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! PC1 (Pukall Cipher 1), the 128-bit stream cipher Mobipocket uses for both//! its legacy encryption and its DRM.//!//! The cipher is Alexander Pukall's 1991 design, released without restriction.//! Encryption and decryption differ only in when the key is folded with the//! plaintext byte, so `encrypt` and `decrypt` are exact inverses.
const std = @import("std");
pub const key_size = 16;pub const Key = [key_size]u8;
/// The cipher's internal state, advanced once per byte.const State = struct { si: u16 = 0, x1a2: u16 = 0, x1a0: [key_size / 2]u16 = @splat(0),
/// All arithmetic here is deliberately wrapping 16-bit, matching the /// original 8086 implementation the algorithm was written against. fn code(self: *State, i: usize) u16 { var dx: u16 = self.x1a2 +% @as(u16, @intCast(i)); var ax: u16 = self.x1a0[i]; var cx: u16 = 0x015a; const bx: u16 = 0x4e35;
std.mem.swap(u16, &ax, &self.si); std.mem.swap(u16, &ax, &dx); if (ax != 0) ax = ax *% bx; std.mem.swap(u16, &ax, &cx); if (ax != 0) { ax = ax *% self.si; cx = cx +% ax; } std.mem.swap(u16, &ax, &self.si); ax = ax *% bx; dx = dx +% cx; ax = ax +% 1;
self.x1a2 = dx; self.x1a0[i] = ax; return ax ^ dx; }
fn assemble(self: *State, key: Key) u16 { self.x1a0[0] = (@as(u16, key[0]) << 8) | key[1]; var inter = self.code(0); for (1..key_size / 2) |i| { self.x1a0[i] = self.x1a0[i - 1] ^ ((@as(u16, key[i * 2]) << 8) | key[i * 2 + 1]); inter ^= self.code(i); } return inter; }};
fn mask(inter: u16) u8 { return @as(u8, @truncate(inter >> 8)) ^ @as(u8, @truncate(inter));}
/// Decrypt `in` into `out`. The two may be the same buffer.pub fn decrypt(out: []u8, in: []const u8, key: Key) void { std.debug.assert(out.len >= in.len); var k = key; var state: State = .{}; for (in, 0..) |byte, i| { const m = mask(state.assemble(k)); const plain = byte ^ m; // The key is folded with the plaintext byte, which is why the two // directions differ only in the order of these two steps. for (&k) |*b| b.* ^= plain; out[i] = plain; }}
/// Encrypt `in` into `out`. The two may be the same buffer.pub fn encrypt(out: []u8, in: []const u8, key: Key) void { std.debug.assert(out.len >= in.len); var k = key; var state: State = .{}; for (in, 0..) |plain, i| { const m = mask(state.assemble(k)); for (&k) |*b| b.* ^= plain; out[i] = plain ^ m; }}
// -- Tests -----------------------------------------------------------------//// The expected values below were produced by compiling libmobi's reference PC1// routines (themselves adapted from Pukall's original) and dumping the output// for these inputs, so they pin this implementation to the reference rather// than to itself.
const testing = std.testing;
const reader_key: Key = .{ 0x72, 0x38, 0x33, 0xb0, 0xb4, 0xf2, 0xe3, 0xca, 0xdf, 0x09, 0x01, 0xd6, 0xe2, 0xe0, 0x3f, 0x96,};const reader_key_v1: Key = "QDCVEPMU675RUBSZ".*;
fn expectHex(expected_hex: []const u8, actual: []const u8) !void { var buf: [256]u8 = undefined; const expected = try std.fmt.hexToBytes(&buf, expected_hex); try testing.expectEqualSlices(u8, expected, actual);}
test "decrypt matches the reference implementation" { var input: [32]u8 = undefined; for (&input, 0..) |*b, i| b.* = @intCast(i);
var out: [32]u8 = undefined; decrypt(&out, &input, reader_key_v1); try expectHex("0c598abfba8c8089d2183684988f0d12ad5e68c20261bfcab0dbb95420478de4", &out);
decrypt(&out, "The Origin of Species............"[0..32], reader_key); try expectHex("ce56fdc0e9152fe7b217a1e6eb54cf112712ddbcdf1a255bb6354da4acd1a537", &out);}
test "encrypt matches the reference implementation" { var input: [32]u8 = undefined; for (&input, 0..) |*b, i| b.* = @intCast(i);
var out: [32]u8 = undefined; encrypt(&out, &input, reader_key); try expectHex("9a2b9df95f69c9da00552dd7dff68ee13e4369366f62c90434dd95b2eeb65d46", &out);}
test "keystream depends on position, not just the key" { // Four identical input bytes must each produce a different output byte. const input = [_]u8{ 0xaa, 0xaa, 0xaa, 0xaa }; var out: [4]u8 = undefined; decrypt(&out, &input, reader_key); try expectHex("303b196e", &out);
// A shorter run must be a prefix of the longer one. var short: [1]u8 = undefined; decrypt(&short, input[0..1], reader_key); try testing.expectEqual(out[0], short[0]);}
test "encrypt and decrypt are inverses" { var key: Key = undefined; var prng = std.Random.DefaultPrng.init(0x5eed); const random = prng.random();
for (0..64) |_| { random.bytes(&key); var plain: [257]u8 = undefined; random.bytes(&plain);
var cipher: [257]u8 = undefined; encrypt(&cipher, &plain, key); try testing.expect(!std.mem.eql(u8, &plain, &cipher));
var round: [257]u8 = undefined; decrypt(&round, &cipher, key); try testing.expectEqualSlices(u8, &plain, &round); }}
test "works in place" { var buf = "in place round trip".*; const original = buf; encrypt(&buf, &buf, reader_key); try testing.expect(!std.mem.eql(u8, &original, &buf)); decrypt(&buf, &buf, reader_key); try testing.expectEqualSlices(u8, &original, &buf);}