Skip to content

Commit 6c863d2

Browse files
hyperpolymathclaude
andcommitted
feat(auth): Option A credential forwarding — X25519 + ChaCha20-Poly1305
Implements docs/AUTH-DESIGN.adoc Option A: per-invocation credentials encrypted with the node's X25519 public key, decrypted and injected as env vars into the Deno/boj-invoke subprocess for that call only. BojRest.NodeKey (GenServer): Loads or generates the node's long-lived X25519 keypair at startup. Resolution order: BOJ_NODE_PRIVATE_KEY env → BOJ_NODE_KEY_FILE env → <data_dir>/boj-node.key (auto-generated + persisted) → ephemeral. BojRest.CredentialDecryptor: Two modes: - Encrypted (remote callers): {v,encrypted,caller_pubkey,nonce,ciphertext} → X25519 ECDH shared secret → ChaCha20-Poly1305 decrypt → env map - Plaintext (loopback only): {"KEY": "value"} map accepted from 127.x/::1 Router: - POST /invoke: extracts credentials, calls CredentialDecryptor, passes decrypted env map to dispatch/4. Non-loopback plaintext → 403. - GET /.well-known/boj-node-pubkey: returns base64url node pubkey so callers can discover it without out-of-band config. Invoker + JsInvoker: accept extra_env map, passed as env: overrides to System.cmd — credentials live only for the subprocess lifetime. Application: starts NodeKey before Catalog and HTTP listener. All crypto uses OTP :crypto (OTP 27) — zero new dependencies. Round-trip verified: X25519 ECDH + ChaCha20-Poly1305 PASS. Path to Option C: replace CredentialDecryptor.extract/2 with a RGTV broker token exchange when RGTV is production-ready. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3cac5f3 commit 6c863d2

6 files changed

Lines changed: 323 additions & 28 deletions

File tree

elixir/lib/boj_rest/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ defmodule BojRest.Application do
1515
port = Application.get_env(:boj_rest, :port, 7700)
1616
cartridges_root = Application.get_env(:boj_rest, :cartridges_root)
1717

18+
data_dir = Application.get_env(:boj_rest, :data_dir, "/data")
19+
1820
children = [
21+
{BojRest.NodeKey, data_dir: data_dir},
1922
{BojRest.Catalog, cartridges_root: cartridges_root},
2023
{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port]}
2124
]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.CredentialDecryptor do
4+
@moduledoc """
5+
Decrypts per-invocation credentials from a POST /cartridge/:name/invoke body.
6+
7+
Wire format for ENCRYPTED credentials (remote callers):
8+
9+
"credentials": {
10+
"v": 1,
11+
"encrypted": true,
12+
"caller_pubkey": "<base64url 32-byte X25519 public key>",
13+
"nonce": "<base64url 12-byte ChaCha20-Poly1305 nonce>",
14+
"ciphertext": "<base64url encrypted JSON + 16-byte Poly1305 tag>"
15+
}
16+
17+
The plaintext that was encrypted is a JSON object of env-var name → value:
18+
19+
{"GITHUB_TOKEN": "ghp_xxxx", "OTHER_KEY": "value"}
20+
21+
ECDH shared secret derivation:
22+
shared = X25519(node_private_key, caller_pubkey)
23+
plaintext = ChaCha20-Poly1305-Decrypt(shared[0..31], nonce, ciphertext)
24+
25+
Wire format for PLAINTEXT credentials (loopback callers only):
26+
27+
"credentials": {"GITHUB_TOKEN": "ghp_xxxx"}
28+
29+
Plaintext is only accepted when the caller's remote IP is 127.0.0.1 or ::1.
30+
Any attempt to send plaintext credentials from a non-loopback address is
31+
rejected with 403.
32+
33+
Returns {:ok, env_map} | {:error, reason_string}.
34+
"""
35+
36+
@aad "boj-invoke-v1"
37+
38+
@type env_map :: %{String.t() => String.t()}
39+
@type result :: {:ok, env_map()} | {:error, String.t()}
40+
41+
@doc """
42+
Extract and decrypt credentials from a request body map.
43+
44+
`is_local` should be true when `conn.remote_ip` is a loopback address.
45+
Returns `{:ok, %{}}` (empty map) when no credentials field is present.
46+
"""
47+
@spec extract(map(), boolean()) :: result()
48+
def extract(body, is_local) do
49+
case Map.get(body, "credentials") do
50+
nil ->
51+
{:ok, %{}}
52+
53+
%{"encrypted" => true} = enc ->
54+
decrypt(enc)
55+
56+
plain when is_map(plain) ->
57+
if is_local do
58+
validate_plain(plain)
59+
else
60+
{:error, "plaintext credentials are only accepted from loopback — use encrypted form for remote callers"}
61+
end
62+
63+
_ ->
64+
{:error, "credentials must be a JSON object"}
65+
end
66+
end
67+
68+
# ── encrypted path ────────────────────────────────────────────────────────
69+
70+
defp decrypt(%{"v" => v}) when v != 1 do
71+
{:error, "unsupported credential encryption version #{v}"}
72+
end
73+
74+
defp decrypt(%{"caller_pubkey" => b64_pub, "nonce" => b64_nonce, "ciphertext" => b64_ct}) do
75+
with {:pub, {:ok, caller_pub}} <- {:pub, Base.url_decode64(b64_pub, padding: false)},
76+
{:pub_len, true} <- {:pub_len, byte_size(caller_pub) == 32},
77+
{:nonce, {:ok, nonce}} <- {:nonce, Base.url_decode64(b64_nonce, padding: false)},
78+
{:nonce_len, true} <- {:nonce_len, byte_size(nonce) == 12},
79+
{:ct, {:ok, ct_with_tag}} <- {:ct, Base.url_decode64(b64_ct, padding: false)},
80+
{:ct_len, true} <- {:ct_len, byte_size(ct_with_tag) > 16} do
81+
tag_offset = byte_size(ct_with_tag) - 16
82+
<<ciphertext::binary-size(tag_offset), tag::binary-size(16)>> = ct_with_tag
83+
84+
node_priv = BojRest.NodeKey.private_key()
85+
shared = :crypto.compute_key(:ecdh, caller_pub, node_priv, :x25519)
86+
87+
case :crypto.crypto_one_time_aead(
88+
:chacha20_poly1305,
89+
shared,
90+
nonce,
91+
ciphertext,
92+
@aad,
93+
tag,
94+
false
95+
) do
96+
plaintext when is_binary(plaintext) ->
97+
case Jason.decode(plaintext) do
98+
{:ok, map} -> validate_plain(map)
99+
{:error, _} -> {:error, "decrypted credentials are not valid JSON"}
100+
end
101+
102+
:error ->
103+
{:error, "credential decryption failed — wrong node key or tampered ciphertext"}
104+
end
105+
else
106+
{:pub, _} -> {:error, "caller_pubkey is not valid base64url"}
107+
{:pub_len, _} -> {:error, "caller_pubkey must be 32 bytes"}
108+
{:nonce, _} -> {:error, "nonce is not valid base64url"}
109+
{:nonce_len, _} -> {:error, "nonce must be 12 bytes"}
110+
{:ct, _} -> {:error, "ciphertext is not valid base64url"}
111+
{:ct_len, _} -> {:error, "ciphertext too short"}
112+
end
113+
end
114+
115+
defp decrypt(_), do: {:error, "encrypted credentials missing caller_pubkey, nonce, or ciphertext"}
116+
117+
# ── plaintext validation ──────────────────────────────────────────────────
118+
119+
# Ensure the plain map is string→string (env var names and values only).
120+
defp validate_plain(map) do
121+
if Enum.all?(map, fn {k, v} -> is_binary(k) and is_binary(v) end) do
122+
{:ok, map}
123+
else
124+
{:error, "credential keys and values must all be strings"}
125+
end
126+
end
127+
end

elixir/lib/boj_rest/invoker.ex

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,23 @@ defmodule BojRest.Invoker do
5757

5858
@doc """
5959
Invoke a tool on a cartridge: runs init, calls invoke, runs deinit.
60+
61+
`extra_env` is an optional map of env-var name → value injected into the
62+
boj-invoke subprocess for this invocation only (Option A credential
63+
forwarding). Vars are merged over the inherited environment.
64+
6065
Returns `{:ok, map()}` on success (JSON output parsed).
6166
"""
62-
@spec invoke(so_path(), String.t(), map()) :: result()
63-
def invoke(so_path, tool_name, args) do
67+
@spec invoke(so_path(), String.t(), map(), map()) :: result()
68+
def invoke(so_path, tool_name, args, extra_env \\ %{}) do
6469
args_json = Jason.encode!(args)
65-
run(so_path, "invoke", [tool_name, args_json])
70+
run(so_path, "invoke", [tool_name, args_json], extra_env)
6671
end
6772

6873
# ── internals ───────────────────────────────────────────────────────
6974

70-
@spec run(so_path(), String.t(), [String.t()]) :: result()
71-
defp run(so_path, verb, extra_args \\ []) do
75+
@spec run(so_path(), String.t(), [String.t()], map()) :: result()
76+
defp run(so_path, verb, extra_args \\ [], extra_env \\ %{}) do
7277
cli = cli_path()
7378

7479
if cli == nil do
@@ -79,11 +84,10 @@ defmodule BojRest.Invoker do
7984
body: "boj-invoke CLI not found — run `zig build invoke` in ffi/zig/"
8085
}}
8186
else
82-
# stderr_to_stdout merges the CLI's error-path JSON (which it emits on
83-
# stderr) into stdout so we get it in the `{output, exit_code}` tuple.
8487
cmd_args = [so_path, verb] ++ extra_args
88+
env_overrides = Enum.map(extra_env, fn {k, v} -> {k, v} end)
8589

86-
case System.cmd(cli, cmd_args, stderr_to_stdout: true) do
90+
case System.cmd(cli, cmd_args, stderr_to_stdout: true, env: env_overrides) do
8791
{stdout, @exit_ok} ->
8892
case Jason.decode(stdout) do
8993
{:ok, map} -> {:ok, map}

elixir/lib/boj_rest/js_invoker.ex

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,14 @@ defmodule BojRest.JsInvoker do
3939
@doc """
4040
Invoke `tool_name` on the cartridge at `mod_js_path` with `args`.
4141
42+
`extra_env` is an optional map of env-var name → value injected into the
43+
Deno subprocess for this invocation only (Option A credential forwarding).
44+
These override any identically-named vars already in the process environment.
45+
4246
Returns `{:ok, data_map}` on success or `{:error, info_map}` on failure.
4347
"""
44-
@spec invoke(String.t(), String.t() | nil, map()) :: result()
45-
def invoke(mod_js_path, tool_name, args) do
48+
@spec invoke(String.t(), String.t() | nil, map(), map()) :: result()
49+
def invoke(mod_js_path, tool_name, args, extra_env \\ %{}) do
4650
with {:deno, deno} when deno != nil <- {:deno, deno_path()},
4751
{:runner, runner} when runner != nil <- {:runner, runner_path()},
4852
{:mod, true} <- {:mod, File.regular?(mod_js_path)} do
@@ -59,7 +63,14 @@ defmodule BojRest.JsInvoker do
5963
args_json
6064
]
6165

62-
task = Task.async(fn -> System.cmd(deno, cmd_args, stderr_to_stdout: true) end)
66+
# Merge extra_env into the inherited process environment so credential
67+
# vars (e.g. GITHUB_TOKEN) are visible to the mod.js without being
68+
# stored anywhere — they exist only for the lifetime of the subprocess.
69+
env_overrides = Enum.map(extra_env, fn {k, v} -> {k, v} end)
70+
71+
task = Task.async(fn ->
72+
System.cmd(deno, cmd_args, stderr_to_stdout: true, env: env_overrides)
73+
end)
6374

6475
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
6576
{:ok, {stdout, 0}} ->

elixir/lib/boj_rest/node_key.ex

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.NodeKey do
4+
@moduledoc """
5+
Holds the node's long-lived X25519 keypair used for credential encryption
6+
under Option A of the auth design (docs/AUTH-DESIGN.adoc).
7+
8+
Callers encrypt their credentials with:
9+
shared_secret = X25519(their_privkey, node_pubkey)
10+
ciphertext = ChaCha20-Poly1305(shared_secret, nonce, plaintext_credentials_json)
11+
12+
The node decrypts by deriving the same shared secret from its private key
13+
and the caller's public key (included in the request).
14+
15+
Key persistence order:
16+
1. BOJ_NODE_PRIVATE_KEY env var — base64url-encoded 32-byte scalar
17+
2. BOJ_NODE_KEY_FILE env var — path to a file containing the base64url scalar
18+
3. <data_dir>/boj-node.key — auto-generated and persisted on first boot
19+
4. In-memory ephemeral — fallback if data_dir is not writable (dev)
20+
21+
The public key is served at GET /.well-known/boj-node-pubkey so callers
22+
can discover it without out-of-band configuration.
23+
"""
24+
use GenServer
25+
require Logger
26+
27+
@table :boj_node_key
28+
29+
# ── public API ────────────────────────────────────────────────────────────
30+
31+
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
32+
33+
@doc "Return the node's X25519 public key as a 32-byte binary."
34+
@spec public_key() :: binary()
35+
def public_key do
36+
[{:public, pub}] = :ets.lookup(@table, :public)
37+
pub
38+
end
39+
40+
@doc "Return the node's X25519 private key (scalar) as a 32-byte binary."
41+
@spec private_key() :: binary()
42+
def private_key do
43+
[{:private, priv}] = :ets.lookup(@table, :private)
44+
priv
45+
end
46+
47+
# ── GenServer callbacks ────────────────────────────────────────────────────
48+
49+
@impl true
50+
def init(opts) do
51+
:ets.new(@table, [:named_table, :protected, read_concurrency: true])
52+
data_dir = Keyword.get(opts, :data_dir, "/data")
53+
54+
{pub, priv} = load_or_generate(data_dir)
55+
:ets.insert(@table, {:public, pub})
56+
:ets.insert(@table, {:private, priv})
57+
58+
Logger.info("BoJ node key loaded — pubkey #{Base.url_encode64(pub, padding: false)}")
59+
{:ok, %{pub: pub}}
60+
end
61+
62+
# ── key management ────────────────────────────────────────────────────────
63+
64+
defp load_or_generate(data_dir) do
65+
case load_from_env() do
66+
{:ok, priv} ->
67+
{derive_public(priv), priv}
68+
69+
:not_found ->
70+
key_file = Path.join(data_dir, "boj-node.key")
71+
72+
case load_from_file(key_file) do
73+
{:ok, priv} ->
74+
{derive_public(priv), priv}
75+
76+
:not_found ->
77+
generate_and_persist(key_file)
78+
end
79+
end
80+
end
81+
82+
defp load_from_env do
83+
case System.get_env("BOJ_NODE_PRIVATE_KEY") do
84+
nil ->
85+
case System.get_env("BOJ_NODE_KEY_FILE") do
86+
nil -> :not_found
87+
path -> load_from_file(path)
88+
end
89+
90+
b64 ->
91+
case Base.url_decode64(b64, padding: false) do
92+
{:ok, priv} when byte_size(priv) == 32 -> {:ok, priv}
93+
_ -> :not_found
94+
end
95+
end
96+
end
97+
98+
defp load_from_file(path) do
99+
case File.read(path) do
100+
{:ok, content} ->
101+
b64 = String.trim(content)
102+
103+
case Base.url_decode64(b64, padding: false) do
104+
{:ok, priv} when byte_size(priv) == 32 ->
105+
Logger.debug("Node key loaded from #{path}")
106+
{:ok, priv}
107+
108+
_ ->
109+
Logger.warning("Node key file #{path} is malformed — regenerating")
110+
:not_found
111+
end
112+
113+
{:error, _} ->
114+
:not_found
115+
end
116+
end
117+
118+
defp generate_and_persist(key_file) do
119+
{pub, priv} = :crypto.generate_key(:ecdh, :x25519)
120+
121+
case File.write(key_file, Base.url_encode64(priv, padding: false)) do
122+
:ok ->
123+
Logger.info("Generated new node key → #{key_file}")
124+
125+
{:error, reason} ->
126+
Logger.warning("Could not persist node key to #{key_file}: #{inspect(reason)} — using ephemeral key")
127+
end
128+
129+
{pub, priv}
130+
end
131+
132+
defp derive_public(priv) do
133+
# OTP :crypto does not expose a standalone scalar-mult for x25519;
134+
# generate_key with a provided private key is the supported path.
135+
{pub, ^priv} = :crypto.generate_key(:ecdh, :x25519, priv)
136+
pub
137+
end
138+
end

0 commit comments

Comments
 (0)