Files

527 lines
16 KiB
Rust

use crank_core::{Secret, SecretId, SecretKind, SecretStatus, WorkspaceId};
use crank_registry::{
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
RotateSecretRequest,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::common::TestDatabase;
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
#[tokio::test]
async fn registers_and_verifies_active_master_key_identity() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let registered = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
let verified = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
assert_eq!(registered.epoch, 1);
assert_eq!(registered.status, "active");
assert_eq!(registered, verified);
database.cleanup().await;
}
#[tokio::test]
async fn rejects_different_active_master_key_identity() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
let error = registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap_err();
assert!(matches!(
error,
RegistryError::MasterKeyIdentityMismatch { epoch: 1 }
));
assert!(!error.to_string().contains("fedcba"));
assert!(!error.to_string().contains("012345"));
database.cleanup().await;
}
#[tokio::test]
async fn master_key_rotation_is_resumable_and_promotes_atomically() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let later = timestamp("2026-08-21T00:01:00Z");
let current_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: current_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_a", "cipher-a", &now).await;
insert_secret_version(&registry, "secret_b", "cipher-b", &now).await;
let snapshot = registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap();
assert_eq!(snapshot.len(), 2);
let preflight_status = registry.master_key_rotation_status().await.unwrap();
assert_eq!(preflight_status.active_identity.unwrap().epoch, 1);
assert!(preflight_status.rotations.is_empty());
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.iter()
.all(|version| version.target_ciphertext.is_none())
);
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &now)
.await
.unwrap();
assert_eq!(rotation.state, "running");
assert_eq!(rotation.total_secret_versions, 2);
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_a"),
1,
1,
"target-cipher-a",
"v2",
2,
&later,
)
.await
.unwrap();
let resumed = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &later)
.await
.unwrap();
assert_eq!(resumed.processed_secret_versions, 1);
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_b"),
1,
1,
"target-cipher-b",
"v2",
2,
&later,
)
.await
.unwrap();
let ready = registry
.finish_master_key_rotation_batches(&rotation.id, &later)
.await
.unwrap();
assert_eq!(ready.state, "verifying");
registry
.verify_master_key_rotation(&rotation.id, 2, &later)
.await
.unwrap();
registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: 2,
fingerprint: target_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &later,
},
&later,
)
.await
.unwrap();
let status = registry.master_key_rotation_status().await.unwrap();
let active = status.active_identity.unwrap();
assert_eq!(active.epoch, 2);
assert_eq!(active.fingerprint, target_fingerprint);
assert_eq!(status.rotations[0].state, "promoted");
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.is_empty()
);
let promoted = registry
.list_secret_versions_for_master_key_epoch(2)
.await
.unwrap();
assert_eq!(promoted.len(), 2);
assert!(
promoted
.iter()
.all(|version| version.target_ciphertext.is_none())
);
database.cleanup().await;
}
#[tokio::test]
async fn abort_preserves_current_epoch_and_clears_target_ciphertext() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_abort", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(
1,
2,
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
None,
&now,
)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_abort"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.abort_master_key_rotation(&rotation.id, &now)
.await
.unwrap();
let status = registry.master_key_rotation_status().await.unwrap();
assert_eq!(status.active_identity.unwrap().epoch, 1);
assert_eq!(status.rotations[0].state, "aborted");
let versions = registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap();
assert_eq!(versions.len(), 1);
assert!(versions[0].target_ciphertext.is_none());
database.cleanup().await;
}
#[tokio::test]
async fn aborted_rotation_can_be_rerun_for_same_source_epoch() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "secret_retry", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("secret_retry"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.abort_master_key_rotation(&rotation.id, &now)
.await
.unwrap();
let rerun = registry
.begin_master_key_rotation(1, 2, target_fingerprint, Some("second-attempt"), &now)
.await
.unwrap();
assert_eq!(rerun.id, rotation.id);
assert_eq!(rerun.state, "running");
assert_eq!(rerun.processed_secret_versions, 0);
assert_eq!(rerun.backup_ref.as_deref(), Some("second-attempt"));
assert!(
registry
.list_secret_versions_for_master_key_epoch(1)
.await
.unwrap()
.iter()
.all(|version| version.target_ciphertext.is_none())
);
database.cleanup().await;
}
#[tokio::test]
async fn active_rotation_rejects_new_secret_writes() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "existing_secret", "cipher-a", &now).await;
registry
.begin_master_key_rotation(
1,
2,
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
None,
&now,
)
.await
.unwrap();
let create_error = registry
.create_secret(CreateSecretRequest {
secret: &test_secret("new_secret", &now),
ciphertext: "cipher-new",
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
create_error,
RegistryError::MasterKeyRotationInProgress
));
let rotate_error = registry
.rotate_secret(RotateSecretRequest {
workspace_id: &WorkspaceId::new("ws_default"),
secret_id: &SecretId::new("existing_secret"),
ciphertext: "cipher-rotated",
key_version: "v2",
master_key_epoch: 1,
created_at: &now,
updated_at: &now,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
rotate_error,
RegistryError::MasterKeyRotationInProgress
));
let delete_error = registry
.delete_secret(
&WorkspaceId::new("ws_default"),
&SecretId::new("existing_secret"),
)
.await
.unwrap_err();
assert!(matches!(
delete_error,
RegistryError::MasterKeyRotationInProgress
));
database.cleanup().await;
}
#[tokio::test]
async fn stale_source_epoch_writer_is_rejected_after_promotion() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let now = timestamp("2026-08-21T00:00:00Z");
let source_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
registry
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
epoch: 1,
fingerprint: source_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
})
.await
.unwrap();
insert_secret_version(&registry, "promoted_secret", "cipher-a", &now).await;
let rotation = registry
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
.await
.unwrap();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&SecretId::new("promoted_secret"),
1,
1,
"target-cipher-a",
"v2",
2,
&now,
)
.await
.unwrap();
registry
.finish_master_key_rotation_batches(&rotation.id, &now)
.await
.unwrap();
registry
.verify_master_key_rotation(&rotation.id, 1, &now)
.await
.unwrap();
registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: 2,
fingerprint: target_fingerprint,
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
},
&now,
)
.await
.unwrap();
let create_error = registry
.create_secret(CreateSecretRequest {
secret: &test_secret("stale_new_secret", &now),
ciphertext: "cipher-new",
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
create_error,
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
));
let rotate_error = registry
.rotate_secret(RotateSecretRequest {
workspace_id: &WorkspaceId::new("ws_default"),
secret_id: &SecretId::new("promoted_secret"),
ciphertext: "cipher-rotated",
key_version: "v2",
master_key_epoch: 1,
created_at: &now,
updated_at: &now,
created_by: None,
})
.await
.unwrap_err();
assert!(matches!(
rotate_error,
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
));
database.cleanup().await;
}
async fn insert_secret_version(
registry: &crank_registry::PostgresRegistry,
id: &str,
ciphertext: &str,
now: &OffsetDateTime,
) {
registry
.create_secret(CreateSecretRequest {
secret: &test_secret(id, now),
ciphertext,
key_version: "v2",
master_key_epoch: 1,
created_by: None,
})
.await
.unwrap();
}
fn test_secret(id: &str, now: &OffsetDateTime) -> Secret {
Secret {
id: SecretId::new(id),
workspace_id: WorkspaceId::new("ws_default"),
name: id.to_owned(),
kind: SecretKind::Token,
status: SecretStatus::Active,
current_version: 1,
created_at: *now,
updated_at: *now,
last_used_at: None,
}
}