|
1
|
+ |
defmodule OpenAgents.DataRights.Age do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Encrypts an export to an `age` recipient the operator does not hold.
|
|
4
|
+ |
|
|
5
|
+ |
`EXIT-001` proves an account can obtain its own records. It says nothing
|
|
6
|
+ |
about who else can read the document on the way out, and until now the
|
|
7
|
+ |
answer was "the operator, and anyone the operator's transport touches",
|
|
8
|
+ |
because the export was plain JSON over TLS. This module closes the part of
|
|
9
|
+ |
that which can be closed: the account supplies an X25519 public key, the
|
|
10
|
+ |
forge encrypts to it, and the private half never exists on this side of the
|
|
11
|
+ |
boundary.
|
|
12
|
+ |
|
|
13
|
+ |
The format is [age v1](https://age-encryption.org/v1) rather than something
|
|
14
|
+ |
of ours, and that choice is the substance of the claim rather than a
|
|
15
|
+ |
convenience. An export a recipient can only open with a decryptor the
|
|
16
|
+ |
operator wrote is still an export the operator defines the terms of. `age`,
|
|
17
|
+ |
`rage`, and every other implementation of the specification read this
|
|
18
|
+ |
document, so the recipient depends on the operator for neither the key nor
|
|
19
|
+ |
the reader.
|
|
20
|
+ |
|
|
21
|
+ |
What it does not do is stated as plainly. The forge builds the export from
|
|
22
|
+ |
plaintext PostgreSQL, so the operator holds the contents before this module
|
|
23
|
+ |
runs and holds them still afterwards. Encryption protects the *artifact* —
|
|
24
|
+ |
the file, its copies, whatever logs or proxies or backups the response
|
|
25
|
+ |
passes through — and it protects nothing about the store it was read from.
|
|
26
|
+ |
`docs/2026-08-24-private-export-encryption.md` records that boundary, the
|
|
27
|
+ |
threat model on both sides of it, and the options rejected for the storage
|
|
28
|
+ |
layer.
|
|
29
|
+ |
|
|
30
|
+ |
Losing the key costs nothing here, which is the property that makes this
|
|
31
|
+ |
decidable at all. An export is derived, not stored: an account that loses
|
|
32
|
+ |
its private key requests the export again under a new one. That is why
|
|
33
|
+ |
recipient-held encryption is right for the export path and wrong for the
|
|
34
|
+ |
columns behind it, where the same key loss would be permanent.
|
|
35
|
+ |
|
|
36
|
+ |
## Construction
|
|
37
|
+ |
|
|
38
|
+ |
One `X25519` recipient stanza, ChaCha20-Poly1305 over a 16-byte file key,
|
|
39
|
+ |
an HMAC-SHA256 over the header, and the payload in `STREAM` chunks of 64
|
|
40
|
+ |
KiB. All of it comes out of `:crypto`; no dependency is added for it.
|
|
41
|
+ |
"""
|
|
42
|
+ |
|
|
43
|
+ |
import Bitwise
|
|
44
|
+ |
|
|
45
|
+ |
@charset ~c"qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
46
|
+ |
@hrp "age"
|
|
47
|
+ |
@key_bytes 32
|
|
48
|
+ |
@file_key_bytes 16
|
|
49
|
+ |
@chunk_bytes 65_536
|
|
50
|
+ |
@info "age-encryption.org/v1/X25519"
|
|
51
|
+ |
@intro "age-encryption.org/v1\n"
|
|
52
|
+ |
|
|
53
|
+ |
@typedoc "A parsed recipient: the raw 32-byte X25519 public key."
|
|
54
|
+ |
@type recipient :: <<_::256>>
|
|
55
|
+ |
|
|
56
|
+ |
@doc """
|
|
57
|
+ |
Parses an `age1…` recipient into its raw X25519 public key.
|
|
58
|
+ |
|
|
59
|
+ |
Bech32 with the `age` human-readable part, which is what `age-keygen -y`
|
|
60
|
+ |
prints. The checksum is verified, so a transcription error is refused here
|
|
61
|
+ |
rather than producing a document nobody can open.
|
|
62
|
+ |
"""
|
|
63
|
+ |
@spec parse_recipient(term()) :: {:ok, recipient()} | {:error, atom()}
|
|
64
|
+ |
def parse_recipient(recipient) when is_binary(recipient) do
|
|
65
|
+ |
with true <- byte_size(recipient) <= 120,
|
|
66
|
+ |
{:ok, @hrp, key} <- bech32_decode(recipient),
|
|
67
|
+ |
@key_bytes <- byte_size(key) do
|
|
68
|
+ |
{:ok, key}
|
|
69
|
+ |
else
|
|
70
|
+ |
_invalid -> {:error, :invalid_recipient}
|
|
71
|
+ |
end
|
|
72
|
+ |
end
|
|
73
|
+ |
|
|
74
|
+ |
def parse_recipient(_recipient), do: {:error, :invalid_recipient}
|
|
75
|
+ |
|
|
76
|
+ |
@doc """
|
|
77
|
+ |
Encrypts `plaintext` to `recipient`, returning an age v1 document.
|
|
78
|
+ |
|
|
79
|
+ |
The recipient is the raw public key from `parse_recipient/1`. An all-zero
|
|
80
|
+ |
X25519 shared secret is refused rather than encrypted to: it means the
|
|
81
|
+ |
recipient key has small order, and the result would be readable by anyone.
|
|
82
|
+ |
"""
|
|
83
|
+ |
@spec encrypt(binary(), recipient()) :: {:ok, binary()} | {:error, atom()}
|
|
84
|
+ |
def encrypt(plaintext, <<recipient::binary-size(@key_bytes)>>) when is_binary(plaintext) do
|
|
85
|
+ |
file_key = :crypto.strong_rand_bytes(@file_key_bytes)
|
|
86
|
+ |
{ephemeral_public, ephemeral_secret} = :crypto.generate_key(:ecdh, :x25519)
|
|
87
|
+ |
shared = :crypto.compute_key(:ecdh, recipient, ephemeral_secret, :x25519)
|
|
88
|
+ |
|
|
89
|
+ |
if shared == <<0::size(@key_bytes * 8)>> do
|
|
90
|
+ |
{:error, :invalid_recipient}
|
|
91
|
+ |
else
|
|
92
|
+ |
wrap_key = hkdf(shared, ephemeral_public <> recipient, @info)
|
|
93
|
+ |
wrapped = seal(wrap_key, <<0::96>>, file_key)
|
|
94
|
+ |
|
|
95
|
+ |
header =
|
|
96
|
+ |
@intro <>
|
|
97
|
+ |
"-> X25519 " <>
|
|
98
|
+ |
encode(ephemeral_public) <> "\n" <> encode(wrapped) <> "\n" <> "---"
|
|
99
|
+ |
|
|
100
|
+ |
mac = :crypto.mac(:hmac, :sha256, hkdf(file_key, "", "header"), header)
|
|
101
|
+ |
nonce = :crypto.strong_rand_bytes(16)
|
|
102
|
+ |
stream_key = hkdf(file_key, nonce, "payload")
|
|
103
|
+ |
|
|
104
|
+ |
{:ok, header <> " " <> encode(mac) <> "\n" <> nonce <> stream(plaintext, stream_key)}
|
|
105
|
+ |
end
|
|
106
|
+ |
rescue
|
|
107
|
+ |
_error -> {:error, :encryption_failed}
|
|
108
|
+ |
end
|
|
109
|
+ |
|
|
110
|
+ |
def encrypt(_plaintext, _recipient), do: {:error, :invalid_recipient}
|
|
111
|
+ |
|
|
112
|
+ |
# `STREAM`: each chunk carries its own tag, and the final chunk sets the
|
|
113
|
+ |
# last byte of the nonce, so a truncated document fails to open rather than
|
|
114
|
+ |
# opening short.
|
|
115
|
+ |
defp stream(plaintext, key), do: stream(plaintext, key, 0, [])
|
|
116
|
+ |
|
|
117
|
+ |
defp stream(rest, key, counter, acc) do
|
|
118
|
+ |
last? = byte_size(rest) <= @chunk_bytes
|
|
119
|
+ |
size = min(byte_size(rest), @chunk_bytes)
|
|
120
|
+ |
<<chunk::binary-size(^size), remaining::binary>> = rest
|
|
121
|
+ |
flag = if last?, do: 1, else: 0
|
|
122
|
+ |
acc = [seal(key, <<counter::88, flag::8>>, chunk) | acc]
|
|
123
|
+ |
|
|
124
|
+ |
if last?,
|
|
125
|
+ |
do: acc |> Enum.reverse() |> IO.iodata_to_binary(),
|
|
126
|
+ |
else: stream(remaining, key, counter + 1, acc)
|
|
127
|
+ |
end
|
|
128
|
+ |
|
|
129
|
+ |
defp seal(key, nonce, plaintext) do
|
|
130
|
+ |
{ciphertext, tag} =
|
|
131
|
+ |
:crypto.crypto_one_time_aead(:chacha20_poly1305, key, nonce, plaintext, <<>>, true)
|
|
132
|
+ |
|
|
133
|
+ |
ciphertext <> tag
|
|
134
|
+ |
end
|
|
135
|
+ |
|
|
136
|
+ |
# HKDF-SHA256 to one 32-byte block. An empty salt is HashLen zeros, which is
|
|
137
|
+ |
# what RFC 5869 says and what every age implementation does.
|
|
138
|
+ |
defp hkdf(ikm, salt, info) do
|
|
139
|
+ |
salt = if salt == "", do: <<0::256>>, else: salt
|
|
140
|
+ |
prk = :crypto.mac(:hmac, :sha256, salt, ikm)
|
|
141
|
+ |
:crypto.mac(:hmac, :sha256, prk, info <> <<1>>)
|
|
142
|
+ |
end
|
|
143
|
+ |
|
|
144
|
+ |
defp encode(binary), do: Base.encode64(binary, padding: false)
|
|
145
|
+ |
|
|
146
|
+ |
defp bech32_decode(string) do
|
|
147
|
+ |
downcased = String.downcase(string)
|
|
148
|
+ |
|
|
149
|
+ |
with [_first, _second | _rest] = parts <- String.split(downcased, "1"),
|
|
150
|
+ |
data = List.last(parts),
|
|
151
|
+ |
hrp = parts |> Enum.drop(-1) |> Enum.join("1"),
|
|
152
|
+ |
true <- hrp != "",
|
|
153
|
+ |
{:ok, values} <- charset_values(data),
|
|
154
|
+ |
true <- length(values) >= 6,
|
|
155
|
+ |
1 <- polymod(hrp_expand(hrp) ++ values),
|
|
156
|
+ |
{:ok, bytes} <- regroup(Enum.drop(values, -6)) do
|
|
157
|
+ |
{:ok, hrp, :binary.list_to_bin(bytes)}
|
|
158
|
+ |
else
|
|
159
|
+ |
_invalid -> {:error, :invalid_recipient}
|
|
160
|
+ |
end
|
|
161
|
+ |
end
|
|
162
|
+ |
|
|
163
|
+ |
defp charset_values(data) do
|
|
164
|
+ |
Enum.reduce_while(String.to_charlist(data), {:ok, []}, fn character, {:ok, acc} ->
|
|
165
|
+ |
case Enum.find_index(@charset, &(&1 == character)) do
|
|
166
|
+ |
nil -> {:halt, {:error, :invalid_recipient}}
|
|
167
|
+ |
index -> {:cont, {:ok, acc ++ [index]}}
|
|
168
|
+ |
end
|
|
169
|
+ |
end)
|
|
170
|
+ |
end
|
|
171
|
+ |
|
|
172
|
+ |
defp hrp_expand(hrp) do
|
|
173
|
+ |
characters = String.to_charlist(hrp)
|
|
174
|
+ |
Enum.map(characters, &div(&1, 32)) ++ [0] ++ Enum.map(characters, &rem(&1, 32))
|
|
175
|
+ |
end
|
|
176
|
+ |
|
|
177
|
+ |
@generator [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
|
178
|
+ |
|
|
179
|
+ |
defp polymod(values) do
|
|
180
|
+ |
Enum.reduce(values, 1, fn value, checksum ->
|
|
181
|
+ |
top = bsr(checksum, 25)
|
|
182
|
+ |
checksum = bxor(bsl(band(checksum, 0x1FFFFFF), 5), value)
|
|
183
|
+ |
|
|
184
|
+ |
@generator
|
|
185
|
+ |
|> Enum.with_index()
|
|
186
|
+ |
|> Enum.reduce(checksum, fn {constant, index}, acc ->
|
|
187
|
+ |
if band(bsr(top, index), 1) == 1, do: bxor(acc, constant), else: acc
|
|
188
|
+ |
end)
|
|
189
|
+ |
end)
|
|
190
|
+ |
end
|
|
191
|
+ |
|
|
192
|
+ |
# 5-bit groups back to 8-bit bytes. Trailing bits must be fewer than five and
|
|
193
|
+ |
# zero, which is what makes a padded or truncated recipient fail here.
|
|
194
|
+ |
defp regroup(values) do
|
|
195
|
+ |
{accumulator, bits, bytes} =
|
|
196
|
+ |
Enum.reduce(values, {0, 0, []}, fn value, {accumulator, bits, bytes} ->
|
|
197
|
+ |
drain(bor(bsl(accumulator, 5), value), bits + 5, bytes)
|
|
198
|
+ |
end)
|
|
199
|
+ |
|
|
200
|
+ |
if bits < 5 and band(bsl(accumulator, 8 - bits), 0xFF) == 0,
|
|
201
|
+ |
do: {:ok, bytes},
|
|
202
|
+ |
else: {:error, :invalid_recipient}
|
|
203
|
+ |
end
|
|
204
|
+ |
|
|
205
|
+ |
defp drain(accumulator, bits, bytes) when bits >= 8 do
|
|
206
|
+ |
drain(accumulator, bits - 8, bytes ++ [band(bsr(accumulator, bits - 8), 0xFF)])
|
|
207
|
+ |
end
|
|
208
|
+ |
|
|
209
|
+ |
defp drain(accumulator, bits, bytes), do: {accumulator, bits, bytes}
|
|
210
|
+ |
end
|