diff --git a/crates/tranquil-api/src/identity/did.rs b/crates/tranquil-api/src/identity/did.rs index f2d96c8..e5adcb5 100644 --- a/crates/tranquil-api/src/identity/did.rs +++ b/crates/tranquil-api/src/identity/did.rs @@ -491,16 +491,10 @@ pub async fn get_recommended_did_credentials( let rotation_keys = if auth.did.starts_with("did:web:") { vec![] } else { - let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key { - Some(key) => key.clone(), - None => { - warn!( - "PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation" - ); - did_key.clone() - } - }; - vec![server_rotation_key] + tranquil_pds::plc::rotation_keys_for( + tranquil_config::get().secrets.plc_rotation_key.as_deref(), + &signing_key, + ) }; Ok(Json(GetRecommendedDidCredentialsOutput { rotation_keys, diff --git a/crates/tranquil-api/src/identity/plc/sign.rs b/crates/tranquil-api/src/identity/plc/sign.rs index 66dceca..f0829c4 100644 --- a/crates/tranquil-api/src/identity/plc/sign.rs +++ b/crates/tranquil-api/src/identity/plc/sign.rs @@ -9,7 +9,10 @@ use tranquil_pds::api::ApiError; use tranquil_pds::api::error::DbResultExt; use tranquil_pds::auth::{Auth, Permissive}; use tranquil_pds::circuit_breaker::with_circuit_breaker; -use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation}; +use tranquil_pds::plc::{ + PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation, + signing_key_to_did_key, +}; use tranquil_pds::state::AppState; #[derive(Debug, Deserialize)] @@ -115,6 +118,18 @@ pub async fn sign_plc_operation( } })?; + let signing_did_key = signing_key_to_did_key(&signing_key); + if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) { + let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect(); + if let Some(missing) = missing_required_rotation_key( + &rotation_key_strs, + &signing_did_key, + tranquil_config::get().secrets.plc_rotation_key.as_deref(), + ) { + return Err(ApiError::InvalidRequest(missing.message().into())); + } + } + let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| { error!("Failed to sign PLC operation: {:?}", e); ApiError::InternalError(None) diff --git a/crates/tranquil-api/src/identity/plc/submit.rs b/crates/tranquil-api/src/identity/plc/submit.rs index fb6d635..3374105 100644 --- a/crates/tranquil-api/src/identity/plc/submit.rs +++ b/crates/tranquil-api/src/identity/plc/submit.rs @@ -67,19 +67,14 @@ pub async fn submit_plc_operation( })?; let user_did_key = signing_key_to_did_key(&signing_key); - let server_rotation_key = tranquil_config::get() - .secrets - .plc_rotation_key - .clone() - .unwrap_or_else(|| user_did_key.clone()); if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) { - let has_server_key = rotation_keys - .iter() - .any(|k| k.as_str() == Some(&server_rotation_key)); - if !has_server_key { - return Err(ApiError::InvalidRequest( - "Rotation keys do not include server's rotation key".into(), - )); + let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect(); + if let Some(missing) = tranquil_pds::plc::missing_required_rotation_key( + &rotation_key_strs, + &user_did_key, + tranquil_config::get().secrets.plc_rotation_key.as_deref(), + ) { + return Err(ApiError::InvalidRequest(missing.message().into())); } } if let Some(services) = op.get("services").and_then(Value::as_object) diff --git a/crates/tranquil-api/src/identity/provision.rs b/crates/tranquil-api/src/identity/provision.rs index a4b7052..a78d87f 100644 --- a/crates/tranquil-api/src/identity/provision.rs +++ b/crates/tranquil-api/src/identity/provision.rs @@ -45,15 +45,9 @@ pub async fn submit_plc_genesis( let hostname = &tranquil_config::get().server.hostname; let pds_endpoint = format!("https://{}", hostname); - let rotation_key = tranquil_config::get() - .secrets - .plc_rotation_key - .clone() - .unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key)); - let genesis_result = tranquil_pds::plc::create_genesis_operation( signing_key, - &rotation_key, + tranquil_config::get().secrets.plc_rotation_key.as_deref(), handle, &pds_endpoint, ) diff --git a/crates/tranquil-api/src/repo/import.rs b/crates/tranquil-api/src/repo/import.rs index 742ef01..e63ede1 100644 --- a/crates/tranquil-api/src/repo/import.rs +++ b/crates/tranquil-api/src/repo/import.rs @@ -14,6 +14,35 @@ use tranquil_pds::sync::verify::CarVerifier; use tranquil_pds::types::Did; use tranquil_types::{AtUri, CidLink}; +fn map_car_verify_error(e: tranquil_pds::sync::verify::VerifyError) -> ApiError { + use tranquil_pds::sync::verify::VerifyError; + match e { + VerifyError::DidMismatch { + commit_did, + expected_did, + } => ApiError::InvalidRepo(format!( + "CAR file is for DID {} but you are authenticated as {}", + commit_did, expected_did + )), + VerifyError::InvalidSignature => ApiError::InvalidRequest( + "Repo commit signature does not match the DID document signing key".into(), + ), + VerifyError::NoSigningKey => { + ApiError::InvalidRequest("DID document has no atproto signing key".into()) + } + VerifyError::DidResolutionFailed(msg) => { + ApiError::InvalidRequest(format!("Could not resolve DID document: {}", msg)) + } + VerifyError::MstValidationFailed(msg) => { + ApiError::InvalidRequest(format!("MST validation failed: {}", msg)) + } + other => { + error!("CAR verification failed: {:?}", other); + ApiError::InvalidRequest(format!("CAR verification failed: {}", other)) + } + } +} + pub async fn import_repo( State(state): State, auth: Auth, @@ -88,41 +117,23 @@ pub async fn import_repo( let is_migration = user.inbound_migration && user.deactivated_at.is_some(); if skip_verification { warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)"); - } else { + } else if is_migration { + let verified = CarVerifier::new() + .verify_car_structure_only(&root, &blocks) + .map_err(map_car_verify_error)?; debug!( - "Verifying CAR file structure for repo import (skipping signature and DID verification)" + "CAR structure verified for migration import: rev={}, data_cid={}", + verified.rev, verified.data_cid + ); + } else { + let verified = CarVerifier::new() + .verify_car(did, &root, &blocks) + .await + .map_err(map_car_verify_error)?; + debug!( + "CAR signature and structure verified: rev={}, data_cid={}", + verified.rev, verified.data_cid ); - let verifier = CarVerifier::new(); - match verifier.verify_car_structure_only(&root, &blocks) { - Ok(verified) => { - debug!( - "CAR structure verification successful: rev={}, data_cid={}", - verified.rev, verified.data_cid - ); - } - Err(tranquil_pds::sync::verify::VerifyError::DidMismatch { - commit_did, - expected_did, - }) => { - return Err(ApiError::InvalidRepo(format!( - "CAR file is for DID {} but you are authenticated as {}", - commit_did, expected_did - ))); - } - Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => { - return Err(ApiError::InvalidRequest(format!( - "MST validation failed: {}", - msg - ))); - } - Err(e) => { - error!("CAR structure verification error: {:?}", e); - return Err(ApiError::InvalidRequest(format!( - "CAR verification failed: {}", - e - ))); - } - } } let max_blocks = tranquil_config::get().import.max_blocks as usize; let _write_lock = state.repo_write_locks.lock(user_id).await; diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index 7cfc489..33de10d 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -197,18 +197,19 @@ async fn assert_valid_did_document_for_service( .await .map_err(ApiError::InvalidRequest)?; + let doc_rotation_keys = doc_data + .get("rotationKeys") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone(); - if let Some(ref expected_rotation_key) = server_rotation_key { - let rotation_keys = doc_data - .get("rotationKeys") - .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(Value::as_str).collect::>()) - .unwrap_or_default(); - if !rotation_keys.contains(&expected_rotation_key.as_str()) { - return Err(ApiError::InvalidRequest( - "Server rotation key not included in PLC DID data".into(), - )); - } + if let Some(ref expected_rotation_key) = server_rotation_key + && !doc_rotation_keys.contains(&expected_rotation_key.as_str()) + { + return Err(ApiError::InvalidRequest( + "Server rotation key not included in PLC DID data".into(), + )); } let doc_signing_key = doc_data @@ -243,6 +244,16 @@ async fn assert_valid_did_document_for_service( "DID document verification method does not match expected signing key".into(), )); } + + if !doc_rotation_keys.contains(&expected_did_key.as_str()) { + warn!( + "DID {} rotation keys {:?} omit the PDS-managed signing key {}", + did, doc_rotation_keys, expected_did_key + ); + return Err(ApiError::InvalidRequest( + "PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(), + )); + } } } else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") { let client = tranquil_pds::api::proxy_client::did_resolution_client(); diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index d54a7a0..7002ae7 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -224,15 +224,9 @@ pub async fn create_passkey_account( )); } } else { - let rotation_key = tranquil_config::get() - .secrets - .plc_rotation_key - .clone() - .unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&secret_key)); - let genesis_result = match tranquil_pds::plc::create_genesis_operation( &secret_key, - &rotation_key, + tranquil_config::get().secrets.plc_rotation_key.as_deref(), &handle, &pds_endpoint, ) { diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 423e584..b948934 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -632,7 +632,9 @@ pub struct SecretsConfig { #[config(env = "MASTER_KEY")] pub master_key: Option, - /// PLC rotation key (DID key). If not set, user-level keys are used. + /// Optional operator-held PLC recovery key, as a public `did:key`. The PDS + /// continues to sign PLC operations with the per-account signing key, which + /// always remains in `rotationKeys`. #[config(env = "PLC_ROTATION_KEY")] pub plc_rotation_key: Option, diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 7c1e652..bfa83e5 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -1043,15 +1043,9 @@ pub async fn complete_registration( d.to_string() } _ => { - let rotation_key = tranquil_config::get() - .secrets - .plc_rotation_key - .clone() - .unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&signing_key)); - let genesis_result = match tranquil_pds::plc::create_genesis_operation( &signing_key, - &rotation_key, + tranquil_config::get().secrets.plc_rotation_key.as_deref(), &handle, &pds_endpoint, ) { diff --git a/crates/tranquil-pds/src/api/invite.rs b/crates/tranquil-pds/src/api/invite.rs index 1716a36..9bac06e 100644 --- a/crates/tranquil-pds/src/api/invite.rs +++ b/crates/tranquil-pds/src/api/invite.rs @@ -18,20 +18,29 @@ impl InviteRegistration { } } +fn bootstrap_registration( + expected_code: &str, + user_count: i64, + invite_code: Option<&str>, +) -> Option> { + if user_count != 0 { + return None; + } + Some(match invite_code { + Some(code) if code == expected_code => Ok(InviteRegistration::Bootstrap), + _ => Err(ApiError::InvalidInviteCode), + }) +} + pub async fn check_registration_invite( state: &AppState, invite_code: Option<&str>, ) -> Result { - let is_bootstrap = state.bootstrap_invite_code.is_some() - && state.repos.user.count_users().await.unwrap_or(1) == 0; - - if is_bootstrap { - return match invite_code { - Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => { - Ok(InviteRegistration::Bootstrap) - } - _ => Err(ApiError::InvalidInviteCode), - }; + if let Some(expected) = state.bootstrap_invite_code.as_deref() { + let user_count = state.repos.user.count_users().await.unwrap_or(1); + if let Some(decision) = bootstrap_registration(expected, user_count, invite_code) { + return decision; + } } match invite_code.map(str::trim).filter(|code| !code.is_empty()) { @@ -49,3 +58,37 @@ pub async fn check_registration_invite( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_taken_for_matching_code_on_empty_instance() { + assert!(matches!( + bootstrap_registration("squid-bootstrap", 0, Some("squid-bootstrap")), + Some(Ok(InviteRegistration::Bootstrap)) + )); + } + + #[test] + fn bootstrap_rejects_wrong_code_on_empty_instance() { + assert!(matches!( + bootstrap_registration("squid-bootstrap", 0, Some("whelk")), + Some(Err(ApiError::InvalidInviteCode)) + )); + } + + #[test] + fn bootstrap_rejects_missing_code_on_empty_instance() { + assert!(matches!( + bootstrap_registration("squid-bootstrap", 0, None), + Some(Err(ApiError::InvalidInviteCode)) + )); + } + + #[test] + fn bootstrap_falls_through_once_users_exist() { + assert!(bootstrap_registration("squid-bootstrap", 1, Some("squid-bootstrap")).is_none()); + } +} diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index cba9754..eb8e390 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -114,6 +114,7 @@ impl<'de> Deserialize<'de> for ServiceType { } pub const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01]; +pub const P256_MULTICODEC_PREFIX: [u8; 2] = [0x80, 0x24]; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcOperation { @@ -415,6 +416,100 @@ pub fn signing_key_to_did_key(signing_key: &SigningKey) -> String { format!("did:key:{}", encoded) } +pub fn rotation_keys_for( + configured_rotation_key: Option<&str>, + signing_key: &SigningKey, +) -> Vec { + let signing_did_key = signing_key_to_did_key(signing_key); + match configured_rotation_key { + Some(key) if key != signing_did_key.as_str() => vec![key.to_string(), signing_did_key], + _ => vec![signing_did_key], + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequiredRotationKey { + Signing, + Operator, +} + +impl RequiredRotationKey { + pub fn message(self) -> &'static str { + match self { + Self::Signing => { + "Rotation keys must include the PDS-managed signing key so the server can sign future operations" + } + Self::Operator => { + "Rotation keys must include the operator-held PLC recovery key configured for this server" + } + } + } +} + +pub fn missing_required_rotation_key( + rotation_keys: &[&str], + signing_did_key: &str, + configured_rotation_key: Option<&str>, +) -> Option { + if !rotation_keys.contains(&signing_did_key) { + return Some(RequiredRotationKey::Signing); + } + match configured_rotation_key { + Some(operator_key) if !rotation_keys.contains(&operator_key) => { + Some(RequiredRotationKey::Operator) + } + _ => None, + } +} + +fn validate_compressed_did_key( + key_bytes: &[u8], + label: &str, + did_key: &str, + parse: impl Fn(&[u8]) -> Result, +) -> Result<(), String> +where + E: std::fmt::Display, +{ + if key_bytes.len() != 33 { + return Err(format!( + "`{did_key}` must be a compressed {label} public key" + )); + } + parse(key_bytes) + .map(|_| ()) + .map_err(|e| format!("`{did_key}` is not a valid {label} public key: {e}")) +} + +pub fn validate_rotation_did_key(did_key: &str) -> Result<(), String> { + let multibase_part = did_key + .strip_prefix("did:key:") + .ok_or_else(|| format!("must be a did:key, got `{did_key}`"))?; + let (base, decoded) = multibase::decode(multibase_part) + .map_err(|e| format!("`{did_key}` is not valid multibase: {e}"))?; + if base != multibase::Base::Base58Btc { + return Err(format!("`{did_key}` must use base58btc multibase encoding")); + } + let (prefix, key_bytes) = decoded.split_at(decoded.len().min(2)); + if prefix == SECP256K1_MULTICODEC_PREFIX { + validate_compressed_did_key( + key_bytes, + "secp256k1", + did_key, + k256::ecdsa::VerifyingKey::from_sec1_bytes, + ) + } else if prefix == P256_MULTICODEC_PREFIX { + validate_compressed_did_key( + key_bytes, + "p256", + did_key, + p256::ecdsa::VerifyingKey::from_sec1_bytes, + ) + } else { + Err(format!("`{did_key}` is not a secp256k1 or p256 did:key")) + } +} + pub struct GenesisResult { pub did: String, pub signed_operation: Value, @@ -422,13 +517,14 @@ pub struct GenesisResult { pub fn create_genesis_operation( signing_key: &SigningKey, - rotation_key: &str, + configured_rotation_key: Option<&str>, handle: &str, pds_endpoint: &str, ) -> Result { let signing_did_key = signing_key_to_did_key(signing_key); + let rotation_keys = rotation_keys_for(configured_rotation_key, signing_key); let mut verification_methods = HashMap::new(); - verification_methods.insert("atproto".to_string(), signing_did_key.clone()); + verification_methods.insert("atproto".to_string(), signing_did_key); let mut services = HashMap::new(); services.insert( "atproto_pds".to_string(), @@ -439,7 +535,7 @@ pub fn create_genesis_operation( ); let genesis_op = PlcOperation { op_type: PlcOpType::Operation, - rotation_keys: vec![rotation_key.to_string()], + rotation_keys, verification_methods, also_known_as: vec![format!("at://{}", handle)], services, @@ -651,6 +747,132 @@ mod tests { assert!(did_key.starts_with("did:key:z")); } + #[test] + fn test_rotation_keys_default_is_signing_key() { + let key = SigningKey::random(&mut rand::thread_rng()); + let signing_did_key = signing_key_to_did_key(&key); + assert_eq!(rotation_keys_for(None, &key), vec![signing_did_key]); + } + + #[test] + fn test_rotation_keys_prepends_operator_key() { + let key = SigningKey::random(&mut rand::thread_rng()); + let signing_did_key = signing_key_to_did_key(&key); + let operator_key = "did:key:zQ3shScallopRecoveryKey"; + assert_eq!( + rotation_keys_for(Some(operator_key), &key), + vec![operator_key.to_string(), signing_did_key.clone()] + ); + } + + #[test] + fn test_rotation_keys_dedupes_when_operator_equals_signing() { + let key = SigningKey::random(&mut rand::thread_rng()); + let signing_did_key = signing_key_to_did_key(&key); + assert_eq!( + rotation_keys_for(Some(&signing_did_key), &key), + vec![signing_did_key] + ); + } + + #[test] + fn test_genesis_includes_signing_key_with_operator_rotation_key() { + let key = SigningKey::random(&mut rand::thread_rng()); + let signing_did_key = signing_key_to_did_key(&key); + let operator_key = "did:key:zQ3shWhelkOperatorKey"; + let result = + create_genesis_operation(&key, Some(operator_key), "whelk.nel.pet", "https://nel.pet") + .unwrap(); + let rotation_keys = result.signed_operation["rotationKeys"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect::>(); + assert_eq!(rotation_keys, vec![operator_key, signing_did_key.as_str()]); + assert!( + verify_operation_signature( + &result.signed_operation, + &[operator_key.to_string(), signing_did_key] + ) + .unwrap() + ); + } + + fn p256_did_key(key: &p256::ecdsa::SigningKey) -> String { + let point = key.verifying_key().to_encoded_point(true); + let mut prefixed = Vec::from(P256_MULTICODEC_PREFIX); + prefixed.extend_from_slice(point.as_bytes()); + format!( + "did:key:{}", + multibase::encode(multibase::Base::Base58Btc, &prefixed) + ) + } + + #[test] + fn test_validate_rotation_did_key_accepts_secp256k1() { + let key = SigningKey::random(&mut rand::thread_rng()); + assert!(validate_rotation_did_key(&signing_key_to_did_key(&key)).is_ok()); + } + + #[test] + fn test_validate_rotation_did_key_accepts_p256() { + let key = p256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + assert!(validate_rotation_did_key(&p256_did_key(&key)).is_ok()); + } + + #[test] + fn test_validate_rotation_did_key_rejects_non_did_key() { + assert!(validate_rotation_did_key("did:plc:squid").is_err()); + assert!(validate_rotation_did_key("zSomeMultibaseButNoPrefix").is_err()); + } + + #[test] + fn test_validate_rotation_did_key_rejects_unknown_multicodec() { + let mut ed25519 = vec![0xed, 0x01]; + ed25519.extend_from_slice(&[0u8; 32]); + let did_key = format!( + "did:key:{}", + multibase::encode(multibase::Base::Base58Btc, &ed25519) + ); + assert!(validate_rotation_did_key(&did_key).is_err()); + } + + #[test] + fn test_validate_rotation_did_key_rejects_non_base58btc() { + let key = SigningKey::random(&mut rand::thread_rng()); + let point = key.verifying_key().to_encoded_point(true); + let mut prefixed = Vec::from(SECP256K1_MULTICODEC_PREFIX); + prefixed.extend_from_slice(point.as_bytes()); + let hex_did_key = format!( + "did:key:{}", + multibase::encode(multibase::Base::Base16Lower, &prefixed) + ); + assert!(validate_rotation_did_key(&hex_did_key).is_err()); + } + + #[test] + fn test_missing_required_rotation_key() { + let signing = "did:key:zSigning"; + let operator = "did:key:zOperator"; + assert_eq!( + missing_required_rotation_key(&[operator], signing, None), + Some(RequiredRotationKey::Signing) + ); + assert_eq!( + missing_required_rotation_key(&[signing], signing, Some(operator)), + Some(RequiredRotationKey::Operator) + ); + assert_eq!( + missing_required_rotation_key(&[operator, signing], signing, Some(operator)), + None + ); + assert_eq!( + missing_required_rotation_key(&[signing], signing, None), + None + ); + } + #[test] fn test_cid_for_cbor() { let value = json!({ diff --git a/crates/tranquil-pds/tests/invite_registration.rs b/crates/tranquil-pds/tests/invite_registration.rs index c825486..452a4d4 100644 --- a/crates/tranquil-pds/tests/invite_registration.rs +++ b/crates/tranquil-pds/tests/invite_registration.rs @@ -25,30 +25,6 @@ async fn create_invite_code(client: &Client, admin_jwt: &str, use_count: u32) -> async fn check_registration_invite_validates_without_consuming() { let state = get_test_app_state().await; - assert_eq!( - state.repos.user.count_users().await.unwrap(), - 0, - "bootstrap branch needs a zero-user instance" - ); - - let mut bootstrap = state.clone(); - bootstrap.bootstrap_invite_code = Some("squid-bootstrap".to_string()); - - assert_eq!( - check_registration_invite(&bootstrap, Some("squid-bootstrap")) - .await - .unwrap(), - InviteRegistration::Bootstrap - ); - assert!(matches!( - check_registration_invite(&bootstrap, Some("whelk")).await, - Err(ApiError::InvalidInviteCode) - )); - assert!(matches!( - check_registration_invite(&bootstrap, None).await, - Err(ApiError::InvalidInviteCode) - )); - let client = client(); let (admin_jwt, _did) = create_admin_account_and_login(&client).await; let code = create_invite_code(&client, &admin_jwt, 1).await; diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index 8525d7c..1229e4c 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -62,6 +62,12 @@ async fn main() -> ExitCode { eprint!("{e}"); return ExitCode::FAILURE; } + if let Some(key) = config.secrets.plc_rotation_key.as_deref() + && let Err(reason) = tranquil_pds::plc::validate_rotation_did_key(key) + { + eprintln!("secrets.plc_rotation_key (PLC_ROTATION_KEY) {reason}"); + return ExitCode::FAILURE; + } if !*ignore_secrets && let Some((cert, key)) = config.server.tls.material() && let Err(e) = tls::load_certified_key(cert, key) @@ -90,6 +96,13 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } + if let Some(key) = config.secrets.plc_rotation_key.as_deref() + && let Err(reason) = tranquil_pds::plc::validate_rotation_did_key(key) + { + error!("secrets.plc_rotation_key (PLC_ROTATION_KEY) {reason}"); + return ExitCode::FAILURE; + } + tranquil_config::init(config); tranquil_pds::metrics::init_metrics(); diff --git a/example.toml b/example.toml index 023bd65..aeceade 100644 --- a/example.toml +++ b/example.toml @@ -4,7 +4,7 @@ # Can also be specified via environment variable `PDS_HOSTNAME`. # # Required! This value must be specified. -#hostname = "pds.example.com" +#hostname = # Address to bind the HTTP server to. # @@ -24,7 +24,7 @@ # Defaults to the PDS hostname when not set. # # Can also be specified via environment variable `PDS_USER_HANDLE_DOMAINS`. -#user_handle_domains = ["example.com"] +#user_handle_domains = # Enable PDS-hosted did:web identities. Hosting did:web requires a # long-term commitment to serve DID documents; opt-in only. @@ -200,7 +200,9 @@ # Can also be specified via environment variable `MASTER_KEY`. #master_key = -# PLC rotation key (DID key). If not set, user-level keys are used. +# Optional operator-held PLC recovery key, as a public `did:key`. The PDS +# continues to sign PLC operations with the per-account signing key, which +# always remains in `rotationKeys`. # # Can also be specified via environment variable `PLC_ROTATION_KEY`. #plc_rotation_key =