using System.IO.Compression; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Encryption; namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization; /// /// Packs + into a /// ZIP archive (manifest.json always, plus either content.json /// or content.enc) and reverses the operation on read. /// /// When packing with a passphrase, the caller must have already encrypted the /// content bytes upstream and built a manifest whose /// + ContentHash describe those exact bytes. uses the /// manifest as source of truth: it re-serializes the content (or re-encrypts /// with the manifest's salt/iv when needed) so the zip stays internally /// consistent. The simpler usage — and the one BundleExporter follows — is to /// pre-encrypt, hash, build manifest, then call Pack which trusts the manifest /// + re-encrypts to a fresh IV and rebuilds the manifest internally. /// public sealed class BundleSerializer { private const string ManifestEntryName = "manifest.json"; private const string ContentJsonEntryName = "content.json"; private const string ContentEncEntryName = "content.enc"; // All bundle content serialization goes through BundleJsonOptions.Default — // see that class for the rationale (WhenWritingNull + unknown-member tolerance). private static readonly JsonSerializerOptions JsonOptions = BundleJsonOptions.Default; /// /// Serializes the bundle content to its canonical JSON byte form. Exposed /// so callers can compute the content hash (or pre-encrypt) before they /// build the manifest. /// /// The bundle content DTO to serialize. /// The UTF-8 JSON byte representation of . public byte[] SerializeContentBytes(BundleContentDto content) { ArgumentNullException.ThrowIfNull(content); return JsonSerializer.SerializeToUtf8Bytes(content, JsonOptions); } /// /// Packs bundle content and manifest into a ZIP stream, optionally encrypting the content with the supplied passphrase. /// /// The bundle content to pack. /// The bundle manifest describing the content and encryption metadata. /// Passphrase used for encryption; null produces a plaintext bundle. /// Encryptor required when is supplied. /// A seeked-to-start containing the bundle ZIP archive. public Stream Pack( BundleContentDto content, BundleManifest manifest, string? passphrase, BundleSecretEncryptor? encryptor) { ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(manifest); if (passphrase is not null && encryptor is null) { throw new ArgumentException("Encryptor is required when a passphrase is supplied.", nameof(encryptor)); } if (passphrase is not null && manifest.Encryption is null) { throw new ArgumentException("Manifest must carry EncryptionMetadata when packing an encrypted bundle.", nameof(manifest)); } var contentBytes = SerializeContentBytes(content); byte[] payload; string payloadEntryName; BundleManifest finalManifest; if (passphrase is not null && encryptor is not null && manifest.Encryption is not null) { // Fresh encryption — re-derives salt/iv. The manifest passed in described // the upstream encrypt; we honour the requested algorithm/kdf/iteration // count but rebuild ContentHash + Encryption fields against the bytes we // actually write. The non-encryption manifest fields (source env, exported // by, summary, contents, version) are preserved verbatim. // // T-005: bind the manifest's non-derivative fields into the AES-GCM AAD // so a tampered SourceEnvironment / ExportedBy / etc. on a stolen bundle // yields an authentication-tag mismatch on decrypt instead of a forged // origin label slipping past the Step-4 confirmation gate. AAD is // computed over a manifest normalised to empty ContentHash + null // Encryption (those fields are derivative of the ciphertext / IV and // cannot themselves be authenticated). var aad = BundleManifestAad.Compute(manifest); var (cipher, freshMeta) = encryptor.Encrypt( contentBytes, passphrase, manifest.Encryption.Iterations, aad); payload = cipher; payloadEntryName = ContentEncEntryName; finalManifest = manifest with { Encryption = freshMeta, ContentHash = "sha256:" + Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(cipher)).ToLowerInvariant(), }; } else { payload = contentBytes; payloadEntryName = ContentJsonEntryName; // Plaintext path: trust the supplied manifest's ContentHash. The caller // built it via ManifestBuilder against the same SerializeContentBytes // output (JSON is deterministic for a given DTO + JsonOptions). finalManifest = manifest; } var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(finalManifest, JsonOptions); var ms = new MemoryStream(); using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { WriteEntry(archive, ManifestEntryName, manifestBytes); WriteEntry(archive, payloadEntryName, payload); } ms.Position = 0; return ms; } /// /// Reads and deserializes the manifest entry from a bundle ZIP stream. /// /// The ZIP stream containing the bundle. /// The deserialized from the bundle. public BundleManifest ReadManifest(Stream zipStream) { ArgumentNullException.ThrowIfNull(zipStream); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); var entry = archive.GetEntry(ManifestEntryName) ?? throw new InvalidDataException("Bundle is missing manifest.json."); using var es = entry.Open(); var manifest = JsonSerializer.Deserialize(es, JsonOptions) ?? throw new InvalidDataException("manifest.json deserialized to null."); return manifest; } /// /// Returns the raw bytes from content.enc if present, otherwise from /// content.json. The caller decrypts + JSON-deserializes via /// . /// /// The ZIP stream containing the bundle. /// The bundle manifest used to determine which content entry to read. /// The raw bytes from the content entry (content.enc or content.json). public byte[] ReadContentBytes(Stream zipStream, BundleManifest manifest) { ArgumentNullException.ThrowIfNull(zipStream); ArgumentNullException.ThrowIfNull(manifest); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); // Prefer the encrypted variant when both are somehow present. var entry = archive.GetEntry(ContentEncEntryName) ?? archive.GetEntry(ContentJsonEntryName) ?? throw new InvalidDataException("Bundle is missing both content.enc and content.json."); using var es = entry.Open(); using var ms = new MemoryStream(); es.CopyTo(ms); return ms.ToArray(); } /// /// Decrypts (if encrypted) and deserializes raw content bytes into a . /// /// Raw bytes read from the bundle ZIP entry. /// The bundle manifest providing encryption metadata. /// Passphrase for decryption; required when the manifest indicates encryption. /// Encryptor used for decryption; required when the manifest indicates encryption. /// The deserialized from the plaintext content. public BundleContentDto UnpackContent( byte[] contentBytes, BundleManifest manifest, string? passphrase, BundleSecretEncryptor? encryptor) { ArgumentNullException.ThrowIfNull(contentBytes); ArgumentNullException.ThrowIfNull(manifest); byte[] plaintext; if (manifest.Encryption is not null) { if (passphrase is null || encryptor is null) { throw new ArgumentException("Encrypted bundle requires both passphrase and encryptor."); } // T-005: mirror the encrypt side — AAD is derived from the manifest's // non-derivative fields. Tampering yields an authentication-tag mismatch. var aad = BundleManifestAad.Compute(manifest); plaintext = encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad); } else { plaintext = contentBytes; } var content = JsonSerializer.Deserialize(plaintext, JsonOptions) ?? throw new InvalidDataException("content payload deserialized to null."); return content; } private static void WriteEntry(ZipArchive archive, string entryName, byte[] payload) { var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); using var es = entry.Open(); es.Write(payload, 0, payload.Length); } }