diff --git a/Cargo.lock b/Cargo.lock index 28bfae5..d038d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -324,6 +359,16 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "colored" version = "3.1.1" @@ -394,9 +439,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.21.3" @@ -569,6 +624,7 @@ dependencies = [ name = "divine-atbridge" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "axum", @@ -945,6 +1001,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -1261,6 +1327,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipld-core" version = "0.4.3" @@ -1521,6 +1596,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1578,6 +1659,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2573,6 +2666,16 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsigned-varint" version = "0.8.0" diff --git a/crates/divine-atbridge/Cargo.toml b/crates/divine-atbridge/Cargo.toml index 4076f28..121e391 100644 --- a/crates/divine-atbridge/Cargo.toml +++ b/crates/divine-atbridge/Cargo.toml @@ -12,6 +12,7 @@ divine-bridge-db = { path = "../divine-bridge-db" } divine-bridge-types = { path = "../divine-bridge-types" } divine-video-worker = { path = "../divine-video-worker" } anyhow = { workspace = true } +aes-gcm = "0.10" axum = "0.7" diesel = { workspace = true } serde = { workspace = true } diff --git a/crates/divine-atbridge/src/config.rs b/crates/divine-atbridge/src/config.rs index 4dedba9..4374e23 100644 --- a/crates/divine-atbridge/src/config.rs +++ b/crates/divine-atbridge/src/config.rs @@ -31,6 +31,8 @@ pub struct BridgeConfig { pub handle_domain: String, /// Shared bearer token for the internal provisioning API (ATPROTO_PROVISIONING_TOKEN). pub provisioning_bearer_token: String, + /// 32-byte hex key used to encrypt persisted provisioning secrets. + pub provisioning_key_encryption_key_hex: String, } impl BridgeConfig { @@ -53,6 +55,26 @@ impl BridgeConfig { handle_domain: env::var("HANDLE_DOMAIN").context("HANDLE_DOMAIN must be set")?, provisioning_bearer_token: env::var("ATPROTO_PROVISIONING_TOKEN") .context("ATPROTO_PROVISIONING_TOKEN must be set")?, + provisioning_key_encryption_key_hex: env::var( + "ATPROTO_PROVISIONING_KEY_ENCRYPTION_KEY_HEX", + ) + .context("ATPROTO_PROVISIONING_KEY_ENCRYPTION_KEY_HEX must be set")?, + }) + } + + pub fn provisioning_key_encryption_key(&self) -> Result<[u8; 32]> { + let raw = hex::decode( + self.provisioning_key_encryption_key_hex + .trim() + .strip_prefix("0x") + .unwrap_or(self.provisioning_key_encryption_key_hex.trim()), + ) + .context("ATPROTO_PROVISIONING_KEY_ENCRYPTION_KEY_HEX must be valid hex")?; + + raw.try_into().map_err(|_| { + anyhow::anyhow!( + "ATPROTO_PROVISIONING_KEY_ENCRYPTION_KEY_HEX must decode to exactly 32 bytes" + ) }) } } @@ -79,8 +101,63 @@ mod tests { plc_directory_url: "https://plc.directory".into(), handle_domain: "divine.video".into(), provisioning_bearer_token: "test-token".into(), + provisioning_key_encryption_key_hex: + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".into(), }; assert_eq!(config.relay_url, "wss://relay.example.com"); assert_eq!(config.s3_bucket, "test-bucket"); } + + #[test] + fn provisioning_key_encryption_key_decodes_hex() { + let config = BridgeConfig { + relay_url: "wss://relay.example.com".into(), + pds_url: "https://pds.staging.dvines.org".into(), + pds_auth_token: "test-token".into(), + blossom_url: "https://blossom.example.com".into(), + database_url: "postgres://localhost/test".into(), + s3_endpoint: "https://s3.example.com".into(), + s3_bucket: "test-bucket".into(), + relay_source_name: "nostr-relay".into(), + health_bind_addr: "0.0.0.0:8080".into(), + plc_directory_url: "https://plc.directory".into(), + handle_domain: "divine.video".into(), + provisioning_bearer_token: "test-token".into(), + provisioning_key_encryption_key_hex: + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".into(), + }; + + assert_eq!( + config.provisioning_key_encryption_key().unwrap(), + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + ] + ); + } + + #[test] + fn provisioning_key_encryption_key_rejects_wrong_length() { + let config = BridgeConfig { + relay_url: "wss://relay.example.com".into(), + pds_url: "https://pds.staging.dvines.org".into(), + pds_auth_token: "test-token".into(), + blossom_url: "https://blossom.example.com".into(), + database_url: "postgres://localhost/test".into(), + s3_endpoint: "https://s3.example.com".into(), + s3_bucket: "test-bucket".into(), + relay_source_name: "nostr-relay".into(), + health_bind_addr: "0.0.0.0:8080".into(), + plc_directory_url: "https://plc.directory".into(), + handle_domain: "divine.video".into(), + provisioning_bearer_token: "test-token".into(), + provisioning_key_encryption_key_hex: "deadbeef".into(), + }; + + assert!( + config.provisioning_key_encryption_key().is_err(), + "short keys must be rejected" + ); + } } diff --git a/crates/divine-atbridge/src/health.rs b/crates/divine-atbridge/src/health.rs index 79c6769..a1a626a 100644 --- a/crates/divine-atbridge/src/health.rs +++ b/crates/divine-atbridge/src/health.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use crate::config::BridgeConfig; use crate::pds_accounts::PdsAccountsClient; use crate::plc_directory::PlcDirectoryClient; -use crate::provision_runtime::{DbAccountLinkStore, GeneratedKeyStore}; +use crate::provision_runtime::{DbAccountLinkStore, DbProvisioningKeyStore}; use crate::provisioner::{ AccountLinkStore, AccountProvisioner, KeyStore, PdsAccountCreator, PlcClient, ProvisionResult, }; @@ -241,20 +241,35 @@ pub fn app_with_runtime_state(runtime: RuntimeHealthState) -> Router { }) } -pub fn app_with_config(config: BridgeConfig) -> Result { - anyhow::ensure!( - !config.provisioning_bearer_token.trim().is_empty(), - "ATPROTO_PROVISIONING_TOKEN must not be empty" - ); - - let provisioner = AccountProvisioner { - key_store: GeneratedKeyStore, +fn build_configured_provisioner( + config: &BridgeConfig, +) -> Result< + AccountProvisioner< + DbProvisioningKeyStore, + PlcDirectoryClient, + PdsAccountsClient, + DbAccountLinkStore, + >, +> { + Ok(AccountProvisioner { + key_store: DbProvisioningKeyStore::new( + config.database_url.clone(), + config.provisioning_key_encryption_key()?, + ), plc_client: PlcDirectoryClient::new(config.plc_directory_url.clone()), pds_creator: PdsAccountsClient::new(config.pds_url.clone(), config.pds_auth_token.clone()), link_store: DbAccountLinkStore::new(config.database_url.clone()), pds_endpoint: config.pds_url.clone(), handle_domain: config.handle_domain.clone(), - }; + }) +} + +pub fn app_with_config(config: BridgeConfig) -> Result { + anyhow::ensure!( + !config.provisioning_bearer_token.trim().is_empty(), + "ATPROTO_PROVISIONING_TOKEN must not be empty" + ); + let provisioner = build_configured_provisioner(&config)?; Ok(app_with_state(InternalApiState { runtime: RuntimeHealthState::default(), @@ -274,17 +289,7 @@ pub async fn spawn( let app = app_with_state(InternalApiState { runtime, expected_bearer: Some(config.provisioning_bearer_token.clone()), - provisioner: Some(Arc::new(AccountProvisioner { - key_store: GeneratedKeyStore, - plc_client: PlcDirectoryClient::new(config.plc_directory_url.clone()), - pds_creator: PdsAccountsClient::new( - config.pds_url.clone(), - config.pds_auth_token.clone(), - ), - link_store: DbAccountLinkStore::new(config.database_url.clone()), - pds_endpoint: config.pds_url.clone(), - handle_domain: config.handle_domain.clone(), - })), + provisioner: Some(Arc::new(build_configured_provisioner(&config)?)), }); let listener = tokio::net::TcpListener::bind(addr) .await diff --git a/crates/divine-atbridge/src/provision_runtime.rs b/crates/divine-atbridge/src/provision_runtime.rs index 321dd43..099876c 100644 --- a/crates/divine-atbridge/src/provision_runtime.rs +++ b/crates/divine-atbridge/src/provision_runtime.rs @@ -1,18 +1,26 @@ use anyhow::{bail, Context, Result}; +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use async_trait::async_trait; use diesel::Connection; use diesel::PgConnection; use divine_bridge_db::{ get_account_link_lifecycle, get_account_link_lifecycle_by_handle, mark_account_link_failed, - mark_account_link_ready, upsert_pending_account_link, + mark_account_link_ready, upsert_pending_account_link, get_provisioning_key, + insert_provisioning_key, }; +use divine_bridge_db::models::NewProvisioningKey; +use secp256k1::rand::RngCore; use secp256k1::rand::rngs::OsRng; -use secp256k1::Secp256k1; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; use crate::provisioner::{ AccountLinkRecord, AccountLinkStore, KeyPair, KeyStore, PendingAccountLink, ProvisioningState, }; +const PROVISIONING_KEY_ENVELOPE_VERSION: u8 = 1; +const AES_GCM_NONCE_LEN: usize = 12; + #[derive(Clone)] pub struct DbAccountLinkStore { database_url: String, @@ -30,6 +38,109 @@ impl DbAccountLinkStore { pub struct GeneratedKeyStore; +#[derive(Clone)] +pub struct DbProvisioningKeyStore { + database_url: String, + encryption_key: [u8; 32], +} + +impl DbProvisioningKeyStore { + pub fn new(database_url: String, encryption_key: [u8; 32]) -> Self { + Self { + database_url, + encryption_key, + } + } + + fn connect(&self) -> Result { + PgConnection::establish(&self.database_url).context("failed to connect to PostgreSQL") + } + + fn cipher(&self) -> Result { + Aes256Gcm::new_from_slice(&self.encryption_key) + .context("failed to initialise provisioning key cipher") + } + + fn provisioning_aad(key_ref: &str, purpose: &str) -> Vec { + format!("{key_ref}:{purpose}").into_bytes() + } + + fn encrypt_secret( + &self, + key_ref: &str, + purpose: &str, + secret_key: &SecretKey, + ) -> Result> { + let cipher = self.cipher()?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + aes_gcm::aead::Payload { + msg: &secret_key.secret_bytes(), + aad: &Self::provisioning_aad(key_ref, purpose), + }, + ) + .map_err(|_| anyhow::anyhow!("encrypting provisioning secret"))?; + + let mut envelope = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + envelope.push(PROVISIONING_KEY_ENVELOPE_VERSION); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&ciphertext); + Ok(envelope) + } + + fn decrypt_secret(&self, key_ref: &str, purpose: &str, envelope: &[u8]) -> Result { + if envelope.len() <= 1 + AES_GCM_NONCE_LEN { + bail!("encrypted provisioning secret is truncated"); + } + if envelope[0] != PROVISIONING_KEY_ENVELOPE_VERSION { + bail!( + "unsupported provisioning secret envelope version: {}", + envelope[0] + ); + } + + let nonce = &envelope[1..1 + AES_GCM_NONCE_LEN]; + let ciphertext = &envelope[1 + AES_GCM_NONCE_LEN..]; + let decrypted = self + .cipher()? + .decrypt( + Nonce::from_slice(nonce), + aes_gcm::aead::Payload { + msg: ciphertext, + aad: &Self::provisioning_aad(key_ref, purpose), + }, + ) + .map_err(|_| anyhow::anyhow!("decrypting provisioning secret"))?; + + SecretKey::from_slice(&decrypted).context("stored provisioning secret is not a valid key") + } + + fn keypair_from_row( + &self, + key_ref: &str, + purpose: &str, + public_key_hex: &str, + encrypted_secret: &[u8], + ) -> Result { + let secret_key = self.decrypt_secret(key_ref, purpose, encrypted_secret)?; + let secp = Secp256k1::new(); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + let derived_hex = hex::encode(public_key.serialize()); + if derived_hex != public_key_hex { + bail!("stored provisioning key public key does not match decrypted secret"); + } + + Ok(KeyPair { + secret_key, + public_key, + }) + } +} + fn map_state(raw: &str) -> Result { match raw { "pending" => Ok(ProvisioningState::Pending), @@ -127,3 +238,48 @@ impl KeyStore for GeneratedKeyStore { )) } } + +#[async_trait] +impl KeyStore for DbProvisioningKeyStore { + async fn generate_keypair(&self, purpose: &str) -> Result<(String, KeyPair)> { + let secp = Secp256k1::new(); + let mut rng = OsRng; + let (secret_key, public_key) = secp.generate_keypair(&mut rng); + let public_key_hex = hex::encode(public_key.serialize()); + let key_ref = format!("{purpose}:{public_key_hex}"); + let encrypted_secret = self.encrypt_secret(&key_ref, purpose, &secret_key)?; + + let mut connection = self.connect()?; + insert_provisioning_key( + &mut connection, + &NewProvisioningKey { + key_ref: &key_ref, + key_purpose: purpose, + public_key_hex: &public_key_hex, + encrypted_secret: &encrypted_secret, + }, + )?; + + Ok(( + key_ref, + KeyPair { + secret_key, + public_key, + }, + )) + } + + async fn load_keypair(&self, key_ref: &str) -> Result> { + let mut connection = self.connect()?; + let row = get_provisioning_key(&mut connection, key_ref)?; + row.map(|row| { + self.keypair_from_row( + &row.key_ref, + &row.key_purpose, + &row.public_key_hex, + &row.encrypted_secret, + ) + }) + .transpose() + } +} diff --git a/crates/divine-atbridge/src/provisioner.rs b/crates/divine-atbridge/src/provisioner.rs index d625337..4dfcd3b 100644 --- a/crates/divine-atbridge/src/provisioner.rs +++ b/crates/divine-atbridge/src/provisioner.rs @@ -101,6 +101,10 @@ pub struct PlcService { #[async_trait] pub trait KeyStore: Send + Sync { async fn generate_keypair(&self, purpose: &str) -> Result<(String, KeyPair)>; + + async fn load_keypair(&self, _key_ref: &str) -> Result> { + Ok(None) + } } /// Interacts with the PLC directory to create DIDs. diff --git a/crates/divine-atbridge/tests/provision_api.rs b/crates/divine-atbridge/tests/provision_api.rs index fe2b936..ba0862c 100644 --- a/crates/divine-atbridge/tests/provision_api.rs +++ b/crates/divine-atbridge/tests/provision_api.rs @@ -2,7 +2,9 @@ use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use diesel::Connection; use diesel::PgConnection; +use diesel::QueryableByName; use diesel::RunQueryDsl; +use diesel::sql_types::{Binary, Text}; use divine_atbridge::config::BridgeConfig; use divine_atbridge::health::app_with_config; use divine_bridge_db::{get_account_link_lifecycle, upsert_pending_account_link}; @@ -10,6 +12,20 @@ use serde_json::{json, Value}; use tower::util::ServiceExt; const AUTH_HEADER: &str = "Bearer test-provisioning-token"; +const TEST_KEY_HEX: &str = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + +#[derive(Debug, QueryableByName)] +struct StoredProvisioningKey { + #[diesel(sql_type = Text)] + key_ref: String, + #[diesel(sql_type = Text)] + key_purpose: String, + #[diesel(sql_type = Text)] + public_key_hex: String, + #[diesel(sql_type = Binary)] + encrypted_secret: Vec, +} fn test_database_url() -> String { std::env::var("TEST_DATABASE_URL") @@ -29,6 +45,10 @@ fn execute_batch(conn: &mut PgConnection, sql: &str) { fn reset_database(database_url: &str) { let mut conn = PgConnection::establish(database_url).expect("test database should be reachable"); + execute_batch( + &mut conn, + include_str!("../../../migrations/004_provisioning_keys/down.sql"), + ); execute_batch( &mut conn, include_str!("../../../migrations/001_bridge_tables/down.sql"), @@ -37,6 +57,10 @@ fn reset_database(database_url: &str) { &mut conn, include_str!("../../../migrations/001_bridge_tables/up.sql"), ); + execute_batch( + &mut conn, + include_str!("../../../migrations/004_provisioning_keys/up.sql"), + ); } #[tokio::test] @@ -88,6 +112,7 @@ async fn configured_internal_api_provisions_pending_link() { plc_directory_url: plc_server.url(), handle_domain: "divine.video".into(), provisioning_bearer_token: "test-provisioning-token".into(), + provisioning_key_encryption_key_hex: TEST_KEY_HEX.into(), }) .expect("configured app should build"); @@ -128,6 +153,37 @@ async fn configured_internal_api_provisions_pending_link() { .expect("row should exist"); assert_eq!(stored.did.as_deref(), Some("did:plc:alice123")); assert_eq!(stored.provisioning_state, "ready"); + + let persisted_keys = diesel::sql_query( + "SELECT key_ref, key_purpose, public_key_hex, encrypted_secret + FROM provisioning_keys + ORDER BY key_purpose ASC, key_ref ASC", + ) + .load::(&mut conn) + .expect("provisioning keys should load"); + assert_eq!(persisted_keys.len(), 2, "provisioning should persist signing and rotation keys"); + assert_eq!( + persisted_keys + .iter() + .map(|row| row.key_purpose.as_str()) + .collect::>(), + vec!["plc-rotation-key", "signing-key"] + ); + for row in persisted_keys { + assert!( + !row.key_ref.is_empty(), + "persisted provisioning key ref should not be empty" + ); + assert_eq!( + row.public_key_hex.len(), + 66, + "compressed secp256k1 public keys should be stored as 33-byte hex" + ); + assert!( + row.encrypted_secret.len() > 32, + "encrypted secret should include nonce and authentication tag" + ); + } } #[test] @@ -145,6 +201,7 @@ fn configured_internal_api_requires_provisioning_token() { plc_directory_url: "https://plc.directory".into(), handle_domain: "divine.video".into(), provisioning_bearer_token: String::new(), + provisioning_key_encryption_key_hex: TEST_KEY_HEX.into(), }); assert!( diff --git a/crates/divine-atbridge/tests/provisioning_key_store.rs b/crates/divine-atbridge/tests/provisioning_key_store.rs new file mode 100644 index 0000000..ff63d43 --- /dev/null +++ b/crates/divine-atbridge/tests/provisioning_key_store.rs @@ -0,0 +1,76 @@ +use diesel::Connection; +use diesel::PgConnection; +use diesel::RunQueryDsl; +use divine_atbridge::provision_runtime::DbProvisioningKeyStore; +use divine_atbridge::provisioner::KeyStore; +use divine_bridge_db::get_provisioning_key; + +const TEST_KEY: [u8; 32] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, +]; + +fn test_database_url() -> String { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://divine:divine_dev@[::1]:5432/divine_bridge".to_string()) +} + +fn execute_batch(conn: &mut PgConnection, sql: &str) { + for statement in sql + .split(';') + .map(str::trim) + .filter(|line| !line.is_empty()) + { + diesel::sql_query(statement).execute(conn).unwrap(); + } +} + +fn reset_provisioning_keys_table(database_url: &str) { + let mut conn = + PgConnection::establish(database_url).expect("test database should be reachable"); + execute_batch( + &mut conn, + include_str!("../../../migrations/004_provisioning_keys/down.sql"), + ); + execute_batch( + &mut conn, + include_str!("../../../migrations/004_provisioning_keys/up.sql"), + ); +} + +#[tokio::test] +async fn db_provisioning_key_store_round_trips_generated_keypairs() { + let database_url = test_database_url(); + reset_provisioning_keys_table(&database_url); + + let store = DbProvisioningKeyStore::new(database_url.clone(), TEST_KEY); + let (key_ref, generated) = store + .generate_keypair("plc-rotation-key") + .await + .expect("key generation should persist"); + + let loaded = store + .load_keypair(&key_ref) + .await + .expect("stored key should decrypt") + .expect("stored key should exist"); + + assert_eq!( + loaded.secret_key.secret_bytes(), + generated.secret_key.secret_bytes() + ); + assert_eq!(loaded.public_key, generated.public_key); + + let mut conn = + PgConnection::establish(&database_url).expect("test database should be reachable"); + let row = get_provisioning_key(&mut conn, &key_ref) + .expect("persisted row should load") + .expect("persisted row should exist"); + assert_eq!(row.key_purpose, "plc-rotation-key"); + assert_ne!( + row.encrypted_secret.as_slice(), + generated.secret_key.secret_bytes().as_slice(), + "secret material must not be stored in plaintext" + ); +} diff --git a/crates/divine-bridge-db/src/models.rs b/crates/divine-bridge-db/src/models.rs index 6628340..ab4ec02 100644 --- a/crates/divine-bridge-db/src/models.rs +++ b/crates/divine-bridge-db/src/models.rs @@ -86,6 +86,31 @@ pub struct AccountLinkLifecycleRow { pub updated_at: DateTime, } +// --------------------------------------------------------------------------- +// provisioning_keys +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = provisioning_keys)] +#[diesel(primary_key(key_ref))] +pub struct ProvisioningKey { + pub key_ref: String, + pub key_purpose: String, + pub public_key_hex: String, + pub encrypted_secret: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = provisioning_keys)] +pub struct NewProvisioningKey<'a> { + pub key_ref: &'a str, + pub key_purpose: &'a str, + pub public_key_hex: &'a str, + pub encrypted_secret: &'a [u8], +} + // --------------------------------------------------------------------------- // ingest_offsets // --------------------------------------------------------------------------- diff --git a/crates/divine-bridge-db/src/queries.rs b/crates/divine-bridge-db/src/queries.rs index c44aba4..f224555 100644 --- a/crates/divine-bridge-db/src/queries.rs +++ b/crates/divine-bridge-db/src/queries.rs @@ -210,6 +210,33 @@ pub fn disable_account_link( Ok(result) } +// --------------------------------------------------------------------------- +// provisioning_keys queries +// --------------------------------------------------------------------------- + +/// Look up a persisted provisioning key by its stable reference. +pub fn get_provisioning_key( + conn: &mut PgConnection, + key_ref: &str, +) -> Result> { + let result = provisioning_keys::table + .find(key_ref) + .first::(conn) + .optional()?; + Ok(result) +} + +/// Persist a new provisioning key envelope. +pub fn insert_provisioning_key( + conn: &mut PgConnection, + key: &NewProvisioningKey<'_>, +) -> Result { + let result = diesel::insert_into(provisioning_keys::table) + .values(key) + .get_result::(conn)?; + Ok(result) +} + // --------------------------------------------------------------------------- // ingest_offsets queries // --------------------------------------------------------------------------- diff --git a/crates/divine-bridge-db/src/schema.rs b/crates/divine-bridge-db/src/schema.rs index 7ea1f47..a477838 100644 --- a/crates/divine-bridge-db/src/schema.rs +++ b/crates/divine-bridge-db/src/schema.rs @@ -16,6 +16,17 @@ diesel::table! { } } +diesel::table! { + provisioning_keys (key_ref) { + key_ref -> Text, + key_purpose -> Text, + public_key_hex -> Text, + encrypted_secret -> Binary, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { ingest_offsets (source_name) { source_name -> Text, @@ -184,6 +195,7 @@ diesel::joinable!(appview_posts -> appview_profiles (did)); diesel::allow_tables_to_appear_in_same_query!( account_links, + provisioning_keys, ingest_offsets, asset_manifest, record_mappings, diff --git a/crates/divine-feedgen/src/skeleton.rs b/crates/divine-feedgen/src/skeleton.rs index f1fe94f..890d657 100644 --- a/crates/divine-feedgen/src/skeleton.rs +++ b/crates/divine-feedgen/src/skeleton.rs @@ -1,8 +1,9 @@ -use std::sync::Arc; - use anyhow::{anyhow, Result}; use async_trait::async_trait; +use diesel::{Connection, PgConnection}; +use divine_bridge_db::{list_latest_appview_posts, list_trending_appview_posts}; use serde::Serialize; +use std::sync::Arc; const FEED_DID: &str = "did:plc:divine.feed"; const LATEST_URI: &str = "at://did:plc:divine.feed/app.bsky.feed.generator/latest"; @@ -43,23 +44,38 @@ pub trait FeedStore: Send + Sync { pub type DynFeedStore = Arc; -#[derive(Clone, Debug, Default)] -pub struct DbFeedStore; +pub struct DbFeedStore { + database_url: String, +} impl DbFeedStore { pub fn from_env() -> Self { - Self + Self { + database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"), + } + } + + fn connect(&self) -> Result { + Ok(PgConnection::establish(&self.database_url)?) } } #[async_trait] impl FeedStore for DbFeedStore { async fn latest_posts(&self, limit: usize) -> Result> { - Ok(latest_posts().into_iter().take(limit).collect()) + let mut conn = self.connect()?; + Ok(list_latest_appview_posts(&mut conn, limit as i64)? + .into_iter() + .map(|post| post.uri) + .collect()) } async fn trending_posts(&self, limit: usize) -> Result> { - Ok(trending_posts().into_iter().take(limit).collect()) + let mut conn = self.connect()?; + Ok(list_trending_appview_posts(&mut conn, limit as i64)? + .into_iter() + .map(|post| post.uri) + .collect()) } } @@ -97,18 +113,3 @@ pub async fn feed_skeleton( cursor: None, }) } - -/// Returns the current latest feed URIs used by the local feed generator. -fn latest_posts() -> Vec { - vec![ - "at://did:plc:ebt5msdpfavoklkap6gl54bm/app.bsky.feed.post/3mhjk5tbom655".to_string(), - "at://did:plc:ebt5msdpfavoklkap6gl54bm/app.bsky.feed.post/3mhjk3ct6xja5".to_string(), - "at://did:plc:w2bvwfebcrmc2pznxvz3lfdi/app.bsky.feed.post/3mhjn3iejoaaa".to_string(), - "at://did:plc:w2bvwfebcrmc2pznxvz3lfdi/app.bsky.feed.post/3mhjmzie5xmtk".to_string(), - ] -} - -fn trending_posts() -> Vec { - // Same posts for now; trending and latest share the same backing list. - latest_posts() -} diff --git a/migrations/004_provisioning_keys/down.sql b/migrations/004_provisioning_keys/down.sql new file mode 100644 index 0000000..31f35dd --- /dev/null +++ b/migrations/004_provisioning_keys/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS provisioning_keys; diff --git a/migrations/004_provisioning_keys/up.sql b/migrations/004_provisioning_keys/up.sql new file mode 100644 index 0000000..967fd11 --- /dev/null +++ b/migrations/004_provisioning_keys/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE provisioning_keys ( + key_ref TEXT PRIMARY KEY, + key_purpose TEXT NOT NULL, + public_key_hex TEXT NOT NULL, + encrypted_secret BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);