mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-06-09 16:42:40 +00:00
plc: always keep signing key in rotationKeys
Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<Vec<_>>())
|
||||
.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::<Vec<_>>())
|
||||
.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();
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -632,7 +632,9 @@ pub struct SecretsConfig {
|
||||
#[config(env = "MASTER_KEY")]
|
||||
pub master_key: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -18,20 +18,29 @@ impl InviteRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
fn bootstrap_registration(
|
||||
expected_code: &str,
|
||||
user_count: i64,
|
||||
invite_code: Option<&str>,
|
||||
) -> Option<Result<InviteRegistration, ApiError>> {
|
||||
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<InviteRegistration, ApiError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<RequiredRotationKey> {
|
||||
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<V, E>(
|
||||
key_bytes: &[u8],
|
||||
label: &str,
|
||||
did_key: &str,
|
||||
parse: impl Fn(&[u8]) -> Result<V, E>,
|
||||
) -> 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<GenesisResult, PlcError> {
|
||||
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::<Vec<_>>();
|
||||
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!({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+5
-3
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user