synology_apm_repo.sdk.format.crypto module

Chunk and key-material cryptography (FORMAT-SPEC.md: key-hierarchy, chunk-pool-encryption, vaultkey-custody, aHlT).

Three independent AES schemes, never confused with each other:

  • Chunk pool (.buk): AES-256-CTR, key = vaultKey (the DEK), IV derived from each chunk’s own ChunkAddress — never random, never stored, recomputed identically on every read (chunk-pool-encryption).

  • Wrapped VaultKey: AES-256-GCM, key = userKey (the KEK), nonce = the first 12 ASCII bytes of userKeyID (vaultkey-custody) — this is the only place a nonce is explicit rather than derived, and it doubles as an integrity check (a wrong key/nonce pair fails the GCM tag outright).

  • copy_meta_file’s aHlT envelope reuses the chunk-pool DEK (vaultKey) but with AES-256-CTR and an IV stored in its own header, not derived (aHlT).

  • db/copy_target_version.version_spec: AES-256-CTR, same DEK (vaultKey) again, but its own derived-not-stored IV, taken from the first 16 ASCII bytes of hex(MD5(version_uid)) rather than the raw MD5 digest (version-spec-encryption).

Nothing here decides whether something is encrypted — that is always a mode-bit or magic-byte check made by the caller; this module only ever runs once the caller has already decided encryption applies.

synology_apm_repo.sdk.format.crypto.NO_ENCRYPTION_USER_KEY_ID = 'NoEncryption'

user_key_id value marking a vault that was never encrypted.

synology_apm_repo.sdk.format.crypto.chunk_iv(addr)

16-byte AES-CTR IV for one chunk: its own 64-bit ChunkAddress, big-endian, repeated twice (FORMAT-SPEC.md: chunk-pool-encryption).

synology_apm_repo.sdk.format.crypto.decrypt_chunk(vault_key, addr, ciphertext)

AES-256-CTR-decrypt one chunk’s ciphertext. Does not decompress — see compression for that.

ciphertext accepts a memoryview as well as bytes — a caller slicing chunk ciphertext straight out of a merged run’s I/O buffer can pass a view through without copying, since Cipher.decryptor().update() reads it via the buffer protocol and always allocates its own fresh plaintext output either way.

One ``Cipher`` per call, deliberately. Each chunk’s counter starts from its own independently derived chunk_iv, not from where the previous chunk’s update() left the running counter, so streaming many chunks through one decryptor would silently produce wrong plaintext after the first. Batching the Cipher construction itself is not a win with AES-NI either: chunk ciphertext lengths vary (compressed sizes, not a fixed 4096), so building a batch’s counter blocks needs a Python-level loop whose own overhead dwarfs whatever construction cost it would save. The algorithms.AES key-schedule half is safe to reuse across chunks — see _aes_algorithm.

synology_apm_repo.sdk.format.crypto.parse_key_string(key_string)

Split an administrator-provided key string "<userKeyID>@<base64(userKey)>" (FORMAT-SPEC.md: key-hierarchy) into (user_key_id, user_key). Splits on the last @ per spec, and validates user_key_id is exactly 12 characters and user_key decodes to exactly 32 raw bytes.

Does not special-case "NoEncryption" — that’s a caller-level (KeyMaterial) decision about whether a key is needed, not a string-parsing concern.

synology_apm_repo.sdk.format.crypto.unwrap_vault_key(user_key_id, user_key, wrapped)

AES-256-GCM-unwrap a 48-byte wrapped VaultKey (ciphertext(32) || tag(16)) using userKey and a nonce built from userKeyID’s first 12 ASCII bytes (FORMAT-SPEC.md: vaultkey-custody).

A successful unwrap already proves the (userKeyID, userKey) pair is correct for this repository’s real data too, not merely for this stored record in isolation — the DEK it unwraps never changes after first initialization.

Raises:

KeyMismatchError – The GCM tag check fails — proof the (userKeyID, userKey) pair does not match.

synology_apm_repo.sdk.format.crypto.ahlt_decrypt(data, vault_key)

Decrypt a copy_meta_file entry’s aHlT-enveloped bytes: 64-byte header (the generic IndexHeader shell, IV at [8,24)) followed by AES-256-CTR ciphertext from byte 64 onward (FORMAT-SPEC.md: aHlT). Returns the plaintext body only (header stripped).

The IV here is stored in the header, unlike a pool chunk’s address-derived IV — do not reuse chunk_iv for this envelope.

synology_apm_repo.sdk.format.crypto.version_spec_iv(version_uid)

16-byte AES-CTR IV for one copy_target_version row’s own version_spec column: the first 16 ASCII bytes of the hex string hex(MD5(version_uid)) — not the raw 16-byte MD5 digest itself. MD5’s raw digest is also exactly 16 bytes, which makes grabbing it directly (instead of its hex text) an easy trap: the on-disk format takes the hex string and copies its first 16 characters, never the raw digest bytes (FORMAT-SPEC.md: version-spec-encryption).

synology_apm_repo.sdk.format.crypto.decrypt_version_spec(ciphertext_b64, version_uid, vault_key)

Decrypt one copy_target_version.version_spec column: AES-256-CTR, same DEK as chunk-pool/aHlT (vault_key), IV from version_spec_iv. Plain standard base64 text, no header/magic bytes to sniff — whether to call this at all is the caller’s own decision (repo.vault_key is not None), same “no guessing” rule as this module’s other schemes (FORMAT-SPEC.md: version-spec-encryption).

Returns the decoded UTF-8 plaintext (a JSON string) — raises UnicodeDecodeError/binascii.Error on a wrong key or corrupt input rather than returning garbage silently; callers decide how to degrade.