|
1
|
+ |
defmodule OpenAgents.Forge.AtRestTest do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
EXIT-006, VAULT-001, issue #193.
|
|
4
|
+ |
|
|
5
|
+ |
`encrypted_at_rest` was a literal `false` on the status page. A literal
|
|
6
|
+ |
cannot fail, so it said nothing about the store — not even the part that was
|
|
7
|
+ |
true, which is that three columns do rest as ciphertext.
|
|
8
|
+ |
|
|
9
|
+ |
These are the assertions that make the boolean mean something. Two of them
|
|
10
|
+ |
read raw columns back through SQL rather than through Ecto, because a schema
|
|
11
|
+ |
that loads a value through a type is exactly the layer that would hide the
|
|
12
|
+ |
answer: the question is what PostgreSQL holds, so PostgreSQL is asked.
|
|
13
|
+ |
|
|
14
|
+ |
The third is the one worth keeping. The population of secret-shaped columns
|
|
15
|
+ |
comes from `information_schema`, so a migration that adds a plaintext token
|
|
16
|
+ |
column fails here on the day it lands. A test of the columns someone thought
|
|
17
|
+ |
of cannot fail on the column they did not.
|
|
18
|
+ |
"""
|
|
19
|
+ |
|
|
20
|
+ |
use OpenAgents.DataCase, async: false
|
|
21
|
+ |
|
|
22
|
+ |
alias OpenAgents.Accounts
|
|
23
|
+ |
alias OpenAgents.Conversations
|
|
24
|
+ |
alias OpenAgents.Forge.AtRest
|
|
25
|
+ |
alias OpenAgents.Machines
|
|
26
|
+ |
alias OpenAgents.Repo
|
|
27
|
+ |
alias OpenAgents.Voice
|
|
28
|
+ |
alias OpenAgents.Voice.Config
|
|
29
|
+ |
alias OpenAgents.Voice.TranscriptItem
|
|
30
|
+ |
|
|
31
|
+ |
describe "the sealed columns rest as ciphertext" do
|
|
32
|
+ |
test "a GitHub access token is not readable in its own column" do
|
|
33
|
+ |
{:ok, user} = Accounts.upsert_github_user(github_profile("at-rest-github"))
|
|
34
|
+ |
token = "gho_at_rest_#{System.unique_integer([:positive])}"
|
|
35
|
+ |
|
|
36
|
+ |
assert {:ok, connected} = Accounts.store_github_token(user, token)
|
|
37
|
+ |
|
|
38
|
+ |
# The application still reads it, so the seal is a seal and not a loss.
|
|
39
|
+ |
assert {:ok, ^token} = Accounts.github_token(connected)
|
|
40
|
+ |
|
|
41
|
+ |
stored = raw_column("users", "github_token_ciphertext", connected.id)
|
|
42
|
+ |
|
|
43
|
+ |
assert is_binary(stored) and byte_size(stored) > 0,
|
|
44
|
+ |
"the fixture must actually store something"
|
|
45
|
+ |
|
|
46
|
+ |
refute contains?(stored, token),
|
|
47
|
+ |
"users.github_token_ciphertext holds the token PostgreSQL was supposed to hide"
|
|
48
|
+ |
end
|
|
49
|
+ |
|
|
50
|
+ |
test "a pairing token is not readable in its own column" do
|
|
51
|
+ |
{:ok, %{pairing: pairing, code: code, poll_secret: poll_secret}} =
|
|
52
|
+ |
Machines.start_pairing(%{"name" => "box", "tier" => "probe"})
|
|
53
|
+ |
|
|
54
|
+ |
{:ok, _machine} = Machines.approve_pairing(github_user("at-rest-pairing"), code)
|
|
55
|
+ |
|
|
56
|
+ |
stored = raw_column("machine_pairings", "token_ciphertext", pairing.id)
|
|
57
|
+ |
|
|
58
|
+ |
assert is_binary(stored) and byte_size(stored) > 0,
|
|
59
|
+ |
"the fixture must actually hold a sealed token"
|
|
60
|
+ |
|
|
61
|
+ |
# The claim returns the plaintext exactly once, which is the only reader.
|
|
62
|
+ |
assert {:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
|
|
63
|
+ |
|
|
64
|
+ |
refute contains?(stored, token),
|
|
65
|
+ |
"machine_pairings.token_ciphertext holds the token it was supposed to seal"
|
|
66
|
+ |
end
|
|
67
|
+ |
|
|
68
|
+ |
test "a call audio slice is not readable in its own column" do
|
|
69
|
+ |
audio = "opus-bytes-#{System.unique_integer([:positive])}"
|
|
70
|
+ |
session = admitted_voice_session("at-rest-audio")
|
|
71
|
+ |
|
|
72
|
+ |
assert {:ok, recording} =
|
|
73
|
+ |
Voice.Recordings.append_chunk(
|
|
74
|
+ |
session,
|
|
75
|
+ |
session.generation,
|
|
76
|
+ |
1,
|
|
77
|
+ |
audio,
|
|
78
|
+ |
"audio/webm;codecs=opus"
|
|
79
|
+ |
)
|
|
80
|
+ |
|
|
81
|
+ |
assert recording.sealed
|
|
82
|
+ |
assert {:ok, ^audio} = Voice.Recordings.read(recording)
|
|
83
|
+ |
|
|
84
|
+ |
%{rows: [[stored]]} =
|
|
85
|
+ |
Repo.query!(
|
|
86
|
+ |
"SELECT data FROM voice_recording_chunks WHERE voice_recording_id = $1 AND sequence = 1",
|
|
87
|
+ |
[Ecto.UUID.dump!(recording.id)]
|
|
88
|
+ |
)
|
|
89
|
+ |
|
|
90
|
+ |
refute contains?(stored, audio),
|
|
91
|
+ |
"voice_recording_chunks.data holds the audio VOICE-012 says is sealed"
|
|
92
|
+ |
end
|
|
93
|
+ |
end
|
|
94
|
+ |
|
|
95
|
+ |
describe "the plaintext columns rest as plaintext" do
|
|
96
|
+ |
# A ledger that names a gap has to be capable of being wrong about it.
|
|
97
|
+ |
# These read the same way the sealed assertions do and expect the opposite
|
|
98
|
+ |
# answer, so a column that quietly became sealed stops being published as
|
|
99
|
+ |
# a gap in the same commit.
|
|
100
|
+ |
test "every column plaintext_private_columns/0 names is plaintext in PostgreSQL" do
|
|
101
|
+ |
for column <- AtRest.plaintext_private_columns() do
|
|
102
|
+ |
{id, written} = write_private_row(column)
|
|
103
|
+ |
stored = raw_column(column.table, column.column, id)
|
|
104
|
+ |
|
|
105
|
+ |
assert contains?(stored, written),
|
|
106
|
+ |
"#{column.table}.#{column.column} is named as plaintext but PostgreSQL " <>
|
|
107
|
+ |
"does not hold the plaintext. If it is sealed now, take it off the list."
|
|
108
|
+ |
end
|
|
109
|
+ |
end
|
|
110
|
+ |
end
|
|
111
|
+ |
|
|
112
|
+ |
describe "the population comes from the database" do
|
|
113
|
+ |
test "every secret-shaped column the catalog reports is classified" do
|
|
114
|
+ |
unclassified =
|
|
115
|
+ |
for {table, column} <- secret_shaped_columns(),
|
|
116
|
+ |
is_nil(AtRest.classification(table, column)),
|
|
117
|
+ |
do: "#{table}.#{column}"
|
|
118
|
+ |
|
|
119
|
+ |
assert unclassified == [],
|
|
120
|
+ |
"These columns carry secret-shaped names and OpenAgents.Forge.AtRest does " <>
|
|
121
|
+ |
"not say where their contents rest:\n " <>
|
|
122
|
+ |
Enum.join(unclassified, "\n ") <>
|
|
123
|
+ |
"\n\nClassify each one. If any holds reversible secret material in " <>
|
|
124
|
+ |
"plaintext, seal it under a vault rather than classifying it away."
|
|
125
|
+ |
end
|
|
126
|
+ |
|
|
127
|
+ |
test "no column is classified as a plaintext secret" do
|
|
128
|
+ |
# The security assertion. Everything else in this file exists to make
|
|
129
|
+ |
# this one capable of failing.
|
|
130
|
+ |
plaintext_secrets =
|
|
131
|
+ |
for {{table, column}, :plaintext_secret} <- AtRest.classifications(),
|
|
132
|
+ |
do: "#{table}.#{column}"
|
|
133
|
+ |
|
|
134
|
+ |
assert plaintext_secrets == [],
|
|
135
|
+ |
"Reversible secret material rests as plaintext in: " <>
|
|
136
|
+ |
Enum.join(plaintext_secrets, ", ")
|
|
137
|
+ |
end
|
|
138
|
+ |
|
|
139
|
+ |
test "the ledger classifies nothing the catalog does not have" do
|
|
140
|
+ |
catalog = MapSet.new(secret_shaped_columns())
|
|
141
|
+ |
|
|
142
|
+ |
stale =
|
|
143
|
+ |
for {{table, column}, _classification} <- AtRest.classifications(),
|
|
144
|
+ |
not MapSet.member?(catalog, {table, column}),
|
|
145
|
+ |
do: "#{table}.#{column}"
|
|
146
|
+ |
|
|
147
|
+ |
assert stale == [],
|
|
148
|
+ |
"OpenAgents.Forge.AtRest classifies columns PostgreSQL does not have: " <>
|
|
149
|
+ |
Enum.join(stale, ", ")
|
|
150
|
+ |
end
|
|
151
|
+ |
|
|
152
|
+ |
test "every sealed column exists and names a vault that can seal and open" do
|
|
153
|
+ |
for sealed <- AtRest.sealed_columns() do
|
|
154
|
+ |
assert column_exists?(sealed.table, sealed.column),
|
|
155
|
+ |
"#{sealed.table}.#{sealed.column} is named as sealed but does not exist"
|
|
156
|
+ |
|
|
157
|
+ |
assert Code.ensure_loaded?(sealed.vault),
|
|
158
|
+ |
"#{inspect(sealed.vault)} does not exist"
|
|
159
|
+ |
|
|
160
|
+ |
assert function_exported?(sealed.vault, :seal, 1) or
|
|
161
|
+ |
function_exported?(sealed.vault, :seal, 3),
|
|
162
|
+ |
"#{inspect(sealed.vault)} exports no seal/1 or seal/3"
|
|
163
|
+ |
|
|
164
|
+ |
assert function_exported?(sealed.vault, :open, 1) or
|
|
165
|
+ |
function_exported?(sealed.vault, :open, 3),
|
|
166
|
+ |
"#{inspect(sealed.vault)} exports no open/1 or open/3"
|
|
167
|
+ |
end
|
|
168
|
+ |
end
|
|
169
|
+ |
end
|
|
170
|
+ |
|
|
171
|
+ |
describe "the disclosure derives from this ledger" do
|
|
172
|
+ |
test "encrypted_at_rest? is exactly whether the plaintext list is empty" do
|
|
173
|
+ |
assert AtRest.encrypted_at_rest?() == Enum.empty?(AtRest.plaintext_private_columns())
|
|
174
|
+ |
end
|
|
175
|
+ |
|
|
176
|
+ |
test "the store is not encrypted at rest today, and the ledger says why" do
|
|
177
|
+ |
refute AtRest.encrypted_at_rest?()
|
|
178
|
+ |
assert length(AtRest.plaintext_private_columns()) > 0
|
|
179
|
+ |
end
|
|
180
|
+ |
|
|
181
|
+ |
test "the status projection publishes this value rather than a literal" do
|
|
182
|
+ |
# EXIT-006 derives `encrypted_at_rest` from this module. If the
|
|
183
|
+ |
# projection stops asking, this fails even though the published boolean
|
|
184
|
+ |
# does not change, which is the mutation the old literal could not catch.
|
|
185
|
+ |
section = OpenAgents.Forge.Independence.projection()["private_data"]
|
|
186
|
+ |
|
|
187
|
+ |
assert section["encrypted_at_rest"] == AtRest.encrypted_at_rest?()
|
|
188
|
+ |
assert section["operator_reads_source"] == not AtRest.encrypted_at_rest?()
|
|
189
|
+ |
end
|
|
190
|
+ |
|
|
191
|
+ |
test "the disclosure is compiled against this module, not against a literal" do
|
|
192
|
+ |
# Comparing the two values cannot catch a revert to `false`, because
|
|
193
|
+ |
# `false` is the answer today. The compiled import table can: the
|
|
194
|
+ |
# projection either reaches this module or it does not. Same read
|
|
195
|
+ |
# EXIT-006 already uses for `export_recipient_encryption`.
|
|
196
|
+ |
assert AtRest in external_calls(OpenAgents.Forge.Independence),
|
|
197
|
+ |
"EXIT-006 derives `encrypted_at_rest` from OpenAgents.Forge.AtRest. " <>
|
|
198
|
+ |
"OpenAgents.Forge.Independence no longer calls it, so the published " <>
|
|
199
|
+ |
"boolean is a literal again even though its value has not changed."
|
|
200
|
+ |
end
|
|
201
|
+ |
end
|
|
202
|
+ |
|
|
203
|
+ |
defp external_calls(module) do
|
|
204
|
+ |
case :beam_lib.chunks(:code.which(module), [:imports]) do
|
|
205
|
+ |
{:ok, {^module, [imports: imports]}} -> Enum.map(imports, &elem(&1, 0))
|
|
206
|
+ |
_unreadable -> []
|
|
207
|
+ |
end
|
|
208
|
+ |
end
|
|
209
|
+ |
|
|
210
|
+ |
# ── reading PostgreSQL rather than Ecto ──────────────────────────────────
|
|
211
|
+ |
|
|
212
|
+ |
defp raw_column(table, column, id) do
|
|
213
|
+ |
%{rows: [[value]]} =
|
|
214
|
+ |
Repo.query!("SELECT #{column} FROM #{table} WHERE id = $1", [dump_id(id)])
|
|
215
|
+ |
|
|
216
|
+ |
value
|
|
217
|
+ |
end
|
|
218
|
+ |
|
|
219
|
+ |
# Primary keys here are UUIDs or integers depending on the table.
|
|
220
|
+ |
defp dump_id(id) when is_integer(id), do: id
|
|
221
|
+ |
defp dump_id(id) when is_binary(id), do: Ecto.UUID.dump!(id)
|
|
222
|
+ |
|
|
223
|
+ |
defp contains?(nil, _needle), do: false
|
|
224
|
+ |
|
|
225
|
+ |
defp contains?(haystack, needle) when is_binary(haystack) and is_binary(needle),
|
|
226
|
+ |
do: :binary.match(haystack, needle) != :nomatch
|
|
227
|
+ |
|
|
228
|
+ |
defp column_exists?(table, column) do
|
|
229
|
+ |
%{rows: [[count]]} =
|
|
230
|
+ |
Repo.query!(
|
|
231
|
+ |
"""
|
|
232
|
+ |
SELECT count(*) FROM information_schema.columns
|
|
233
|
+ |
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
|
|
234
|
+ |
""",
|
|
235
|
+ |
[table, column]
|
|
236
|
+ |
)
|
|
237
|
+ |
|
|
238
|
+ |
count == 1
|
|
239
|
+ |
end
|
|
240
|
+ |
|
|
241
|
+ |
defp secret_shaped_columns do
|
|
242
|
+ |
%{rows: rows} =
|
|
243
|
+ |
Repo.query!(
|
|
244
|
+ |
"""
|
|
245
|
+ |
SELECT c.table_name, c.column_name
|
|
246
|
+ |
FROM information_schema.columns c
|
|
247
|
+ |
JOIN information_schema.tables t
|
|
248
|
+ |
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
249
|
+ |
WHERE c.table_schema = 'public'
|
|
250
|
+ |
AND t.table_type = 'BASE TABLE'
|
|
251
|
+ |
AND (c.column_name ~ $1 OR c.column_name LIKE '%\\_key')
|
|
252
|
+ |
""",
|
|
253
|
+ |
[AtRest.secret_shaped_pattern()]
|
|
254
|
+ |
)
|
|
255
|
+ |
|
|
256
|
+ |
Enum.map(rows, fn [table, column] -> {table, column} end)
|
|
257
|
+ |
end
|
|
258
|
+ |
|
|
259
|
+ |
# ── writing one real row per named plaintext column ──────────────────────
|
|
260
|
+ |
|
|
261
|
+ |
defp write_private_row(%{table: "messages", column: "content"}) do
|
|
262
|
+ |
content = "plaintext-message-#{System.unique_integer([:positive])}"
|
|
263
|
+ |
{:ok, conversation} = Conversations.ensure_conversation("at-rest-message")
|
|
264
|
+ |
{:ok, message} = Conversations.create_voice_context_message(conversation, content)
|
|
265
|
+ |
|
|
266
|
+ |
{message.id, content}
|
|
267
|
+ |
end
|
|
268
|
+ |
|
|
269
|
+ |
defp write_private_row(%{table: "voice_transcript_items", column: "content"}) do
|
|
270
|
+ |
# Voice creates these inside the sideband handler, so the insert goes
|
|
271
|
+ |
# through the schema's own changeset here. That is the layer under test:
|
|
272
|
+ |
# "no Ecto column is encrypted at rest" is a claim about the types a
|
|
273
|
+ |
# schema declares, and this is the type layer answering.
|
|
274
|
+ |
content = "plaintext-transcript-#{System.unique_integer([:positive])}"
|
|
275
|
+ |
session = admitted_voice_session("at-rest-transcript")
|
|
276
|
+ |
|
|
277
|
+ |
{:ok, item} =
|
|
278
|
+ |
%TranscriptItem{}
|
|
279
|
+ |
|> TranscriptItem.create_changeset(%{
|
|
280
|
+ |
voice_session_id: session.id,
|
|
281
|
+ |
generation: session.generation,
|
|
282
|
+ |
provider_item_id: "item-#{System.unique_integer([:positive])}",
|
|
283
|
+ |
role: "user",
|
|
284
|
+ |
content: content,
|
|
285
|
+ |
status: "final",
|
|
286
|
+ |
observed_at: DateTime.utc_now()
|
|
287
|
+ |
})
|
|
288
|
+ |
|> Repo.insert()
|
|
289
|
+ |
|
|
290
|
+ |
{item.id, content}
|
|
291
|
+ |
end
|
|
292
|
+ |
|
|
293
|
+ |
defp write_private_row(%{table: "issues", column: "body"}) do
|
|
294
|
+ |
body = "plaintext-issue-#{System.unique_integer([:positive])}"
|
|
295
|
+ |
repository = OpenAgents.AccountsFixtures.repository_fixture()
|
|
296
|
+ |
|
|
297
|
+ |
{:ok, issue} =
|
|
298
|
+ |
OpenAgents.Issues.create_issue(repository, %{title: "at rest", body: body})
|
|
299
|
+ |
|
|
300
|
+ |
{issue.id, body}
|
|
301
|
+ |
end
|
|
302
|
+ |
|
|
303
|
+ |
defp write_private_row(%{table: "comments", column: "body"}) do
|
|
304
|
+ |
body = "plaintext-comment-#{System.unique_integer([:positive])}"
|
|
305
|
+ |
user = github_user("at-rest-comment")
|
|
306
|
+ |
repository = OpenAgents.AccountsFixtures.repository_fixture()
|
|
307
|
+ |
{:ok, issue} = OpenAgents.Issues.create_issue(repository, %{title: "at rest"})
|
|
308
|
+ |
{:ok, comment} = OpenAgents.Issues.create_comment(issue, %{"body" => body}, user)
|
|
309
|
+ |
|
|
310
|
+ |
{comment.id, body}
|
|
311
|
+ |
end
|
|
312
|
+ |
|
|
313
|
+ |
# ── fixtures ─────────────────────────────────────────────────────────────
|
|
314
|
+ |
|
|
315
|
+ |
defp github_profile(key) do
|
|
316
|
+ |
digest = :crypto.hash(:sha256, key)
|
|
317
|
+ |
|
|
318
|
+ |
%{
|
|
319
|
+ |
github_id: digest |> binary_part(0, 7) |> :binary.decode_unsigned(),
|
|
320
|
+ |
github_login: "at-rest-#{Base.encode16(digest, case: :lower) |> binary_part(0, 10)}",
|
|
321
|
+ |
github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
|
|
322
|
+ |
}
|
|
323
|
+ |
end
|
|
324
|
+ |
|
|
325
|
+ |
defp github_user(key) do
|
|
326
|
+ |
{:ok, user} = Accounts.upsert_github_user(github_profile(key))
|
|
327
|
+ |
user
|
|
328
|
+ |
end
|
|
329
|
+ |
|
|
330
|
+ |
defp admitted_voice_session(key) do
|
|
331
|
+ |
{:ok, conversation} = Conversations.ensure_conversation(key)
|
|
332
|
+ |
{:ok, session} = Voice.admit_session(conversation, voice_config())
|
|
333
|
+ |
session
|
|
334
|
+ |
end
|
|
335
|
+ |
|
|
336
|
+ |
defp voice_config do
|
|
337
|
+ |
Config.build!(
|
|
338
|
+ |
enabled: true,
|
|
339
|
+ |
architecture: :openai_realtime,
|
|
340
|
+ |
provider: "openai",
|
|
341
|
+ |
model: "gpt-realtime-2.1",
|
|
342
|
+ |
voice: "marin",
|
|
343
|
+ |
reasoning_effort: "low",
|
|
344
|
+ |
maximum_session_seconds: 3_000
|
|
345
|
+ |
)
|
|
346
|
+ |
end
|
|
347
|
+ |
end
|