Skip to content

Commit ad016c5

Browse files
ajha-devclaude
andcommitted
Add encryption and compression to storage backend
Implement transparent AES-256-GCM encryption and zlib compression for document storage via a new TransformAdapter. This provides at-rest encryption without modifying existing adapters. Features: - AES-256-GCM encryption (hardware-accelerated on modern CPUs) - zlib compression to reduce storage size - Backwards-compatible: reads legacy unencrypted documents - Supports both disk and S3 storage backends - Configurable via MARKDOC_ENCRYPTION_KEY and MARKDOC_COMPRESSION_ENABLED Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b321759 commit ad016c5

5 files changed

Lines changed: 1102 additions & 10 deletions

File tree

config/config.exs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ config :markdoc, :storage,
5858
idle_flush_ms: 300_000,
5959
retention_hours: 24,
6060
cleanup_interval_ms: 900_000,
61-
s3_prefix: "documents/"
61+
s3_prefix: "documents/",
62+
# Encryption: Base64-encoded 32-byte key (nil to disable)
63+
# Generate with: openssl rand -base64 32
64+
encryption_key: nil,
65+
# Compression: Enable zlib compression (works with or without encryption)
66+
compression_enabled: true
6267

6368
# Import environment specific config. This must remain at the bottom
6469
# of this file so it overrides the configuration defined above.

config/runtime.exs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,36 @@ env_backend =
105105
_ -> Keyword.get(base_storage_config, :backend, :none)
106106
end
107107

108+
# Encryption key: Base64-encoded 32-byte key
109+
# Generate with: openssl rand -base64 32
110+
env_encryption_key =
111+
case System.get_env("MARKDOC_ENCRYPTION_KEY") do
112+
nil ->
113+
Keyword.get(base_storage_config, :encryption_key)
114+
115+
base64_key ->
116+
case Markdoc.Storage.TransformAdapter.decode_key(base64_key) do
117+
{:ok, key} ->
118+
key
119+
120+
{:error, reason} ->
121+
raise """
122+
Invalid MARKDOC_ENCRYPTION_KEY: #{inspect(reason)}
123+
The key must be a Base64-encoded 32-byte (256-bit) value.
124+
Generate one with: openssl rand -base64 32
125+
"""
126+
end
127+
end
128+
129+
# Compression: enabled by default
130+
env_compression_enabled =
131+
case System.get_env("MARKDOC_COMPRESSION_ENABLED") do
132+
"false" -> false
133+
"true" -> true
134+
nil -> Keyword.get(base_storage_config, :compression_enabled, true)
135+
_ -> Keyword.get(base_storage_config, :compression_enabled, true)
136+
end
137+
108138
config :markdoc, :storage,
109139
backend: env_backend,
110140
disk_path: System.get_env("MARKDOC_STORAGE_PATH") || Keyword.get(base_storage_config, :disk_path),
@@ -117,7 +147,9 @@ config :markdoc, :storage,
117147
retention_hours:
118148
env_int.("MARKDOC_RETENTION_HOURS", Keyword.get(base_storage_config, :retention_hours, 24)),
119149
cleanup_interval_ms:
120-
env_int.("MARKDOC_CLEANUP_INTERVAL_MS", Keyword.get(base_storage_config, :cleanup_interval_ms, 900_000))
150+
env_int.("MARKDOC_CLEANUP_INTERVAL_MS", Keyword.get(base_storage_config, :cleanup_interval_ms, 900_000)),
151+
encryption_key: env_encryption_key,
152+
compression_enabled: env_compression_enabled
121153

122154
# ExAws (S3) runtime configuration for AWS or S3-compatible endpoints
123155
exaws_region = System.get_env("MARKDOC_S3_REGION") || System.get_env("AWS_REGION") || System.get_env("AWS_DEFAULT_REGION")

lib/markdoc/storage.ex

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,22 @@ defmodule Markdoc.Storage do
44
55
Selects the configured adapter and exposes helper functions for load, persist,
66
delete, and retention settings.
7+
8+
## Encryption
9+
10+
When `encryption_key` is configured, documents are stored with AES-256-GCM
11+
encryption and zlib compression. The encryption key should be a Base64-encoded
12+
32-byte (256-bit) key.
13+
14+
Generate a key with: `openssl rand -base64 32`
15+
16+
## Configuration
17+
18+
- `:encryption_key` - Base64-encoded 32-byte key (nil to disable encryption)
19+
- `:compression_enabled` - Enable zlib compression (default: true)
720
"""
821

9-
alias Markdoc.Storage.{DiskAdapter, S3Adapter, NoopAdapter}
22+
alias Markdoc.Storage.{DiskAdapter, S3Adapter, NoopAdapter, TransformAdapter}
1023

1124
@type doc_id :: Markdoc.Storage.Adapter.doc_id()
1225
@type doc_payload :: Markdoc.Storage.Adapter.doc_payload()
@@ -59,30 +72,69 @@ defmodule Markdoc.Storage do
5972
Path.expand(path)
6073
end
6174

75+
def encryption_key do
76+
case Keyword.get(config(), :encryption_key) do
77+
nil -> nil
78+
key when is_binary(key) -> key
79+
end
80+
end
81+
82+
def compression_enabled? do
83+
Keyword.get(config(), :compression_enabled, true)
84+
end
85+
6286
defp config, do: Application.get_env(:markdoc, :storage, [])
6387

6488
defp adapter_with_opts do
6589
cfg = config()
90+
backend = Keyword.get(cfg, :backend, :none)
91+
encryption_key = Keyword.get(cfg, :encryption_key)
92+
compression_enabled = Keyword.get(cfg, :compression_enabled, true)
6693

67-
case Keyword.get(cfg, :backend, :none) do
94+
case backend do
6895
:none ->
6996
{:ok, {NoopAdapter, []}}
7097

7198
:disk ->
72-
{:ok, {DiskAdapter, [path: disk_path()]}}
99+
if encryption_key do
100+
# Use TransformAdapter with disk storage
101+
opts = [
102+
storage_type: :disk,
103+
path: disk_path(),
104+
encryption_key: encryption_key,
105+
compression_enabled: compression_enabled
106+
]
107+
108+
{:ok, {TransformAdapter, opts}}
109+
else
110+
{:ok, {DiskAdapter, [path: disk_path()]}}
111+
end
73112

74113
:s3 ->
75114
bucket = Keyword.get(cfg, :s3_bucket)
76115

77116
if is_nil(bucket) do
78117
{:error, :missing_bucket}
79118
else
80-
{:ok,
81-
{S3Adapter,
82-
[
119+
if encryption_key do
120+
# Use TransformAdapter with S3 storage
121+
opts = [
122+
storage_type: :s3,
83123
bucket: bucket,
84-
prefix: Keyword.get(cfg, :s3_prefix, "documents/")
85-
]}}
124+
prefix: Keyword.get(cfg, :s3_prefix, "documents/"),
125+
encryption_key: encryption_key,
126+
compression_enabled: compression_enabled
127+
]
128+
129+
{:ok, {TransformAdapter, opts}}
130+
else
131+
{:ok,
132+
{S3Adapter,
133+
[
134+
bucket: bucket,
135+
prefix: Keyword.get(cfg, :s3_prefix, "documents/")
136+
]}}
137+
end
86138
end
87139
end
88140
end

0 commit comments

Comments
 (0)