Packages
AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.
Current section
Files
Jump to
Current section
Files
lib/raxol/agent/backend/lumo/crypto.ex
defmodule Raxol.Agent.Backend.Lumo.Crypto do
@moduledoc """
Cryptographic operations for Proton Lumo's U2L (User-to-LLM) encryption.
Handles:
- AES-256-GCM encryption/decryption with Associated Data (AEAD)
- PGP encryption of the per-request AES key to Lumo's public key (via gpg)
The protocol: each request generates a fresh AES-256-GCM key. Message turns
are encrypted with this key using AD string `lumo.request.{id}.turn`.
The key itself is PGP-encrypted to Lumo's ECDH public key. Response chunks
arrive encrypted and are decrypted with the same key using AD string
`lumo.response.{id}.chunk`.
"""
@aes_key_length 32
@iv_length 12
@tag_length 16
# Proton Lumo production PGP public key (ECDH Curve25519)
# Fingerprint: F032A1169DDFF8EDA728E59A9A74C3EF61514A2A
# Subkey (encryption): DA656791F790D0A297C79CCEE7DF128A8FC5DE04
@lumo_pub_key """
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEaA9k7RYJKwYBBAHaRw8BAQdABaPA24xROahXs66iuekwPmdOpJbPE1a8A69r
siWP8rfNL1Byb3RvbiBMdW1vIChQcm9kIEtleSAwMDAyKSA8c3VwcG9ydEBwcm90
b24ubWU+wpkEExYKAEEWIQTwMqEWnd/47aco5ZqadMPvYVFKKgUCaA9k7QIbAwUJ
B4TOAAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCadMPvYVFKKqiVAQD7
JNeudEXTaNMoQMkYjcutNwNAalwbLr5qe6N5rPogDQD/bA5KBWmDlvxVz7If6SBS
7Xzcvk8VMHYkBLKfh+bfUQzOOARoD2TtEgorBgEEAZdVAQUBAQdAnBIJoFt6Pxnp
RAJMHwhdCXaE+lwQFbKgwb6LCUFWvHYDAQgHwn4EGBYKACYWIQTwMqEWnd/47aco
5ZqadMPvYVFKKgUCaA9k7QIbDAUJB4TOAAAKCRCadMPvYVFKKkuRAQChUthLyAcc
UD6UrJkroc6exHIMSR5Vlk4d4L8OeFUWWAEA3ugyE/b/pSQ4WO+fiTkHN2ZeKlyj
dZMbxO6yWPA5uQk=
=h/mc
-----END PGP PUBLIC KEY BLOCK-----
"""
@doc "Returns Lumo's production PGP public key."
@spec lumo_pub_key() :: String.t()
def lumo_pub_key, do: @lumo_pub_key
@doc "Generate a random 32-byte AES-256-GCM key."
@spec generate_request_key() :: binary()
def generate_request_key do
:crypto.strong_rand_bytes(@aes_key_length)
end
@doc "Generate a UUID v4 request ID."
@spec generate_request_id() :: String.t()
def generate_request_id do
<<a::48, _::4, b::12, _::2, c::62>> = :crypto.strong_rand_bytes(16)
<<a::48, 4::4, b::12, 2::2, c::62>>
|> Base.encode16(case: :lower)
|> then(fn hex ->
<<g1::binary-8, g2::binary-4, g3::binary-4, g4::binary-4, g5::binary-12>> =
hex
"#{g1}-#{g2}-#{g3}-#{g4}-#{g5}"
end)
end
@doc """
Encrypt plaintext with AES-256-GCM.
Returns the concatenation of IV (12 bytes) + ciphertext + tag (16 bytes),
base64-encoded.
"""
@spec encrypt(binary(), <<_::256>>, binary() | nil) :: binary()
def encrypt(plaintext, key, ad \\ nil)
def encrypt(_plaintext, key, _ad) when byte_size(key) != @aes_key_length do
raise ArgumentError,
"expected 32-byte AES key, got #{byte_size(key)} bytes"
end
def encrypt(plaintext, key, ad) when byte_size(key) == @aes_key_length do
iv = :crypto.strong_rand_bytes(@iv_length)
aad = if ad, do: ad, else: <<>>
{ciphertext, tag} =
:crypto.crypto_one_time_aead(
:aes_256_gcm,
key,
iv,
plaintext,
aad,
@tag_length,
true
)
Base.encode64(iv <> ciphertext <> tag)
end
@doc """
Decrypt base64-encoded AES-256-GCM ciphertext.
Expects the format: base64(IV || ciphertext || tag).
"""
@spec decrypt(binary(), <<_::256>>, binary() | nil) ::
{:ok, binary()} | {:error, :decryption_failed | :invalid_base64}
def decrypt(encrypted_b64, key, ad \\ nil)
def decrypt(_encrypted_b64, key, _ad)
when byte_size(key) != @aes_key_length do
raise ArgumentError,
"expected 32-byte AES key, got #{byte_size(key)} bytes"
end
def decrypt(encrypted_b64, key, ad) when byte_size(key) == @aes_key_length do
case Base.decode64(encrypted_b64) do
{:ok, raw} ->
<<iv::binary-size(@iv_length), rest::binary>> = raw
ct_len = byte_size(rest) - @tag_length
<<ciphertext::binary-size(ct_len), tag::binary-size(@tag_length)>> =
rest
aad = if ad, do: ad, else: <<>>
case :crypto.crypto_one_time_aead(
:aes_256_gcm,
key,
iv,
ciphertext,
aad,
tag,
false
) do
:error -> {:error, :decryption_failed}
plaintext -> {:ok, plaintext}
end
:error ->
{:error, :invalid_base64}
end
end
@doc """
Encrypt a turn's content for Lumo U2L.
Uses AD string `lumo.request.{request_id}.turn`.
"""
@spec encrypt_turn_content(binary(), binary(), String.t()) :: binary()
def encrypt_turn_content(content, key, request_id) do
ad = "lumo.request.#{request_id}.turn"
encrypt(content, key, ad)
end
@doc """
Decrypt a response chunk from Lumo.
Uses AD string `lumo.response.{request_id}.chunk`.
"""
@spec decrypt_chunk(binary(), binary(), String.t()) ::
{:ok, binary()} | {:error, :decryption_failed}
def decrypt_chunk(encrypted_b64, key, request_id) do
ad = "lumo.response.#{request_id}.chunk"
decrypt(encrypted_b64, key, ad)
end
@doc """
Encrypt the AES request key to Lumo's PGP public key using gpg.
Returns `{:ok, base64_encrypted_key}` or `{:error, reason}`.
Requires `gpg` (GnuPG) to be installed. Uses a temporary homedir
to avoid polluting the user's keyring.
"""
@spec encrypt_request_key(binary()) :: {:ok, binary()} | {:error, String.t()}
def encrypt_request_key(raw_key) when byte_size(raw_key) == @aes_key_length do
tmpdir =
Path.join(
System.tmp_dir!(),
"raxol-lumo-gpg-#{System.unique_integer([:positive])}"
)
try do
File.mkdir_p!(tmpdir)
File.chmod!(tmpdir, 0o700)
pubkey_path = Path.join(tmpdir, "lumo.pub")
input_path = Path.join(tmpdir, "key.bin")
output_path = Path.join(tmpdir, "key.bin.gpg")
File.write!(pubkey_path, @lumo_pub_key)
File.write!(input_path, raw_key)
File.chmod!(input_path, 0o600)
# Import the key
{_, 0} =
System.cmd(
"gpg",
[
"--homedir",
tmpdir,
"--batch",
"--quiet",
"--import",
pubkey_path
],
stderr_to_stdout: true
)
# Encrypt the raw key bytes to the Lumo subkey
{_, 0} =
System.cmd(
"gpg",
[
"--homedir",
tmpdir,
"--batch",
"--quiet",
"--yes",
"--trust-model",
"always",
"--encrypt",
"--recipient",
"F032A1169DDFF8EDA728E59A9A74C3EF61514A2A",
"--output",
output_path,
input_path
],
stderr_to_stdout: true
)
encrypted = File.read!(output_path)
{:ok, Base.encode64(encrypted)}
rescue
e -> {:error, Exception.message(e)}
after
File.rm_rf(tmpdir)
end
end
@doc """
Check if gpg is available on the system.
"""
@spec gpg_available?() :: boolean()
def gpg_available? do
case System.cmd("gpg", ["--version"], stderr_to_stdout: true) do
{_, 0} -> true
_ -> false
end
rescue
ErlangError -> false
end
end