Skip to content
getnextpdf.com

Enterprise edition

Steganography — Deep Reference

This deep reference documents the NextPDF Enterprise steganographic channel. The channel hides an encrypted payload inside the numeric kern adjustments of a TJ text-showing array. It has four public symbols: SteganographyEncoder, SteganographyDecoder, SteganographyConfig, and SteganographyCapacity. The encoder derives a key with HKDF-SHA-256, encrypts the payload with an AEAD cipher, and returns per-position kern offsets. The decoder reverses the process from observed adjustments or from a raw content stream.

The channel is designed for internal document leak tracing. It is not adversarial-grade steganography. Encoded data may be destroyed by print-then-scan, PDF conversion, re-linearization, content-stream rewriting, or any operation that normalizes kerning.

This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.

The channel exposes four final classes. All entry points are public static, except the SteganographyConfig constructor and its effectiveMaxOffset accessor. The supporting NextPDF\Enterprise\Security\Steganography\SteganographyEncryptionException is thrown by the encoder; it is not a caller-constructed type.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
SteganographyEncoder::encode$payload, $text, $fontKey, $metrics (FontMetrics), $secretKey, $config (SteganographyConfig)Empty $payload returns []; asserts key strength; encrypts; computes per-position kern offsets.array<int, float> (position => adjustment in 1/1000 em, AFM convention)InvalidArgumentException (key below floor); OverflowException (text under 2 characters, or payload over capacity); SteganographyEncryptionException (AEAD failure)Pass the result to NextPDF\Content\TextRenderer::buildTjArrayOperator(). The API returns AFM-convention adjustments; buildTjArrayOperator() performs the PDF TJ numeric conversion (ISO 32000-2 subtracts the number from the current position). Manual content-stream writers must preserve that sign convention.
SteganographyEncoder::assertSecretKeyStrength$secretKeyRejects a key shorter than the floor.voidInvalidArgumentException (key below floor)Shared write-path guard, mirrored on the read path.
SteganographyEncoder::MIN_SECRET_KEY_LENGTHconstantThe 128-bit key-length floor in bytes.int (16)Not applicableThe library enforces length, not entropy.
SteganographyDecoder::decode$observedAdjustments, $text, $fontKey, $metrics (FontMetrics), $secretKey, $config (SteganographyConfig)Asserts key strength; quantizes deviations; rebuilds the blob; AEAD-decrypts.`stringnull(payload, ornull` on wrong key or no payload)InvalidArgumentException (key below floor)
SteganographyDecoder::decodeFromContentStream$contentStream, $fontKey, $metrics (FontMetrics), $secretKey, $config (SteganographyConfig)Tokenizes the stream, reconstructs text and adjustments from TJ arrays, then delegates to decode.`stringnull(payload, ornullwhen noTJ` text or decryption fails)InvalidArgumentException (key below floor, via decode)
SteganographyConfig::__construct$bitDepth, $maxAdjustmentEmRatio, $cipher, $requirePdfACompatibilityValidates each argument domain; produces an immutable value object.SteganographyConfig instanceInvalidArgumentException (invalid $bitDepth, $maxAdjustmentEmRatio, or $cipher)readonly class; the four arguments are public promoted properties.
SteganographyConfig::effectiveMaxOffsetnoneReturns $maxAdjustmentEmRatio * 1000, halved when PDF/A compatibility is requested.float (offset in 1/1000 em)Not applicableThe halving reduces width-mismatch detection risk.
SteganographyConfig::CRYPTO_OVERHEADconstantThe fixed per-payload encryption overhead in bytes.int (32)Not applicable4-byte length, 12-byte nonce, 16-byte tag.
SteganographyCapacity::calculate$text, $config (SteganographyConfig)Computes usable payload bytes for the text, after overhead.int (0 when the text is too short)Not applicableCapacity is positions * bitDepth / 8 minus overhead.
SteganographyCapacity::minimumTextLength$payloadBytes, $config (SteganographyConfig)Computes the minimum UTF-8 character count for a payload.int (character count)Not applicableInverse of calculate.

The verbatim signatures follow, each with source provenance.

public static function encode(
string $payload,
string $text,
string $fontKey,
FontMetrics $metrics,
string $secretKey,
SteganographyConfig $config = new SteganographyConfig(),
): array
public static function assertSecretKeyStrength(string $secretKey): void
public const int MIN_SECRET_KEY_LENGTH = 16;
public static function decode(
array $observedAdjustments,
string $text,
string $fontKey,
FontMetrics $metrics,
string $secretKey,
SteganographyConfig $config = new SteganographyConfig(),
): ?string
public static function decodeFromContentStream(
string $contentStream,
string $fontKey,
FontMetrics $metrics,
string $secretKey,
SteganographyConfig $config = new SteganographyConfig(),
): ?string
public function __construct(
public int $bitDepth = 1,
public float $maxAdjustmentEmRatio = 0.02,
public string $cipher = 'aes-256-gcm',
public bool $requirePdfACompatibility = false,
)
public function effectiveMaxOffset(): float
public const int CRYPTO_OVERHEAD = 32;
public static function calculate(
string $text,
SteganographyConfig $config = new SteganographyConfig(),
): int
public static function minimumTextLength(
int $payloadBytes,
SteganographyConfig $config = new SteganographyConfig(),
): int

The encoder splits $text into UTF-8 characters and forms one position per consecutive character pair. Each position carries $config->bitDepth bits, which is one or two. The payload is first encrypted, then serialized to a blob, then converted to a bit sequence. Each position encodes its bits as a small non-negative offset added to the natural kern value for that character pair.

The offset is a fraction of the effective maximum offset. The effective maximum offset is $maxAdjustmentEmRatio * 1000 design units, halved when $requirePdfACompatibility is true. The natural kern is read from $metrics through FontMetrics::getKernPair. The returned map is sparse: a position whose final adjustment is exactly zero is omitted.

Encryption uses HKDF-SHA-256 to derive a 32-byte key. The HKDF salt is the non-secret $fontKey and the info label is a fixed constant. Therefore the caller’s $secretKey is the sole confidentiality boundary. The AEAD cipher is aes-256-gcm or chacha20-poly1305, selected by $config->cipher, run through openssl_encrypt with a fresh 12-byte nonce and a 16-byte tag. The serialized blob is a 4-byte big-endian length, the 12-byte nonce, the ciphertext, and the 16-byte tag; this fixed overhead is CRYPTO_OVERHEAD, which is 32 bytes.

The decoder reverses the transform. It computes the deviation of each observed adjustment from the natural kern, normalizes by the effective maximum offset, and quantizes to the nearest level. It reassembles the blob, validates the length header, and calls openssl_decrypt. A wrong key, a missing payload, or corrupted adjustments cause the AEAD authentication to fail, and the decoder returns null. decodeFromContentStream first tokenizes the raw stream with NextPDF\Pro\Projection\ContentProjectionWriter::tokenize, reconstructs the text and the numeric adjustments from each TJ array, and then delegates to decode.

SteganographyCapacity::calculate reports the usable payload size for a text and configuration, after subtracting CRYPTO_OVERHEAD; it returns zero when the text is too short. SteganographyCapacity::minimumTextLength is the inverse: the smallest UTF-8 character count that admits a payload of the requested size.

  • An empty $payload returns an empty map from encode; no bytes are written, and the key-strength guard is not reached.
  • For a non-empty payload, a $text with fewer than two characters raises OverflowException in encode (an empty payload short-circuits to [] before the length check); the same text yields null in decode and zero in SteganographyCapacity::calculate.
  • A $payload larger than the text capacity raises OverflowException before any adjustment is emitted.
  • A $secretKey shorter than MIN_SECRET_KEY_LENGTH (16 bytes) raises InvalidArgumentException on both the write and the read path. This is a contract violation, distinct from a normal wrong-key miss.
  • A wrong key, a corrupted adjustment set, or a truncated blob causes decode to return null through AEAD authentication failure, not an exception.
  • Positions absent from a sparse $observedAdjustments map are treated as a zero deviation during extraction.
  • decodeFromContentStream returns null when the stream contains no TJ text.
  • The channel is fragile by design. Print-then-scan, PDF conversion, re-linearization, content-stream rewriting, or kerning normalization can destroy the encoded data. It is unsuitable for adversarial or archival use.

The channel uses HKDF-SHA-256 for key derivation and one AEAD cipher for confidentiality and integrity. The module does not enforce a FIPS profile; cipher selection is the caller’s decision through $config->cipher. aes-256-gcm is AES in Galois/Counter Mode, an authenticated-encryption mode built on an approved 128-bit block cipher whose conformance is validated under the CMVP, per NIST SP 800-38D §2. chacha20-poly1305 is not defined by a NIST mode-of-operation recommendation, so a FIPS-constrained OpenSSL provider rejects it; openssl_encrypt then returns false and the encoder raises SteganographyEncryptionException. Whether a deployment meets a FIPS requirement is the operator’s determination against its validated provider.

The embedding writes numeric elements into a TJ text-showing array. Per ISO 32000-2:2020 §9.4.3, a TJ array shows text and lets a numeric element adjust the glyph position; the number is expressed in thousandths of a text-space unit and is subtracted from the current position. After a glyph is painted, the text matrix is translated by the combined displacement, so a positioning number shifts the placement of subsequent glyphs — ISO 32000-2:2020 §9.4.4. The channel adds its offsets to the natural kern values in the same 1/1000 em (AFM) convention, where a negative value tightens spacing.

The AEAD grounding is limited to primitive selection: aes-256-gcm corresponds to the GCM mode of NIST SP 800-38D §2, which identifies the algorithm.

All clauses are paraphrased. Structural alignment with the TJ positioning model is a capability statement. The channel is designed for internal leak tracing.

  • Entry points are public static methods in NextPDF\Enterprise\Security\Steganography, except the SteganographyConfig constructor and effectiveMaxOffset.
  • SteganographyConfig is a final readonly value object. Its four properties are immutable after construction, and its argument domains are validated in the constructor: $bitDepth is 1 or 2, $maxAdjustmentEmRatio is in (0, 0.05], and $cipher is aes-256-gcm or chacha20-poly1305.
  • The encode output is consumed by NextPDF\Content\TextRenderer::buildTjArrayOperator. Kern pairs come from NextPDF\Typography\FontMetrics. Content-stream decoding reads through NextPDF\Pro\Projection\ContentProjectionWriter and does not mutate the stream.
  • The key-length floor is enforced at the entry point and re-asserted at the private crypto boundary, so no internal path can reach HKDF with a weak key. The library enforces length, not entropy; supplying high-entropy key material is the integrator’s responsibility.
  • CRYPTO_OVERHEAD (32 bytes) is the fixed cost per payload and is already subtracted by SteganographyCapacity::calculate.
  • The documented since is 3.1.0 for the aggregated Enterprise surface. SteganographyEncryptionException extends RuntimeException, so call sites that catch the generic runtime type continue to work.

This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.