diff --git a/SKILL.md b/SKILL.md index 34464818b..d18ae607c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -97,6 +97,8 @@ officecli query # CSS-like query officecli validate # Validate against OpenXML schema ``` +**Encrypted files:** add `--password ` (or set `OFFICECLI_PASSWORD`) to any command (Agile/Office-2013+ only; reads decrypt, edits re-save still-encrypted). Missing → `password_required`. + ### view modes | Mode | Description | Useful flags | diff --git a/src/officecli/CommandBuilder.Add.cs b/src/officecli/CommandBuilder.Add.cs index 3e0adbda9..4328e3503 100644 --- a/src/officecli/CommandBuilder.Add.cs +++ b/src/officecli/CommandBuilder.Add.cs @@ -147,7 +147,7 @@ private static Command BuildAddCommand(Option jsonOption) if (position?.Before != null) req.Args["before"] = position.Before; }, json) is {} rc) return rc != 0 ? rc : (hadWarnings ? 2 : 0); - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0; var resultPath = handler.CopyFrom(from, parentPath, position); var message = $"Copied to {resultPath}"; @@ -186,7 +186,7 @@ private static Command BuildAddCommand(Option jsonOption) // CONSISTENCY(schema-prop-validation): same approach mirrored // in ResidentServer.ExecuteAdd. var tracking = new OfficeCli.Core.TrackingPropertyDictionary(properties); - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0; var resultPath = handler.Add(parentPath, type!, position, tracking); var unsupported = tracking.UnusedKeys.ToList(); @@ -336,7 +336,7 @@ private static Command BuildRemoveCommand(Option jsonOption) if (parsedProps != null) req.Props = parsedProps; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0; string? warning; if (!string.IsNullOrEmpty(shift)) @@ -426,7 +426,7 @@ private static Command BuildMoveCommand(Option jsonOption) if (moveProps.Count > 0) req.Props = moveProps; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var resultPath = handler.Move(path, to, position, moveProps.Count > 0 ? moveProps : null); var message = $"Moved to {resultPath}"; if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message)); @@ -463,7 +463,7 @@ private static Command BuildSwapCommand(Option jsonOption) req.Args["to"] = path2; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var (p1, p2) = handler switch { OfficeCli.Handlers.PowerPointHandler ppt => ppt.Swap(path1, path2), diff --git a/src/officecli/CommandBuilder.Batch.cs b/src/officecli/CommandBuilder.Batch.cs index c3a35cd1e..c8dd93df2 100644 --- a/src/officecli/CommandBuilder.Batch.cs +++ b/src/officecli/CommandBuilder.Batch.cs @@ -263,7 +263,7 @@ private static Command BuildBatchCommand(Option jsonOption) // re-serialize that dominates large replays. Save-once is the // documented intent of this path; per-op Save was redundant given // the Dispose-time flush. - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); if (handler is OfficeCli.Handlers.WordHandler batchWh) batchWh.DeferSave = true; // Protection gate against the just-opened in-memory DOM (one check // for the whole batch; no second file open). diff --git a/src/officecli/CommandBuilder.Check.cs b/src/officecli/CommandBuilder.Check.cs index 504828a40..48b8f7602 100644 --- a/src/officecli/CommandBuilder.Check.cs +++ b/src/officecli/CommandBuilder.Check.cs @@ -25,7 +25,7 @@ private static Command BuildValidateCommand(Option jsonOption) req.Json = json; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName); + using var handler = DocumentHandlerFactory.Open(file.FullName, password: result.GetValue(PasswordOption)); var errors = handler.Validate(); if (json) { diff --git a/src/officecli/CommandBuilder.Dump.cs b/src/officecli/CommandBuilder.Dump.cs index 713be4cd8..210b69b10 100644 --- a/src/officecli/CommandBuilder.Dump.cs +++ b/src/officecli/CommandBuilder.Dump.cs @@ -101,7 +101,7 @@ private static Command BuildDumpCommand(Option jsonOption) // raw OOXML SDK exception out of programmatic callers (tests, // resident batch) — SafeRun catches it at the CLI surface but // any in-process consumer sees the unwrapped form. - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false, password: result.GetValue(PasswordOption)); if (ext == ".docx") { var word = (WordHandler)handler; diff --git a/src/officecli/CommandBuilder.GetQuery.cs b/src/officecli/CommandBuilder.GetQuery.cs index d27c97762..957b5a809 100644 --- a/src/officecli/CommandBuilder.GetQuery.cs +++ b/src/officecli/CommandBuilder.GetQuery.cs @@ -42,7 +42,7 @@ private static Command BuildGetCommand(Option jsonOption) // for the currently-selected element paths and resolve them to nodes. if (string.Equals(path, "selected", StringComparison.OrdinalIgnoreCase)) { - return GetSelectedAction(file.FullName, depth, json); + return GetSelectedAction(file.FullName, depth, json, result.GetValue(PasswordOption)); } if (TryResident(file.FullName, req => @@ -54,7 +54,7 @@ private static Command BuildGetCommand(Option jsonOption) if (!string.IsNullOrEmpty(savePath)) req.Args["save"] = savePath; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName); + using var handler = DocumentHandlerFactory.Open(file.FullName, password: result.GetValue(PasswordOption)); var node = handler.Get(path, depth); // CONSISTENCY(get-not-found-exit): some handler Get paths surface @@ -109,7 +109,7 @@ private static Command BuildGetCommand(Option jsonOption) return getCommand; } - private static int GetSelectedAction(string filePath, int depth, bool json) + private static int GetSelectedAction(string filePath, int depth, bool json, string? password = null) { var paths = WatchNotifier.QuerySelection(filePath); if (paths == null) @@ -127,7 +127,7 @@ private static int GetSelectedAction(string filePath, int depth, bool json) var nodes = new List(); if (paths.Length > 0) { - using var handler = DocumentHandlerFactory.Open(filePath); + using var handler = DocumentHandlerFactory.Open(filePath, password: password); foreach (var p in paths) { try @@ -194,7 +194,7 @@ private static Command BuildQueryCommand(Option jsonOption) var format = json ? OutputFormat.Json : OutputFormat.Text; - using var handler = DocumentHandlerFactory.Open(file.FullName); + using var handler = DocumentHandlerFactory.Open(file.FullName, password: result.GetValue(PasswordOption)); // CONSISTENCY(cell-selector-alias): the Excel cell selector accepts short // aliases (bold -> font.bold, size -> font.size, ...). FilterSelector // applies the same normalization, runs the boolean and/or engine, and diff --git a/src/officecli/CommandBuilder.Raw.cs b/src/officecli/CommandBuilder.Raw.cs index d2ea4bf32..62795924e 100644 --- a/src/officecli/CommandBuilder.Raw.cs +++ b/src/officecli/CommandBuilder.Raw.cs @@ -47,7 +47,7 @@ private static Command BuildRawCommand(Option jsonOption) var rawCols = rawColsStr != null ? new HashSet(rawColsStr.Split(',').Select(c => c.Trim().ToUpperInvariant())) : null; - using var handler = DocumentHandlerFactory.Open(file.FullName); + using var handler = DocumentHandlerFactory.Open(file.FullName, password: result.GetValue(PasswordOption)); var xml = handler.Raw(partPath, startRow, endRow, rawCols); if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(xml)); else Console.WriteLine(xml); @@ -90,7 +90,7 @@ private static Command BuildRawSetCommand(Option jsonOption) if (xml != null) req.Args["xml"] = xml; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var errorsBefore = handler.Validate().Select(e => e.Description).ToHashSet(); handler.RawSet(partPath, xpath, action, xml); var warnings = ReportNewErrorsAsWarnings(handler, errorsBefore); @@ -132,7 +132,7 @@ private static Command BuildAddPartCommand(Option jsonOption) req.Args["type"] = type; }, json) is {} rc) return rc; - using var handler = DocumentHandlerFactory.Open(file, editable: true); + using var handler = DocumentHandlerFactory.Open(file, editable: true, password: result.GetValue(PasswordOption)); var errorsBefore = handler.Validate().Select(e => e.Description).ToHashSet(); var (relId, partPath) = handler.AddPart(parent, type); var warnings = ReportNewErrorsAsWarnings(handler, errorsBefore); diff --git a/src/officecli/CommandBuilder.Set.cs b/src/officecli/CommandBuilder.Set.cs index 689eb6725..dab466f8f 100644 --- a/src/officecli/CommandBuilder.Set.cs +++ b/src/officecli/CommandBuilder.Set.cs @@ -209,7 +209,7 @@ private static Command BuildSetCommand(Option jsonOption) // stay in sync. var properties = ParsePropsArray(props); - using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true); + using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true, password: result.GetValue(PasswordOption)); var unsupported = handler.Set(path, properties); diff --git a/src/officecli/CommandBuilder.View.cs b/src/officecli/CommandBuilder.View.cs index 0ebbbda59..66af7ab0b 100644 --- a/src/officecli/CommandBuilder.View.cs +++ b/src/officecli/CommandBuilder.View.cs @@ -125,7 +125,7 @@ private static Command BuildViewCommand(Option jsonOption) var format = json ? OutputFormat.Json : OutputFormat.Text; var cols = colsStr != null ? new HashSet(colsStr.Split(',').Select(c => c.Trim().ToUpperInvariant())) : null; - using var handler = DocumentHandlerFactory.Open(file.FullName); + using var handler = DocumentHandlerFactory.Open(file.FullName, password: result.GetValue(PasswordOption)); if (mode.ToLowerInvariant() is "html" or "h") { diff --git a/src/officecli/CommandBuilder.cs b/src/officecli/CommandBuilder.cs index c6a1eca3e..4880e4ad9 100644 --- a/src/officecli/CommandBuilder.cs +++ b/src/officecli/CommandBuilder.cs @@ -12,6 +12,16 @@ namespace OfficeCli; static partial class CommandBuilder { + /// + /// Password for an encrypted (Agile) OOXML document. Declared once as a + /// recursive option so every subcommand that opens a document can read it + /// (via result.GetValue(PasswordOption)) and pass it to + /// without each + /// builder having to thread it through its signature. + /// + internal static readonly Option PasswordOption = + new("--password") { Description = "Password for an encrypted (Agile) OOXML document (or set OFFICECLI_PASSWORD)", Recursive = true }; + public static RootCommand BuildRootCommand() { var jsonOption = new Option("--json") { Description = "Output as JSON (AI-friendly)" }; @@ -23,6 +33,7 @@ public static RootCommand BuildRootCommand() See the Commands section below for the full list of subcommands. """); rootCommand.Add(jsonOption); + rootCommand.Add(PasswordOption); // ==================== open command (start resident) ==================== var openFileArg = new Argument("file") { Description = "Office document path (required even with open/close mode)" }; diff --git a/src/officecli/Core/CompoundFile.cs b/src/officecli/Core/CompoundFile.cs index 067832966..31a9a0c72 100644 --- a/src/officecli/Core/CompoundFile.cs +++ b/src/officecli/Core/CompoundFile.cs @@ -204,6 +204,223 @@ private static void WriteDirEntry(byte[] buf, int offset, string name, BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(offset + 120), (ulong)size); } + // ==================== Multi-stream / nested writer ==================== + + private sealed class DirNode + { + public string Name = ""; + public bool IsStorage; + public byte[] Data = Array.Empty(); + public readonly List Children = new(); + public int Index = -1; + // Resolved directory pointers (entry indices, or NOSTREAM). + public uint Left = NOSTREAM, Right = NOSTREAM, Child = NOSTREAM; + // Stream placement. + public uint Start = ENDOFCHAIN; + public long Size; + } + + /// + /// Build a V3 CFB byte array containing an arbitrary set of named streams, + /// optionally nested inside storages. Each entry's path is + /// '/'-separated: all but the last segment are storages, the last is the + /// stream (segment names may include the control-char prefixes Office uses, + /// e.g. "DataSpaces/Version"). + /// + /// Used to assemble password-encrypted OOXML (EncryptionInfo + + /// EncryptedPackage + the DataSpaces transform tree). Small streams + /// (< 4096 bytes) are consolidated into the root mini stream via the mini + /// FAT; larger ones use regular FAT sectors — exactly like a real Office + /// container, so spec-compliant readers (Excel, LibreOffice) accept it. + /// Siblings within each storage form a balanced BST ordered by the + /// [MS-CFB] §2.6.4 name comparison, so directory lookups resolve correctly. + /// + public static byte[] WriteStreams(IEnumerable<(string path, byte[] data)> entries) + { + const int sectorSize = 512; + + // ── 1. Build the storage/stream tree from the flat paths. ────────── + var root = new DirNode { Name = "Root Entry", IsStorage = true }; + foreach (var (path, data) in entries) + { + var segs = path.Split('/'); + var cur = root; + for (int i = 0; i < segs.Length; i++) + { + bool leaf = i == segs.Length - 1; + var existing = cur.Children.Find(c => c.Name == segs[i]); + if (existing == null) + { + existing = new DirNode { Name = segs[i], IsStorage = !leaf }; + if (leaf) existing.Data = data ?? Array.Empty(); + cur.Children.Add(existing); + } + cur = existing; + } + } + + // ── 2. Assign directory indices (Root == 0, then pre-order). ─────── + var nodes = new List(); + void Index(DirNode n) { n.Index = nodes.Count; nodes.Add(n); foreach (var c in n.Children) Index(c); } + Index(root); + + // ── 3. Per storage, order children + build a balanced sibling BST. ── + foreach (var n in nodes) + { + if (!n.IsStorage || n.Children.Count == 0) continue; + var kids = new List(n.Children); + kids.Sort((a, b) => CfbNameCompare(a.Name, b.Name)); + n.Child = (uint)BuildBst(kids, 0, kids.Count - 1).Index; + } + + // ── 4. Lay out streams: small → mini stream, large → regular FAT. ── + var sectors = new List(); + var fat = new List(); + int WriteChain(byte[] blob) + { + int count = Math.Max(1, (blob.Length + sectorSize - 1) / sectorSize); + int start = sectors.Count; + for (int i = 0; i < count; i++) + { + var s = new byte[sectorSize]; + int off = i * sectorSize, len = Math.Min(sectorSize, blob.Length - off); + if (len > 0) Array.Copy(blob, off, s, 0, len); + sectors.Add(s); + fat.Add(i == count - 1 ? ENDOFCHAIN : 0); + } + for (int i = 0; i < count - 1; i++) fat[start + i] = (uint)(start + i + 1); + return start; + } + + var miniStream = new List(); + var miniChainNext = new List(); // mini FAT (per 64-byte mini-sector) + foreach (var n in nodes) + { + if (n.IsStorage) continue; + n.Size = n.Data.Length; + if (n.Data.Length == 0) { n.Start = ENDOFCHAIN; continue; } + if (n.Data.Length < MiniStreamCutoff) + { + int firstMini = miniStream.Count / MiniSectorSize; + int need = (n.Data.Length + MiniSectorSize - 1) / MiniSectorSize; + miniStream.AddRange(n.Data); + int pad = need * MiniSectorSize - n.Data.Length; + for (int i = 0; i < pad; i++) miniStream.Add(0); + for (int i = 0; i < need; i++) + miniChainNext.Add(i == need - 1 ? ENDOFCHAIN : (uint)(firstMini + i + 1)); + n.Start = (uint)firstMini; + } + else + { + n.Start = (uint)WriteChain(n.Data); + } + } + + // Mini stream is itself a regular stream owned by Root Entry. + uint rootStart = ENDOFCHAIN; long rootSize = 0; + uint firstMiniFat = ENDOFCHAIN; uint numMiniFat = 0; + if (miniStream.Count > 0) + { + rootStart = (uint)WriteChain(miniStream.ToArray()); + rootSize = miniStream.Count; + + int miniFatSectors = Math.Max(1, (miniChainNext.Count + 127) / 128); + var miniFatBlob = new byte[miniFatSectors * sectorSize]; + for (int i = 0; i < miniFatBlob.Length; i += 4) + BinaryPrimitives.WriteUInt32LittleEndian(miniFatBlob.AsSpan(i), FREESECT); + for (int i = 0; i < miniChainNext.Count; i++) + BinaryPrimitives.WriteUInt32LittleEndian(miniFatBlob.AsSpan(i * 4), miniChainNext[i]); + firstMiniFat = (uint)WriteChain(miniFatBlob); + numMiniFat = (uint)miniFatSectors; + } + root.Start = rootStart; + root.Size = rootSize; + + // ── 5. Directory stream: one 128-byte entry per node, sector-padded. ─ + int dirEntries = nodes.Count; + int dirSectors = Math.Max(1, (dirEntries + 3) / 4); + var dir = new byte[dirSectors * sectorSize]; + foreach (var n in nodes) + { + byte objType = n == root ? (byte)5 : n.IsStorage ? (byte)1 : (byte)2; + WriteDirEntry(dir, n.Index * DirEntrySize, n.Name, objType, + n.Left, n.Right, n.Child, n.Start, n.Size); + } + int dirStart = WriteChain(dir); + + // ── 6. Allocate FAT sectors last (they must self-represent). ──────── + int dataSectors = sectors.Count; + int numFat = Math.Max(1, (dataSectors + 126) / 127); + while (dataSectors + numFat > numFat * 128) numFat++; + if (numFat > HeaderDifatCount) + throw new NotSupportedException("Encrypted payload too large to wrap (would need DIFAT sectors)."); + + var fatSectorIds = new int[numFat]; + for (int i = 0; i < numFat; i++) + { + fatSectorIds[i] = sectors.Count; + sectors.Add(new byte[sectorSize]); + fat.Add(FATSECT); + } + + var fatBytes = new byte[numFat * sectorSize]; + for (int i = 0; i < fatBytes.Length; i += 4) + BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(i), FREESECT); + for (int s = 0; s < fat.Count; s++) + BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(s * 4), fat[s]); + for (int i = 0; i < numFat; i++) + Array.Copy(fatBytes, i * sectorSize, sectors[fatSectorIds[i]], 0, sectorSize); + + // ── 7. Header + sectors. ─────────────────────────────────────────── + var header = new byte[sectorSize]; + Array.Copy(Magic, header, Magic.Length); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(24), 0x003E); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(26), 0x0003); // V3 + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(28), 0xFFFE); + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(30), 0x0009); // 512-byte sectors + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(32), 0x0006); // 64-byte mini sectors + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(44), (uint)numFat); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(48), (uint)dirStart); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(56), MiniStreamCutoff); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(60), firstMiniFat); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(64), numMiniFat); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(68), ENDOFCHAIN); + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(72), 0); + for (int i = 0; i < HeaderDifatCount; i++) + BinaryPrimitives.WriteUInt32LittleEndian( + header.AsSpan(76 + i * 4), i < numFat ? (uint)fatSectorIds[i] : FREESECT); + + var output = new byte[sectorSize + sectors.Count * sectorSize]; + Array.Copy(header, 0, output, 0, sectorSize); + for (int i = 0; i < sectors.Count; i++) + Array.Copy(sectors[i], 0, output, sectorSize + i * sectorSize, sectorSize); + return output; + } + + /// Build a height-balanced BST from name-sorted children, wiring + /// each node's Left/Right to its subtree roots; returns the subtree root. + private static DirNode BuildBst(List sorted, int lo, int hi) + { + int mid = (lo + hi) / 2; + var node = sorted[mid]; + node.Left = lo <= mid - 1 ? (uint)BuildBst(sorted, lo, mid - 1).Index : NOSTREAM; + node.Right = mid + 1 <= hi ? (uint)BuildBst(sorted, mid + 1, hi).Index : NOSTREAM; + return node; + } + + /// [MS-CFB] §2.6.4 directory name order: by UTF-16 length first, + /// then by uppercased UTF-16 code units. + private static int CfbNameCompare(string a, string b) + { + if (a.Length != b.Length) return a.Length - b.Length; + for (int i = 0; i < a.Length; i++) + { + int ca = char.ToUpperInvariant(a[i]), cb = char.ToUpperInvariant(b[i]); + if (ca != cb) return ca - cb; + } + return 0; + } + // ==================== Reader ==================== /// diff --git a/src/officecli/Core/MsOffCrypto.cs b/src/officecli/Core/MsOffCrypto.cs new file mode 100644 index 000000000..a2b45b863 --- /dev/null +++ b/src/officecli/Core/MsOffCrypto.cs @@ -0,0 +1,402 @@ +// Copyright 2025 OfficeCLI (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; + +namespace OfficeCli.Core; + +/// +/// Reads and writes password-encrypted OOXML (the D0 CF 11 E0 CDFV2 +/// container Office produces when you set a document password). Implements the +/// Agile encryption scheme from [MS-OFFCRYPTO] §2.3.4.10–2.3.4.15: +/// AES-CBC over a per-segment-IV'd EncryptedPackage, with the package key +/// wrapped by a password-derived key (iterated hash) and an HMAC +/// dataIntegrity block. +/// +/// This is a clean-room implementation written from the published spec and +/// validated byte-for-byte against an independent reference decryptor — it does +/// not port code from any (L)GPL/MPL office suite, keeping the file Apache-2.0 +/// clean like the rest of the tree. +/// +/// Scope: Agile only (the modern default since Office 2013 — +/// SHA-512/AES-256). The legacy "Standard" (ECMA-376 binary) and pre-2007 RC4 +/// schemes are intentionally out of scope; +/// recognises them so callers can give a clear "unsupported scheme" message +/// rather than a corrupt-file error. +/// +/// The CFB container itself is read/written by ; +/// this class only owns the cryptography and the EncryptionInfo XML. +/// +internal static class MsOffCrypto +{ + // CDFV2 / OLE compound-file magic — shared with CompoundFile. + private static readonly byte[] CfbMagic = + { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }; + + // Block keys that salt the key-derivation for each encrypted blob + // ([MS-OFFCRYPTO] 2.3.4.12–2.3.4.14). Constant across all files. + private static readonly byte[] BkVerifierInput = { 0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79 }; + private static readonly byte[] BkVerifierValue = { 0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e }; + private static readonly byte[] BkKeyValue = { 0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6 }; + private static readonly byte[] BkHmacKey = { 0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6 }; + private static readonly byte[] BkHmacValue = { 0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33 }; + + private const int PackageSegment = 4096; // EncryptedPackage cipher segment size + private static readonly XNamespace NsEnc = + "http://schemas.microsoft.com/office/2006/encryption"; + private static readonly XNamespace NsPwd = + "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; + + /// True if the first bytes are the OLE/CFB magic — i.e. this is an + /// encrypted (or otherwise OLE-wrapped) OOXML file, not a plain zip. + public static bool IsCfb(byte[] bytes) + { + if (bytes is null || bytes.Length < CfbMagic.Length) return false; + for (int i = 0; i < CfbMagic.Length; i++) + if (bytes[i] != CfbMagic[i]) return false; + return true; + } + + /// Why an encrypted file could not be opened, for a precise message. + public enum Scheme { NotEncrypted, Agile, StandardUnsupported } + + /// + /// Classify a CFB blob: is it Agile-encrypted (supported), Standard/legacy + /// (recognised but unsupported), or not an encrypted OOXML at all? + /// + public static Scheme Classify(byte[] cfb) + { + if (!IsCfb(cfb)) return Scheme.NotEncrypted; + byte[]? info = CompoundFile.ReadStream(cfb, "EncryptionInfo"); + if (info is null || info.Length < 8) return Scheme.NotEncrypted; + ushort vMajor = BinaryPrimitives.ReadUInt16LittleEndian(info.AsSpan(0)); + ushort vMinor = BinaryPrimitives.ReadUInt16LittleEndian(info.AsSpan(2)); + // Agile: version 4.4 with the AES/extensible flag; the rest is XML. + if (vMajor == 4 && vMinor == 4) return Scheme.Agile; + // Standard (2.2/3.2/4.2) and legacy RC4 — recognised, not supported. + return Scheme.StandardUnsupported; + } + + /// Convenience: is this an OOXML we can actually decrypt? + public static bool IsEncryptedOoxml(byte[] cfb) => Classify(cfb) == Scheme.Agile; + + public sealed class WrongPasswordException : Exception + { + public WrongPasswordException() : base("Incorrect password for encrypted document.") { } + } + public sealed class UnsupportedSchemeException : Exception + { + public UnsupportedSchemeException(string m) : base(m) { } + } + + // ==================== Decrypt ==================== + + /// + /// Decrypt an Agile-encrypted OOXML with + /// , returning the inner plain zip bytes + /// (PK\x03\x04…). Throws if the password + /// fails the verifier, or for + /// non-Agile encryption. + /// + public static byte[] Decrypt(byte[] cfb, string password) + { + switch (Classify(cfb)) + { + case Scheme.NotEncrypted: + throw new InvalidOperationException("File is not an encrypted OOXML document."); + case Scheme.StandardUnsupported: + throw new UnsupportedSchemeException( + "This file uses the legacy 'Standard' encryption scheme, which officecli " + + "does not support (only modern 'Agile' encryption). Open it in LibreOffice/Excel instead."); + } + + byte[] info = CompoundFile.ReadStream(cfb, "EncryptionInfo") + ?? throw new InvalidOperationException("EncryptionInfo stream missing."); + byte[] package = CompoundFile.ReadStream(cfb, "EncryptedPackage") + ?? throw new InvalidOperationException("EncryptedPackage stream missing."); + + var d = AgileDescriptor.Parse(info); + byte[] hSpin = SpinHash(d.HashAlg, d.PwSalt, password, d.SpinCount); + + // Verify the password via the verifier hash before doing real work. + byte[] verifierInput = AesCbcDecrypt( + DeriveKey(d.HashAlg, hSpin, BkVerifierInput, d.KeyBytes), Fit(d.PwSalt, d.BlockSize), d.EncVerifierHashInput); + byte[] verifierHash = AesCbcDecrypt( + DeriveKey(d.HashAlg, hSpin, BkVerifierValue, d.KeyBytes), Fit(d.PwSalt, d.BlockSize), d.EncVerifierHashValue); + byte[] expected = Hash(d.HashAlg, verifierInput.AsSpan(0, d.SaltSize).ToArray()); + if (!verifierHash.AsSpan(0, expected.Length).SequenceEqual(expected)) + throw new WrongPasswordException(); + + // Unwrap the package secret key, then decrypt the package per-segment. + byte[] secret = AesCbcDecrypt( + DeriveKey(d.HashAlg, hSpin, BkKeyValue, d.KeyBytes), Fit(d.PwSalt, d.BlockSize), d.EncKeyValue); + secret = secret.AsSpan(0, d.KeyDataKeyBytes).ToArray(); + + long total = BinaryPrimitives.ReadInt64LittleEndian(package.AsSpan(0)); + byte[] enc = package.AsSpan(8).ToArray(); + var outBuf = new byte[checked((int)((enc.Length + d.BlockSize - 1) / d.BlockSize) * d.BlockSize)]; + int written = 0; + for (int i = 0; i < enc.Length; i += PackageSegment) + { + int len = Math.Min(PackageSegment, enc.Length - i); + byte[] iv = Fit(Hash(d.KeyDataHashAlg, d.KeyDataSalt, IntLe(i / PackageSegment)), d.BlockSize); + byte[] dec = AesCbcDecrypt(secret, iv, enc.AsSpan(i, len).ToArray()); + Array.Copy(dec, 0, outBuf, written, dec.Length); + written += dec.Length; + } + if (total < 0 || total > outBuf.Length) total = outBuf.Length; + return outBuf.AsSpan(0, (int)total).ToArray(); + } + + // ==================== Encrypt ==================== + + /// + /// Encrypt plain OOXML zip under + /// using Agile encryption (SHA-512 / AES-256), + /// returning a complete CDFV2 file (EncryptionInfo + EncryptedPackage + the + /// DataSpaces transform streams) ready to write to disk. + /// + public static byte[] Encrypt(byte[] plain, string password) + { + const string hashAlg = "SHA512"; + const int blockSize = 16, saltSize = 16, keyBytes = 32, hashSize = 64, spin = 100000; + + byte[] keyDataSalt = RandomNumberGenerator.GetBytes(saltSize); + byte[] pwSalt = RandomNumberGenerator.GetBytes(saltSize); + byte[] secret = RandomNumberGenerator.GetBytes(keyBytes); + byte[] verifierInput = RandomNumberGenerator.GetBytes(saltSize); + + byte[] hSpin = SpinHash(hashAlg, pwSalt, password, spin); + byte[] encVerifierHashInput = AesCbcEncrypt( + DeriveKey(hashAlg, hSpin, BkVerifierInput, keyBytes), Fit(pwSalt, blockSize), PadBlock(verifierInput, blockSize)); + byte[] verifierHash = Hash(hashAlg, verifierInput); + byte[] encVerifierHashValue = AesCbcEncrypt( + DeriveKey(hashAlg, hSpin, BkVerifierValue, keyBytes), Fit(pwSalt, blockSize), PadBlock(verifierHash, blockSize)); + byte[] encKeyValue = AesCbcEncrypt( + DeriveKey(hashAlg, hSpin, BkKeyValue, keyBytes), Fit(pwSalt, blockSize), secret); + + // EncryptedPackage: 8-byte LE size prefix, then per-4096-segment AES-CBC. + var enc = new byte[checked(((plain.Length + blockSize - 1) / blockSize) * blockSize)]; + int written = 0; + for (int i = 0; i < plain.Length; i += PackageSegment) + { + int len = Math.Min(PackageSegment, plain.Length - i); + byte[] iv = Fit(Hash(hashAlg, keyDataSalt, IntLe(i / PackageSegment)), blockSize); + byte[] seg = AesCbcEncrypt(secret, iv, PadBlock(plain.AsSpan(i, len).ToArray(), blockSize)); + Array.Copy(seg, 0, enc, written, seg.Length); + written += seg.Length; + } + var package = new byte[8 + written]; + BinaryPrimitives.WriteInt64LittleEndian(package.AsSpan(0), plain.Length); + Array.Copy(enc, 0, package, 8, written); + + // dataIntegrity: HMAC of the EncryptedPackage, both key and value wrapped + // under the package secret with block-key-salted IVs. + byte[] hmacKey = RandomNumberGenerator.GetBytes(hashSize); + byte[] encHmacKey = AesCbcEncrypt(secret, + Fit(Hash(hashAlg, keyDataSalt, BkHmacKey), blockSize), PadBlock(hmacKey, blockSize)); + byte[] hmacValue = HmacSha512(hmacKey, package); + byte[] encHmacValue = AesCbcEncrypt(secret, + Fit(Hash(hashAlg, keyDataSalt, BkHmacValue), blockSize), PadBlock(hmacValue, blockSize)); + + string xml = BuildEncryptionInfoXml( + blockSize, saltSize, keyBytes * 8, hashSize, hashAlg, keyDataSalt, + encHmacKey, encHmacValue, spin, pwSalt, encVerifierHashInput, encVerifierHashValue, encKeyValue); + var info = new byte[8 + Encoding.UTF8.GetByteCount(xml)]; + BinaryPrimitives.WriteUInt16LittleEndian(info.AsSpan(0), 4); // version major + BinaryPrimitives.WriteUInt16LittleEndian(info.AsSpan(2), 4); // version minor + BinaryPrimitives.WriteUInt32LittleEndian(info.AsSpan(4), 0x40); // fAgileReserved + Encoding.UTF8.GetBytes(xml, 0, xml.Length, info, 8); + + return CompoundFile.WriteStreams(new[] + { + ("EncryptionInfo", info), + ("EncryptedPackage", package), + // Constant DataSpaces transform tree Office expects on encrypted files. + ("DataSpaces/Version", DataSpaces.Version), + ("DataSpaces/DataSpaceMap", DataSpaces.DataSpaceMap), + ("DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace", DataSpaces.StrongEncryptionDataSpace), + ("DataSpaces/TransformInfo/StrongEncryptionTransform/Primary", DataSpaces.Primary), + }); + } + + // ==================== Agile EncryptionInfo ==================== + + private sealed class AgileDescriptor + { + public string HashAlg = "SHA512"; + public int BlockSize, SaltSize, KeyBytes, SpinCount; + public byte[] PwSalt = Array.Empty(); + public byte[] EncVerifierHashInput = Array.Empty(); + public byte[] EncVerifierHashValue = Array.Empty(); + public byte[] EncKeyValue = Array.Empty(); + // keyData (package) parameters — can differ from the password keyEncryptor. + public string KeyDataHashAlg = "SHA512"; + public int KeyDataBlockSize, KeyDataKeyBytes; + public byte[] KeyDataSalt = Array.Empty(); + + public static AgileDescriptor Parse(byte[] info) + { + // First 8 bytes are the version/flags header; the rest is the XML. + var doc = XDocument.Parse(Encoding.UTF8.GetString(info, 8, info.Length - 8)); + XElement root = doc.Root ?? throw new InvalidOperationException("EncryptionInfo: no root."); + XElement keyData = root.Element(NsEnc + "keyData") + ?? throw new InvalidOperationException("EncryptionInfo: no keyData."); + XElement encKey = root.Element(NsEnc + "keyEncryptors")?.Element(NsEnc + "keyEncryptor") + ?.Element(NsPwd + "encryptedKey") + ?? throw new InvalidOperationException("EncryptionInfo: no password encryptedKey."); + + int Int(XElement e, string a) => int.Parse(e.Attribute(a)?.Value + ?? throw new InvalidOperationException($"EncryptionInfo: missing @{a}.")); + byte[] B64(XElement e, string a) => Convert.FromBase64String(e.Attribute(a)?.Value + ?? throw new InvalidOperationException($"EncryptionInfo: missing @{a}.")); + + return new AgileDescriptor + { + KeyDataHashAlg = keyData.Attribute("hashAlgorithm")?.Value ?? "SHA512", + KeyDataBlockSize = Int(keyData, "blockSize"), + KeyDataKeyBytes = Int(keyData, "keyBits") / 8, + KeyDataSalt = B64(keyData, "saltValue"), + HashAlg = encKey.Attribute("hashAlgorithm")?.Value ?? "SHA512", + BlockSize = Int(encKey, "blockSize"), + SaltSize = Int(encKey, "saltSize"), + KeyBytes = Int(encKey, "keyBits") / 8, + SpinCount = Int(encKey, "spinCount"), + PwSalt = B64(encKey, "saltValue"), + EncVerifierHashInput = B64(encKey, "encryptedVerifierHashInput"), + EncVerifierHashValue = B64(encKey, "encryptedVerifierHashValue"), + EncKeyValue = B64(encKey, "encryptedKeyValue"), + }; + } + } + + private static string BuildEncryptionInfoXml( + int blockSize, int saltSize, int keyBits, int hashSize, string hashAlg, byte[] keyDataSalt, + byte[] encHmacKey, byte[] encHmacValue, int spin, byte[] pwSalt, + byte[] encVerifierHashInput, byte[] encVerifierHashValue, byte[] encKeyValue) + { + string B(byte[] b) => Convert.ToBase64String(b); + return "\r\n" + + "" + + $"" + + $"" + + "" + + $""; + } + + // ==================== Primitives ==================== + + /// H_0 = Hash(salt ‖ UTF16LE(password)); then iterate + /// H_{i+1} = Hash(LE32(i) ‖ H_i) spinCount times. + private static byte[] SpinHash(string alg, byte[] salt, string password, int spin) + { + byte[] h = Hash(alg, salt, Encoding.Unicode.GetBytes(password)); + var buf = new byte[4 + h.Length]; + for (int i = 0; i < spin; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(0), i); + Array.Copy(h, 0, buf, 4, h.Length); + h = Hash(alg, buf); + } + return h; + } + + /// key = Hash(hSpin ‖ blockKey), truncated/zero-padded to keyBytes. + private static byte[] DeriveKey(string alg, byte[] hSpin, byte[] blockKey, int keyBytes) + => Fit(Hash(alg, hSpin, blockKey), keyBytes); + + private static byte[] Hash(string alg, params byte[][] parts) + { + using var h = CreateHash(alg); + foreach (var p in parts) h.AppendData(p); + return h.GetHashAndReset(); + } + + private static IncrementalHash CreateHash(string alg) => alg.ToUpperInvariant() switch + { + "SHA512" => IncrementalHash.CreateHash(HashAlgorithmName.SHA512), + "SHA384" => IncrementalHash.CreateHash(HashAlgorithmName.SHA384), + "SHA256" => IncrementalHash.CreateHash(HashAlgorithmName.SHA256), + "SHA1" => IncrementalHash.CreateHash(HashAlgorithmName.SHA1), + _ => throw new UnsupportedSchemeException($"Unsupported hash algorithm '{alg}'."), + }; + + private static byte[] HmacSha512(byte[] key, byte[] data) => HMACSHA512.HashData(key, data); + + private static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] data) + { + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + aes.Key = key; + aes.IV = iv; + using var dec = aes.CreateDecryptor(); + return dec.TransformFinalBlock(data, 0, data.Length); + } + + private static byte[] AesCbcEncrypt(byte[] key, byte[] iv, byte[] data) + { + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + aes.Key = key; + aes.IV = iv; + using var enc = aes.CreateEncryptor(); + return enc.TransformFinalBlock(data, 0, data.Length); + } + + /// Truncate or zero-pad to exactly bytes. + private static byte[] Fit(byte[] b, int n) + { + if (b.Length == n) return b; + var r = new byte[n]; + Array.Copy(b, r, Math.Min(b.Length, n)); + return r; + } + + /// Zero-pad up to a whole multiple of (AES needs block-aligned input). + private static byte[] PadBlock(byte[] b, int block) + { + int pad = (block - b.Length % block) % block; + if (pad == 0) return b; + var r = new byte[b.Length + pad]; + Array.Copy(b, r, b.Length); + return r; + } + + private static byte[] IntLe(int v) + { + var b = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(b, v); + return b; + } + + /// + /// Constant DataSpaces streams Office writes alongside the encrypted package + /// to describe the "StrongEncryptionTransform". They carry no per-file + /// secrets, so they are emitted verbatim. Captured from an Office/Agile file. + /// + private static class DataSpaces + { + public static readonly byte[] Version = Convert.FromBase64String( + "PAAAAE0AaQBjAHIAbwBzAG8AZgB0AC4AQwBvAG4AdABhAGkAbgBlAHIALgBEAGEAdABhAFMAcABhAGMAZQBzAAEAAAABAAAAAQAAAA=="); + public static readonly byte[] DataSpaceMap = Convert.FromBase64String( + "CAAAAAEAAABoAAAAAQAAAAAAAAAgAAAARQBuAGMAcgB5AHAAdABlAGQAUABhAGMAawBhAGcAZQAyAAAAUwB0AHIAbwBuAGcARQBuAGMAcgB5AHAAdABpAG8AbgBEAGEAdABhAFMAcABhAGMAZQAAAA=="); + public static readonly byte[] StrongEncryptionDataSpace = Convert.FromBase64String( + "CAAAAAEAAAAyAAAAUwB0AHIAbwBuAGcARQBuAGMAcgB5AHAAdABpAG8AbgBUAHIAYQBuAHMAZgBvAHIAbQAAAA=="); + public static readonly byte[] Primary = Convert.FromBase64String( + "WAAAAAEAAABMAAAAewBGAEYAOQBBADMARgAwADMALQA1ADYARQBGAC0ANAA2ADEAMwAtAEIARABEADUALQA1AEEANAAxAEMAMQBEADAANwAyADQANgB9AE4AAABNAGkAYwByAG8AcwBvAGYAdAAuAEMAbwBuAHQAYQBpAG4AZQByAC4ARQBuAGMAcgB5AHAAdABpAG8AbgBUAHIAYQBuAHMAZgBvAHIAbQAAAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAQAAAA="); + } +} diff --git a/src/officecli/Handlers/DocumentHandlerFactory.cs b/src/officecli/Handlers/DocumentHandlerFactory.cs index 844c4425b..0179936e4 100644 --- a/src/officecli/Handlers/DocumentHandlerFactory.cs +++ b/src/officecli/Handlers/DocumentHandlerFactory.cs @@ -11,7 +11,7 @@ namespace OfficeCli.Handlers; public static class DocumentHandlerFactory { - public static IDocumentHandler Open(string filePath, bool editable = false) + public static IDocumentHandler Open(string filePath, bool editable = false, string? password = null) { if (!File.Exists(filePath)) throw new CliException($"File not found: {filePath}") @@ -37,6 +37,13 @@ public static IDocumentHandler Open(string filePath, bool editable = false) var ext = Path.GetExtension(filePath).ToLowerInvariant(); + // Password-protected OOXML is not a zip but an OLE/CDFV2 compound file + // (D0 CF 11 E0…) wrapping an encrypted package. Detect it up front and + // route through the decrypt-to-temp path; otherwise the zip-based guards + // and the Open XML SDK below would only ever see "corrupted data". + if (ext is ".docx" or ".xlsx" or ".pptx" && StartsWithCfbMagic(filePath)) + return OpenEncrypted(filePath, ext, editable, password); + // CONSISTENCY(dos-hardening): reject decompression bombs before the // Open XML SDK / System.IO.Packaging touches the package. A few KB of // zip can inflate to many gigabytes and OOM the process (or, on a @@ -166,6 +173,99 @@ private static IDocumentHandler OpenHandler(string filePath, string ext, bool ed }; } + /// True if the file begins with the OLE/CDFV2 magic — i.e. it is a + /// compound file (a password-encrypted OOXML, or an OLE-wrapped legacy doc), + /// not a plain zip-based .docx/.xlsx/.pptx. + private static bool StartsWithCfbMagic(string filePath) + { + Span head = stackalloc byte[8]; + try + { + using var fs = File.OpenRead(filePath); + if (fs.Read(head) < 8) return false; + } + catch { return false; } + ReadOnlySpan magic = stackalloc byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }; + return head.SequenceEqual(magic); + } + + /// + /// Open a password-protected (Agile-encrypted) OOXML file: decrypt the + /// package to a temporary plaintext copy, open a normal handler on it, and + /// wrap it so any save re-encrypts back to the original path. The temp copy + /// is deleted when the handler is disposed. + /// + private static IDocumentHandler OpenEncrypted(string filePath, string ext, bool editable, string? password) + { + byte[] cfb = File.ReadAllBytes(filePath); + switch (MsOffCrypto.Classify(cfb)) + { + case MsOffCrypto.Scheme.StandardUnsupported: + throw new CliException( + $"Cannot open {Path.GetFileName(filePath)}: it uses the legacy 'Standard' " + + $"encryption scheme, which officecli does not support (only modern 'Agile' encryption).") + { + Code = "unsupported_encryption", + Suggestion = "Re-save it with a password from Office 2013+ (Agile), or remove the password." + }; + case MsOffCrypto.Scheme.NotEncrypted: + // CDFV2 but not an encrypted OOXML (e.g. a real OLE/.doc binary + // mislabeled .docx). Fall through to the normal corrupt-file UX. + throw new CliException( + $"Cannot open {Path.GetFileName(filePath)}: not a valid OOXML package " + + $"(OLE compound file without an EncryptionInfo stream).") + { + Code = "corrupt_file", + Suggestion = "Verify the file is a genuine .docx/.xlsx/.pptx." + }; + } + + // --password wins; otherwise fall back to the OFFICECLI_PASSWORD env var. + // The env path keeps the secret out of argv (visible in `ps`) — better + // for unattended/agent use, where the password comes from a secret store. + if (string.IsNullOrEmpty(password)) + password = Environment.GetEnvironmentVariable("OFFICECLI_PASSWORD"); + + if (string.IsNullOrEmpty(password)) + throw new CliException( + $"{Path.GetFileName(filePath)} is password-protected. Re-run with --password (or set OFFICECLI_PASSWORD).") + { + Code = "password_required", + Suggestion = "officecli --password # or: OFFICECLI_PASSWORD= officecli " + }; + + byte[] plain; + try + { + plain = MsOffCrypto.Decrypt(cfb, password); + } + catch (MsOffCrypto.WrongPasswordException) + { + throw new CliException($"Incorrect password for {Path.GetFileName(filePath)}.") + { + Code = "wrong_password", + Suggestion = "Check the password and try again." + }; + } + + // Decrypted plaintext goes to a private temp file with the right + // extension so the existing handlers + bomb guard work unchanged. + var tempPlain = Path.Combine(Path.GetTempPath(), $"officecli-dec-{Guid.NewGuid():N}{ext}"); + File.WriteAllBytes(tempPlain, plain); + + try + { + GuardDecompressionBomb(tempPlain); // the decrypted package is a real zip + var inner = OpenHandler(tempPlain, ext, editable); + return new EncryptedDocumentHandler(inner, tempPlain, filePath, password, editable); + } + catch + { + try { File.Delete(tempPlain); } catch { /* best-effort */ } + throw; + } + } + /// /// Look for an installed plugin that handles and, if /// found, return a handler that delegates to it. Returns null when no diff --git a/src/officecli/Handlers/EncryptedDocumentHandler.cs b/src/officecli/Handlers/EncryptedDocumentHandler.cs new file mode 100644 index 000000000..571eb2a0c --- /dev/null +++ b/src/officecli/Handlers/EncryptedDocumentHandler.cs @@ -0,0 +1,101 @@ +// Copyright 2025 OfficeCLI (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Nodes; +using OfficeCli.Core; + +namespace OfficeCli.Handlers; + +/// +/// Wraps a normal that is operating on a +/// decrypted copy of a password-protected OOXML file, and transparently +/// re-encrypts on save. +/// +/// The native handlers (Word/Excel/PowerPoint) read and save a document +/// in place through a backing FileStream and have no notion of +/// encryption. Rather than teach each of them MS-OFFCRYPTO, the factory decrypts +/// the package to a temporary plaintext file, opens an ordinary handler on that +/// temp file, and hands it to this decorator. Every operation flows straight +/// through to the inner handler; only the lifecycle is intercepted: +/// after the inner handler flushes plaintext to the temp file, this re-encrypts +/// it under the original password and writes the result back to the original +/// (encrypted) path. On dispose the temp plaintext is shredded-by-delete. +/// +/// Read-only sessions (editable: false, e.g. view/get) +/// never write the original back — the temp file is just decrypted-for-reading +/// and removed on dispose. +/// +internal sealed class EncryptedDocumentHandler : IDocumentHandler +{ + private readonly IDocumentHandler _inner; + private readonly string _tempPlainPath; + private readonly string _originalEncryptedPath; + private readonly string _password; + private readonly bool _editable; + private bool _disposed; + + public EncryptedDocumentHandler( + IDocumentHandler inner, string tempPlainPath, string originalEncryptedPath, + string password, bool editable) + { + _inner = inner; + _tempPlainPath = tempPlainPath; + _originalEncryptedPath = originalEncryptedPath; + _password = password; + _editable = editable; + } + + /// Re-encrypt the current plaintext temp file back to the original + /// path. No-op for read-only sessions. + private void ReEncryptToOriginal() + { + if (!_editable) return; + byte[] plain = File.ReadAllBytes(_tempPlainPath); + byte[] encrypted = MsOffCrypto.Encrypt(plain, _password); + File.WriteAllBytes(_originalEncryptedPath, encrypted); + } + + // === Lifecycle (intercepted) === + public void Save() + { + _inner.Save(); // inner flushes plaintext to the temp file + ReEncryptToOriginal(); // then we re-seal it back to the original + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try + { + _inner.Dispose(); // inner's final save lands in the temp file + ReEncryptToOriginal(); + } + finally + { + try { File.Delete(_tempPlainPath); } catch { /* best-effort cleanup */ } + } + } + + // === Everything else: straight delegation to the inner handler === + public string ViewAsText(int? s = null, int? e = null, int? m = null, HashSet? c = null) => _inner.ViewAsText(s, e, m, c); + public string ViewAsAnnotated(int? s = null, int? e = null, int? m = null, HashSet? c = null) => _inner.ViewAsAnnotated(s, e, m, c); + public string ViewAsOutline() => _inner.ViewAsOutline(); + public string ViewAsStats() => _inner.ViewAsStats(); + public JsonNode ViewAsStatsJson() => _inner.ViewAsStatsJson(); + public JsonNode ViewAsOutlineJson() => _inner.ViewAsOutlineJson(); + public JsonNode ViewAsTextJson(int? s = null, int? e = null, int? m = null, HashSet? c = null) => _inner.ViewAsTextJson(s, e, m, c); + public List ViewAsIssues(string? t = null, int? l = null) => _inner.ViewAsIssues(t, l); + public DocumentNode Get(string path, int depth = 1) => _inner.Get(path, depth); + public List Query(string selector) => _inner.Query(selector); + public List Set(string path, Dictionary properties) => _inner.Set(path, properties); + public string Add(string parentPath, string type, InsertPosition? position, Dictionary properties) => _inner.Add(parentPath, type, position, properties); + public string? Remove(string path, Dictionary? properties = null) => _inner.Remove(path, properties); + public string Move(string sourcePath, string? targetParentPath, InsertPosition? position, Dictionary? properties = null) => _inner.Move(sourcePath, targetParentPath, position, properties); + public string CopyFrom(string sourcePath, string targetParentPath, InsertPosition? position) => _inner.CopyFrom(sourcePath, targetParentPath, position); + public string Raw(string partPath, int? startRow = null, int? endRow = null, HashSet? cols = null) => _inner.Raw(partPath, startRow, endRow, cols); + public void RawSet(string partPath, string xpath, string action, string? xml) => _inner.RawSet(partPath, xpath, action, xml); + public (string RelId, string PartPath) AddPart(string parentPartPath, string partType, Dictionary? properties = null) => _inner.AddPart(parentPartPath, partType, properties); + public List Validate() => _inner.Validate(); + public bool TryExtractBinary(string path, string destPath, out string? contentType, out long byteCount) => _inner.TryExtractBinary(path, destPath, out contentType, out byteCount); +}