@jasy/pdf 1.0.0-alpha.3 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ /** A handler encrypts/decrypts the strings + streams of a document and describes itself as an `/Encrypt` dict. */
2
+ export interface SecurityHandler {
3
+ /** The `/Encrypt` dictionary body (between `<<` and `>>`). Its own strings are never encrypted. */
4
+ encryptDict(): string;
5
+ /** Encrypt one string/stream. `ref` is unused for V5/R6 (one file key) but kept for future per-object schemes. */
6
+ encrypt(data: Uint8Array, ref?: {
7
+ num: number;
8
+ gen: number;
9
+ }): Promise<Uint8Array>;
10
+ /** Decrypt one string/stream (the groundwork the future "open/edit existing PDF" path reuses). */
11
+ decrypt(data: Uint8Array, ref?: {
12
+ num: number;
13
+ gen: number;
14
+ }): Promise<Uint8Array>;
15
+ }
16
+ /** What the user grants; everything defaults to allowed. Maps to the `/P` bitfield (ISO 32000-2 Table 22). */
17
+ export interface Permissions {
18
+ printing?: boolean;
19
+ copying?: boolean;
20
+ modifying?: boolean;
21
+ annotating?: boolean;
22
+ }
23
+ export interface EncryptOptions {
24
+ /** Only "aes-256" today - the seam for future algorithms. */
25
+ algorithm?: "aes-256";
26
+ userPassword: string;
27
+ /** Full-rights password; defaults to the user password if omitted. */
28
+ ownerPassword?: string;
29
+ permissions?: Permissions;
30
+ }
31
+ declare class StandardAes256 implements SecurityHandler {
32
+ private readonly fileKey;
33
+ private readonly dict;
34
+ private constructor();
35
+ static create(opts: EncryptOptions): Promise<StandardAes256>;
36
+ encryptDict(): string;
37
+ encrypt(data: Uint8Array): Promise<Uint8Array>;
38
+ decrypt(data: Uint8Array): Promise<Uint8Array>;
39
+ /** Recover the file key from a password as a reader does: validate it against /U FIRST (so a wrong
40
+ * password is rejected, not silently turned into a garbage key), then decrypt /UE. The groundwork the
41
+ * future "open/edit existing PDF" path reuses. Throws on a wrong password. */
42
+ static recoverFileKey(userPassword: string, u: Uint8Array, ue: Uint8Array): Promise<Uint8Array>;
43
+ }
44
+ /** The factory = the seam. Today it always returns the AES-256 handler. */
45
+ export declare function createSecurityHandler(opts: EncryptOptions): Promise<SecurityHandler>;
46
+ export { StandardAes256 };
@@ -0,0 +1,129 @@
1
+ // PDF Standard security handler. The PDFObjectManager talks to the `SecurityHandler` interface, never to a
2
+ // concrete algorithm - so adding another scheme later (a new revision, or per-object RC4/AES-128) is just a
3
+ // second implementation + a factory branch, with the writer untouched. Today we ship exactly one:
4
+ // `StandardAes256` = AES-256, V5/R6 (ISO 32000-2 / PDF 2.0), the newest standard PDF encryption.
5
+ import { aesCbcDecrypt, aesCbcDecryptNoPad, aesCbcEncrypt, aesCbcEncryptNoPad, randomBytes, sha, } from "./webcrypto.js";
6
+ const utf8 = (s) => new TextEncoder().encode(s).subarray(0, 127); // R6 truncates passwords to 127 bytes
7
+ const concat = (...parts) => {
8
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
9
+ let o = 0;
10
+ for (const p of parts) {
11
+ out.set(p, o);
12
+ o += p.length;
13
+ }
14
+ return out;
15
+ };
16
+ const toHex = (a) => [...a].map((b) => b.toString(16).padStart(2, "0")).join("");
17
+ // Constant-time-ish equality for password validation (no early-out on the first differing byte).
18
+ const bytesEqual = (a, b) => {
19
+ if (a.length !== b.length)
20
+ return false;
21
+ let diff = 0;
22
+ for (let i = 0; i < a.length; i++)
23
+ diff |= a[i] ^ b[i];
24
+ return diff === 0;
25
+ };
26
+ // ISO 32000-2 Algorithm 2.B: the hardened password hash. Loops AES-128-CBC + SHA-256/384/512 until the
27
+ // stopping rule. `udata` is the 48-byte /U for the owner computations, empty for the user ones.
28
+ async function hash2B(password, salt, udata) {
29
+ let k = await sha(256, concat(password, salt, udata));
30
+ let e = new Uint8Array(0);
31
+ // At least 64 rounds; then stop once the last byte of E <= round - 32 (ISO 32000-2 Algorithm 2.B).
32
+ for (let round = 0; round < 64 || e[e.length - 1] > round - 32; round++) {
33
+ const block = concat(password, k, udata);
34
+ const k1 = new Uint8Array(block.length * 64);
35
+ for (let i = 0; i < 64; i++)
36
+ k1.set(block, i * block.length);
37
+ e = await aesCbcEncryptNoPad(k.subarray(0, 16), k.subarray(16, 32), k1);
38
+ let sum = 0;
39
+ for (let i = 0; i < 16; i++)
40
+ sum += e[i]; // big-endian 128-bit mod 3 == byte-sum mod 3 (256 ≡ 1 mod 3)
41
+ k = await sha([256, 384, 512][sum % 3], e);
42
+ }
43
+ return k.subarray(0, 32);
44
+ }
45
+ function permissionBits(p = {}) {
46
+ const { printing = true, copying = true, modifying = true, annotating = true } = p;
47
+ let bits = 0xfffff000 | 0b11000000; // reserved-1 bits (positions 7-8 and 13-32)
48
+ if (printing)
49
+ bits |= 4 | 2048;
50
+ if (modifying)
51
+ bits |= 8 | 1024;
52
+ if (copying)
53
+ bits |= 16 | 512;
54
+ if (annotating)
55
+ bits |= 32 | 256;
56
+ return bits | 0; // int32
57
+ }
58
+ class StandardAes256 {
59
+ constructor(fileKey, dict) {
60
+ this.fileKey = fileKey;
61
+ this.dict = dict;
62
+ }
63
+ static async create(opts) {
64
+ const user = utf8(opts.userPassword);
65
+ const owner = utf8(opts.ownerPassword ?? opts.userPassword);
66
+ const fileKey = randomBytes(32);
67
+ const p = permissionBits(opts.permissions);
68
+ // Algorithm 8: /U and /UE.
69
+ const uVS = randomBytes(8);
70
+ const uKS = randomBytes(8);
71
+ const empty = new Uint8Array(0);
72
+ const u = concat(await hash2B(user, uVS, empty), uVS, uKS); // 48 bytes
73
+ const ue = await aesCbcEncryptNoPad(await hash2B(user, uKS, empty), new Uint8Array(16), fileKey);
74
+ // Algorithm 9: /O and /OE (these also fold in /U).
75
+ const oVS = randomBytes(8);
76
+ const oKS = randomBytes(8);
77
+ const o = concat(await hash2B(owner, oVS, u), oVS, oKS); // 48 bytes
78
+ const oe = await aesCbcEncryptNoPad(await hash2B(owner, oKS, u), new Uint8Array(16), fileKey);
79
+ // Algorithm 10: /Perms (a 16-byte block, ECB == single-block CBC with a zero IV).
80
+ const perms = new Uint8Array(16);
81
+ perms[0] = p & 0xff;
82
+ perms[1] = (p >> 8) & 0xff;
83
+ perms[2] = (p >> 16) & 0xff;
84
+ perms[3] = (p >> 24) & 0xff;
85
+ perms[4] = perms[5] = perms[6] = perms[7] = 0xff;
86
+ perms[8] = 0x46; // 'F' - metadata (XMP) stays unencrypted (standard; keeps it indexer-readable)
87
+ perms[9] = 0x61; // 'a'
88
+ perms[10] = 0x64; // 'd'
89
+ perms[11] = 0x62; // 'b'
90
+ perms.set(randomBytes(4), 12);
91
+ const permsEnc = await aesCbcEncryptNoPad(fileKey, new Uint8Array(16), perms);
92
+ const dict = `/Filter /Standard /V 5 /R 6 /Length 256 /P ${p} /EncryptMetadata false ` +
93
+ `/CF << /StdCF << /CFM /AESV3 /AuthEvent /DocOpen /Length 32 >> >> /StmF /StdCF /StrF /StdCF ` +
94
+ `/U <${toHex(u)}> /UE <${toHex(ue)}> /O <${toHex(o)}> /OE <${toHex(oe)}> /Perms <${toHex(permsEnc)}>`;
95
+ return new StandardAes256(fileKey, dict);
96
+ }
97
+ encryptDict() {
98
+ return this.dict;
99
+ }
100
+ async encrypt(data) {
101
+ const iv = randomBytes(16);
102
+ const ct = await aesCbcEncrypt(this.fileKey, iv, data);
103
+ return concat(iv, ct);
104
+ }
105
+ async decrypt(data) {
106
+ return aesCbcDecrypt(this.fileKey, data.subarray(0, 16), data.subarray(16));
107
+ }
108
+ /** Recover the file key from a password as a reader does: validate it against /U FIRST (so a wrong
109
+ * password is rejected, not silently turned into a garbage key), then decrypt /UE. The groundwork the
110
+ * future "open/edit existing PDF" path reuses. Throws on a wrong password. */
111
+ static async recoverFileKey(userPassword, u, ue) {
112
+ const pw = utf8(userPassword);
113
+ // Algorithm 11: hash(password + validation salt) must equal the first 32 bytes of /U.
114
+ const check = await hash2B(pw, u.subarray(32, 40), new Uint8Array(0));
115
+ if (!bytesEqual(check, u.subarray(0, 32))) {
116
+ throw new Error("@jasy/pdf: wrong password.");
117
+ }
118
+ const intermediate = await hash2B(pw, u.subarray(40, 48), new Uint8Array(0));
119
+ return aesCbcDecryptNoPad(intermediate, new Uint8Array(16), ue);
120
+ }
121
+ }
122
+ /** The factory = the seam. Today it always returns the AES-256 handler. */
123
+ export async function createSecurityHandler(opts) {
124
+ if (opts.algorithm && opts.algorithm !== "aes-256") {
125
+ throw new Error(`@jasy/pdf: unsupported encryption algorithm "${opts.algorithm}" (only "aes-256").`);
126
+ }
127
+ return StandardAes256.create(opts);
128
+ }
129
+ export { StandardAes256 };
@@ -0,0 +1,11 @@
1
+ export declare function randomBytes(n: number): Uint8Array;
2
+ export declare function sha(bits: 256 | 384 | 512, data: Uint8Array): Promise<Uint8Array>;
3
+ /** AES-CBC with PKCS#7 padding (the AESV3 mode for PDF strings/streams). */
4
+ export declare function aesCbcEncrypt(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
5
+ export declare function aesCbcDecrypt(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
6
+ /** AES-CBC WITHOUT padding (`data.length` must be a multiple of 16). WebCrypto pads unconditionally, so we
7
+ * encrypt and drop the extra full pad block - the leading blocks are the true unpadded CBC. */
8
+ export declare function aesCbcEncryptNoPad(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
9
+ /** AES-CBC no-padding DECRYPT (`data` a multiple of 16). We append one cipher block crafted to decrypt to a
10
+ * full 0x10 PKCS#7 pad (so WebCrypto accepts and strips it), leaving the original. */
11
+ export declare function aesCbcDecryptNoPad(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,62 @@
1
+ // Isomorphic crypto via the platform WebCrypto (`globalThis.crypto.subtle`) - native in the browser AND in
2
+ // Node 20+, with zero dependencies. This is the primitive layer the PDF Standard security handler (AES-256,
3
+ // R6) builds on. Everything is async (so is `render()`), so that is no constraint.
4
+ //
5
+ // One wrinkle: WebCrypto's AES-CBC ALWAYS applies PKCS#7 padding and offers no raw/no-pad mode. The PDF R6
6
+ // key derivation (Algorithm 2.B, /UE, /OE, /Perms) needs UNPADDED AES, so we synthesize it from the padded
7
+ // primitive (encrypt: drop the trailing pad block; decrypt: append a block that decrypts to a full pad, then
8
+ // let WebCrypto strip it). Document strings/streams use the padded primitive directly - that IS AESV3.
9
+ const subtle = () => {
10
+ const c = globalThis.crypto;
11
+ if (!c?.subtle) {
12
+ throw new Error("@jasy/pdf: PDF encryption needs WebCrypto (globalThis.crypto.subtle), unavailable here.");
13
+ }
14
+ return c.subtle;
15
+ };
16
+ export function randomBytes(n) {
17
+ const b = new Uint8Array(n);
18
+ globalThis.crypto.getRandomValues(b);
19
+ return b;
20
+ }
21
+ // WebCrypto's types want a BufferSource backed by ArrayBuffer; our Uint8Arrays are ArrayBuffer-backed, so
22
+ // this cast at the platform boundary is safe (TS 5.7+ just can't prove it isn't a SharedArrayBuffer).
23
+ const buf = (a) => a;
24
+ export async function sha(bits, data) {
25
+ return new Uint8Array(await subtle().digest(`SHA-${bits}`, buf(data)));
26
+ }
27
+ async function aesKey(key, usage) {
28
+ return subtle().importKey("raw", buf(key), { name: "AES-CBC" }, false, [usage]);
29
+ }
30
+ /** AES-CBC with PKCS#7 padding (the AESV3 mode for PDF strings/streams). */
31
+ export async function aesCbcEncrypt(key, iv, data) {
32
+ return new Uint8Array(await subtle().encrypt({ name: "AES-CBC", iv: buf(iv) }, await aesKey(key, "encrypt"), buf(data)));
33
+ }
34
+ export async function aesCbcDecrypt(key, iv, data) {
35
+ return new Uint8Array(await subtle().decrypt({ name: "AES-CBC", iv: buf(iv) }, await aesKey(key, "decrypt"), buf(data)));
36
+ }
37
+ /** AES-CBC WITHOUT padding (`data.length` must be a multiple of 16). WebCrypto pads unconditionally, so we
38
+ * encrypt and drop the extra full pad block - the leading blocks are the true unpadded CBC. */
39
+ export async function aesCbcEncryptNoPad(key, iv, data) {
40
+ if (data.length % 16 !== 0)
41
+ throw new Error("aesCbcEncryptNoPad: data must be a multiple of 16 bytes.");
42
+ return (await aesCbcEncrypt(key, iv, data)).subarray(0, data.length);
43
+ }
44
+ const xor16 = (a, b) => {
45
+ const o = new Uint8Array(16);
46
+ for (let i = 0; i < 16; i++)
47
+ o[i] = a[i] ^ b[i];
48
+ return o;
49
+ };
50
+ /** AES-CBC no-padding DECRYPT (`data` a multiple of 16). We append one cipher block crafted to decrypt to a
51
+ * full 0x10 PKCS#7 pad (so WebCrypto accepts and strips it), leaving the original. */
52
+ export async function aesCbcDecryptNoPad(key, iv, data) {
53
+ if (data.length === 0 || data.length % 16 !== 0)
54
+ throw new Error("aesCbcDecryptNoPad: data must be a non-zero multiple of 16 bytes.");
55
+ const prev = data.length >= 16 ? data.subarray(data.length - 16) : iv;
56
+ // ECB(x) == first block of CBC(iv=0, x); we want the appended block to decrypt to 0x10*16 after the CBC xor.
57
+ const appended = await aesCbcEncryptNoPad(key, new Uint8Array(16), xor16(new Uint8Array(16).fill(16), prev));
58
+ const withPad = new Uint8Array(data.length + 16);
59
+ withPad.set(data);
60
+ withPad.set(appended, data.length);
61
+ return aesCbcDecrypt(key, iv, withPad);
62
+ }
@@ -74,6 +74,8 @@ export class PDFRenderer {
74
74
  // Now that the render pass has revealed which glyphs each embedded font uses, fill the reserved
75
75
  // font objects with the subset font program (must happen before the objects are serialized).
76
76
  objectManager.finalizeCustomFonts();
77
+ // Encrypt every registered stream + add the /Encrypt object (no-op unless a security handler was set).
78
+ await objectManager.finalizeEncryption();
77
79
  // Add rendered objects
78
80
  pdfContent += objectManager.getRenderedObjects();
79
81
  // Add XRef table and trailer
package/dist/utils/md5.js CHANGED
@@ -4,10 +4,9 @@
4
4
  // matching `hash.update(string)`'s default encoding.
5
5
  // Per-round left-rotate amounts.
6
6
  const S = [
7
- 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
8
- 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
9
- 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
10
- 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
7
+ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
8
+ 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6,
9
+ 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
11
10
  ];
12
11
  // Per-round constants K[i] = floor(abs(sin(i+1)) * 2^32).
13
12
  const K = new Int32Array(64);
@@ -57,7 +56,7 @@ export function md5(input) {
57
56
  A = D;
58
57
  D = C;
59
58
  C = B;
60
- B = (B + (((f << S[i]) | (f >>> (32 - S[i]))) | 0)) | 0;
59
+ B = (B + ((f << S[i]) | (f >>> (32 - S[i])) | 0)) | 0;
61
60
  }
62
61
  a0 = (a0 + A) | 0;
63
62
  b0 = (b0 + B) | 0;
@@ -1,4 +1,5 @@
1
1
  import type { OverflowPolicy } from "../layout/fragmentation.js";
2
+ import type { SecurityHandler } from "../crypto/security-handler.js";
2
3
  import type { PDFConfig } from "../renderer/pdf-document-class.js";
3
4
  import type { FontMetrics } from "./font-metrics.js";
4
5
  interface FontIndexes {
@@ -32,12 +33,17 @@ export declare class PDFObjectManager implements FontMetrics {
32
33
  private documentId;
33
34
  private compress;
34
35
  private overflowPolicy;
36
+ private security?;
37
+ private encJobs;
38
+ private encryptObjNum?;
35
39
  constructor();
36
40
  addObject(content: string): number;
37
41
  replaceObject(objectNumber: number, content: string): void;
38
42
  setCompress(on: boolean): void;
39
43
  setOverflowPolicy(policy: OverflowPolicy): void;
40
44
  getOverflowPolicy(): OverflowPolicy;
45
+ private encToken;
46
+ private streamPayload;
41
47
  private stream;
42
48
  addContentStream(content: string): number;
43
49
  changePDFConfig(config: PDFConfig): void;
@@ -67,6 +73,8 @@ export declare class PDFObjectManager implements FontMetrics {
67
73
  getPdfVersion(): string;
68
74
  getHeader(): string;
69
75
  enableDocumentId(): void;
76
+ setSecurityHandler(handler: SecurityHandler): void;
77
+ finalizeEncryption(): Promise<void>;
70
78
  registerExtGState(fillAlpha: number, strokeAlpha: number): string;
71
79
  getAllExtGStatesRaw(): Map<string, number>;
72
80
  registerFont(fontName: string, fontStyle?: FontStyle, fullName?: string): FontIndexes;
Binary file
@@ -3,7 +3,7 @@
3
3
  // the `glyf` outlines of unused glyphs (the bulk of the file) and rebuild `loca`; every other table
4
4
  // is copied verbatim. The used-glyph set is closed over composite-glyph components first, so a glyph
5
5
  // built from others (e.g. "ä") keeps its parts. Fonts without `glyf`/`loca` (CFF/OTF) pass through.
6
- import { concatBytes, i16, latin1FromBytes, u16, u32, wi16, wu16, wu32, writeLatin1 } from "./bytes.js";
6
+ import { concatBytes, i16, latin1FromBytes, u16, u32, wi16, wu16, wu32, writeLatin1, } from "./bytes.js";
7
7
  const wrap32 = (n) => n >>> 0;
8
8
  /** TrueType table checksum: sum of big-endian uint32 over the 4-byte-padded table data. */
9
9
  function checksum(buf) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Declarative, component-based PDF generation in pure TypeScript - Flutter-style components, real AFM font metrics, a hand-rolled PDF writer. No headless browser, no Java.",
5
5
  "keywords": [
6
6
  "declarative",