diff --git a/docker-compose.yml b/docker-compose.yml index f509178..094ce82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: - DATABASE_URL=postgres://atrecord:atrecord@db:5432/atrecord # Stable session signing secret. CHANGE THIS for any real deployment. - SECRET_KEY_BASE=dev-only-change-me-to-a-long-random-string + # At-rest encryption key for stored tokens (base64, 32 bytes; generate + # with `openssl rand -base64 32`). CHANGE THIS for any real deployment. + - STORE_KEY=${STORE_KEY:-ZGV2LW9ubHktMzItYnl0ZS1zdG9yZS1rZXktISEhISE=} # Discogs app credential (from .env, gitignored). Empty = search still # works, just without cover thumbnails. - DISCOGS_CONSUMER_KEY=${DISCOGS_CONSUMER_KEY:-} diff --git a/scripts/dev.sh b/scripts/dev.sh index 5599819..c5d3fba 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -18,6 +18,8 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." log() { printf '\033[36m[dev]\033[0m %s\n' "$*"; } # Stable secret so sessions survive the frequent restarts this loop does. export SECRET_KEY_BASE="${SECRET_KEY_BASE:-dev-only-change-me-to-a-long-random-string}" +# At-rest encryption key for stored tokens (required; base64, 32 bytes). +export STORE_KEY="${STORE_KEY:-ZGV2LW9ubHktMzItYnl0ZS1zdG9yZS1rZXktISEhISE=}" # BASE_URL left unset => http://localhost:8080 => OAuth redirect to 127.0.0.1:8080. free_8080() { diff --git a/server/src/at_record_server/config_env.gleam b/server/src/at_record_server/config_env.gleam index d69b743..74bef14 100644 --- a/server/src/at_record_server/config_env.gleam +++ b/server/src/at_record_server/config_env.gleam @@ -1,17 +1,19 @@ -//// Startup configuration resolved from the environment. Each reader has a -//// sensible dev fallback and logs when it falls back, so `main` stays pure -//// orchestration. +//// Startup configuration resolved from the environment, so `main` stays pure +//// orchestration. Secrets (SECRET_KEY_BASE, STORE_KEY) are required; the rest +//// falls back to dev defaults with a log line. import at_record_server/discogs_client import at_record_server/oauth/sessions.{type Store} import at_record_server/oauth/sessions_memory import at_record_server/oauth/sessions_postgres +import at_record_server/sealed_store import atproto/identity import envoy import gleam/erlang/application import gleam/int import gleam/option.{type Option} import gleam/result +import gose import wisp const default_port = 8080 @@ -20,17 +22,13 @@ pub fn port() -> Int { env_int("PORT", default_port) } -/// Stable across restarts when set, so sessions survive a redeploy. Falls back -/// to a random secret in dev (where losing sessions on restart is fine). +/// The cookie signing secret. Required; a random fallback would hide +/// misconfiguration and drop sessions on every restart. pub fn secret_key_base() -> String { case envoy.get("SECRET_KEY_BASE") { Ok(secret) -> secret - Error(Nil) -> { - wisp.log_warning( - "SECRET_KEY_BASE unset: using a random secret (sessions drop on restart)", - ) - wisp.random_string(64) - } + Error(Nil) -> + panic as "SECRET_KEY_BASE unset: set a long random signing secret" } } @@ -57,10 +55,31 @@ pub fn static_directory() -> String { priv <> "/static" } -/// The OAuth session store and the Discogs credential store, sharing one -/// Postgres pool when DATABASE_URL is set. Discogs rows never expire (the -/// tokens are durable), so that table gets no sweep ttl. +/// The session store and the Discogs credential store: one shared Postgres +/// pool, no sweep ttl on the durable Discogs tokens, both encrypted at rest. pub fn stores() -> #(Store, Store) { + let key = store_key() + let #(session_store, discogs_store) = raw_stores() + #( + sealed_store.wrap(session_store, key), + sealed_store.wrap(discogs_store, key), + ) +} + +// Required; no plaintext fallback. Generate with `openssl rand -base64 32`. +fn store_key() -> gose.Key(String) { + case envoy.get("STORE_KEY") { + Ok(encoded) -> + case sealed_store.key_from_base64(encoded) { + Ok(key) -> key + Error(e) -> panic as { "STORE_KEY invalid: " <> e } + } + Error(Nil) -> + panic as "STORE_KEY unset: set a base64 32-byte key (openssl rand -base64 32)" + } +} + +fn raw_stores() -> #(Store, Store) { case envoy.get("DATABASE_URL") { Ok(url) -> case postgres_stores(url) { diff --git a/server/src/at_record_server/discogs_client.gleam b/server/src/at_record_server/discogs_client.gleam index 70535d0..1dfd5c7 100644 --- a/server/src/at_record_server/discogs_client.gleam +++ b/server/src/at_record_server/discogs_client.gleam @@ -163,9 +163,8 @@ fn option_then(o: Option(a), f: fn(a) -> Option(b)) -> Option(b) { } } -/// One page of the connected user's collection (folder 0 = All), normalized to -/// the same `Release` shape the search returns. The caller supplies the signed -/// `Authorization: OAuth ...` header, since collection reads act as that user. +/// One page of the connected user's collection (folder 0 = All). The caller +/// supplies the signed OAuth header, since collection reads act as that user. pub fn collection_page( send: Sender, authorization: String, @@ -246,9 +245,8 @@ fn basic_information_decoder() -> decode.Decoder(Release) { )) } -/// Split a page into the releases to write (unseen, within the remaining write -/// budget) and the count skipped as already in the crate. The returned set has -/// the taken ids added, so re-listed releases dedup within a run too. +/// Split a page into releases to write (unseen, within budget) and the count +/// skipped as already present. Taken ids join `seen`, deduping within the run. pub fn plan_import( items: List(Release), seen: Set(String), diff --git a/server/src/at_record_server/discogs_creds.gleam b/server/src/at_record_server/discogs_creds.gleam index 3558494..b12e48a 100644 --- a/server/src/at_record_server/discogs_creds.gleam +++ b/server/src/at_record_server/discogs_creds.gleam @@ -1,6 +1,5 @@ //// Per-user Discogs credentials (durable OAuth 1.0a access token + username), -//// keyed by the user's DID through a generic `sessions.Store` backend. Like the -//// atproto tokens these are plaintext at rest; encrypt before multi-user prod. +//// keyed by the user's DID through a generic `sessions.Store` backend. import at_record_server/discogs_oauth.{type TokenPair, TokenPair} import at_record_server/oauth/sessions.{type Store} diff --git a/server/src/at_record_server/discogs_oauth.gleam b/server/src/at_record_server/discogs_oauth.gleam index 325e0ef..691bb6a 100644 --- a/server/src/at_record_server/discogs_oauth.gleam +++ b/server/src/at_record_server/discogs_oauth.gleam @@ -26,9 +26,8 @@ pub type TokenPair { TokenPair(token: String, secret: String) } -/// Build the `Authorization: OAuth ...` header value. Pure so tests can pin the -/// nonce and timestamp; the PLAINTEXT signature is `consumer_secret&token_secret` -/// (empty token secret before the request-token step). +/// The `Authorization: OAuth ...` header value. Pure so tests can pin the +/// nonce and timestamp. pub fn auth_header( auth: Auth, token: Option(TokenPair), @@ -95,8 +94,7 @@ pub fn authorize_url(request_token: String) -> String { <> uri.percent_encode(request_token) } -/// Step 4: swap the approved request token + verifier for the durable access -/// token. Needs the request-token secret stashed across the redirect. +/// Step 4: swap the approved request token + verifier for the durable access token. pub fn access_token( send: Sender, auth: Auth, diff --git a/server/src/at_record_server/handlers/discogs.gleam b/server/src/at_record_server/handlers/discogs.gleam index 36cd4a8..793ec43 100644 --- a/server/src/at_record_server/handlers/discogs.gleam +++ b/server/src/at_record_server/handlers/discogs.gleam @@ -1,8 +1,7 @@ -//// Discogs handlers: the public seed search, the OAuth 1.0a account -//// connection (connect/callback/disconnect/status), and the collection import -//// that replays a user's Discogs collection as shelf.entry genesis events. -//// Imports are capped per run and dedup by the discogs external id, so a -//// capped or interrupted run resumes by simply running again. +//// Discogs handlers: seed search, account connection (OAuth 1.0a), and the +//// collection import that replays a Discogs collection as shelf.entry genesis +//// events. Imports dedup by discogs external id, so any capped or interrupted +//// run resumes by running again. import at_record/gen/defs.{ExternalId, Snapshot} import at_record/gen/shelf/entry.{ShelfEntry, encode_shelf_entry} @@ -116,8 +115,8 @@ pub fn callback(req: Request, ctx: Context) -> Response { list.key_find(query, "oauth_verifier"), pending_from_cookie(req) { - // The returned token must equal the one stashed at connect time: binds the - // callback to the browser that initiated it (the OAuth 1.0a CSRF check). + // Token must match the connect-time cookie: binds the callback to the + // initiating browser (CSRF). Ok(token), Ok(verifier), Some(pending) if pending.token == token -> complete_connect(req, ctx, auth, session, pending, verifier) _, _, _ -> error_json(400, "invalid discogs callback") @@ -333,8 +332,7 @@ fn import_pages( } } -/// Write genesis events one by one, stopping at the first failure so a rate -/// limit surfaces as a partial (resumable) run instead of a hard error. +// Stop at the first failure so a rate limit becomes a partial, resumable run. fn write_releases( client: Client, session: OauthSession, diff --git a/server/src/at_record_server/sealed_store.gleam b/server/src/at_record_server/sealed_store.gleam new file mode 100644 index 0000000..d7817a4 --- /dev/null +++ b/server/src/at_record_server/sealed_store.gleam @@ -0,0 +1,60 @@ +//// Encryption at rest for any `sessions.Store`: values are sealed to compact +//// A256GCM JWEs (direct mode, via gose) before reaching the backend. Rows +//// that fail to open (tampered, rotated key) read as absent, i.e. logged out. + +import at_record_server/oauth/sessions.{type Store, Store} +import gleam/bit_array +import gleam/int +import gleam/option.{type Option} +import gleam/result +import gose +import gose/jose/jwe + +pub fn wrap(store: Store, key: gose.Key(String)) -> Store { + Store( + save: fn(id, value) { + seal(key, value) |> result.try(fn(sealed) { store.save(id, sealed) }) + }, + fetch: fn(id) { store.fetch(id) |> option.then(open(key, _)) }, + remove: store.remove, + ) +} + +/// Decode a 32-byte A256GCM key from base64 (standard or url-safe). +pub fn key_from_base64(encoded: String) -> Result(gose.Key(String), String) { + use bits <- result.try( + bit_array.base64_decode(encoded) + |> result.lazy_or(fn() { bit_array.base64_url_decode(encoded) }) + |> result.replace_error("not valid base64"), + ) + case bit_array.byte_size(bits) { + 32 -> + gose.from_octet_bits(bits) + |> result.replace_error("could not build the key") + n -> Error("expected 32 bytes, got " <> int.to_string(n)) + } +} + +fn seal(key: gose.Key(String), value: String) -> Result(String, String) { + jwe.new_direct(gose.AesGcm(gose.Aes256)) + |> jwe.encrypt(key, bit_array.from_string(value)) + |> result.try(jwe.serialize_compact) + |> result.replace_error("could not encrypt row") +} + +fn open(key: gose.Key(String), sealed: String) -> Option(String) { + { + use parsed <- result.try( + jwe.parse_compact(sealed) |> result.replace_error(Nil), + ) + use decryptor <- result.try( + jwe.key_decryptor(gose.Direct, gose.AesGcm(gose.Aes256), keys: [key]) + |> result.replace_error(Nil), + ) + use bits <- result.try( + jwe.decrypt(decryptor, parsed) |> result.replace_error(Nil), + ) + bit_array.to_string(bits) + } + |> option.from_result +} diff --git a/server/test/discogs_oauth_test.gleam b/server/test/discogs_oauth_test.gleam index 53bd36b..fbf5c73 100644 --- a/server/test/discogs_oauth_test.gleam +++ b/server/test/discogs_oauth_test.gleam @@ -4,8 +4,7 @@ import gleam/list import gleam/option.{None, Some} import gleam/set -// Golden header: PLAINTEXT signing is pure string assembly, so the exact -// header (order, quoting, percent-encoding) is pinned here. +// Golden: the exact header (order, quoting, percent-encoding) is pinned. pub fn auth_header_without_token_test() { let header = discogs_oauth.auth_header( diff --git a/server/test/sealed_store_test.gleam b/server/test/sealed_store_test.gleam new file mode 100644 index 0000000..95a9d49 --- /dev/null +++ b/server/test/sealed_store_test.gleam @@ -0,0 +1,41 @@ +import at_record_server/oauth/sessions_memory +import at_record_server/sealed_store +import gleam/list +import gleam/option.{None, Some} +import gleam/string + +const key_b64 = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + +pub fn key_from_base64_test() { + let assert Ok(_) = sealed_store.key_from_base64(key_b64) + assert sealed_store.key_from_base64("dG9vLXNob3J0") + == Error("expected 32 bytes, got 9") + assert sealed_store.key_from_base64("!!!") == Error("not valid base64") +} + +pub fn sealed_round_trip_test() { + let assert Ok(key) = sealed_store.key_from_base64(key_b64) + let assert Ok(backend) = sessions_memory.start() + let store = sealed_store.wrap(backend, key) + + let assert Ok(Nil) = store.save("id1", "{\"secret\":\"value\"}") + assert store.fetch("id1") == Some("{\"secret\":\"value\"}") + + // The backend row is a compact JWE, not the plaintext. + let assert Some(raw) = backend.fetch("id1") + assert !string.contains(raw, "secret") + assert string.split(raw, ".") |> list.length == 5 + + store.remove("id1") + assert store.fetch("id1") == None +} + +pub fn unreadable_rows_read_as_absent_test() { + let assert Ok(key) = sealed_store.key_from_base64(key_b64) + let assert Ok(backend) = sessions_memory.start() + let store = sealed_store.wrap(backend, key) + + // Legacy plaintext row (pre-encryption) and garbage both open to None. + let assert Ok(Nil) = backend.save("legacy", "{\"secret\":\"value\"}") + assert store.fetch("legacy") == None +}