plc: always keep signing key in rotationKeys

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-06-26 16:28:11 +03:00
committed by Tangled
parent 39a2e40b35
commit 28f2e04019
14 changed files with 395 additions and 129 deletions
+4 -10
View File
@@ -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,
+16 -1
View File
@@ -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)
+7 -12
View File
@@ -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,
)
+44 -33
View File
@@ -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,
) {
+3 -1
View File
@@ -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,
) {
+53 -10
View File
@@ -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());
}
}
+225 -3
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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 =