527 lines
16 KiB
Rust
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(®istry, "secret_a", "cipher-a", &now).await;
|
|
insert_secret_version(®istry, "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(®istry, "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(®istry, "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(®istry, "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(®istry, "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,
|
|
}
|
|
}
|