private_artifact.rs (37411B)
1 //! Protected private-artifact metadata contracts. 2 //! 3 //! These contracts intentionally contain neither plaintext nor ciphertext. 4 //! Encryption, key access, and backend schema remain implementation details. 5 6 use core::fmt; 7 use radroots_transport::BoxFuture; 8 use sha2::{Digest, Sha256}; 9 10 use crate::Error; 11 12 pub const ARTIFACT_KIND_MAX_BYTES: usize = 96; 13 pub const ARTIFACT_SCHEMA_MAX_BYTES: usize = 128; 14 pub const SECRET_PROVIDER_MAX_BYTES: usize = 64; 15 pub const SECRET_REFERENCE_MAX_BYTES: usize = 512; 16 pub const EXPIRED_ARTIFACT_QUERY_LIMIT_MAX: u16 = 256; 17 pub const PRIVATE_ARTIFACT_ENVELOPE_PURPOSE_PREFIX: &str = "radroots.private_artifact."; 18 pub const PRIVATE_ARTIFACT_ENVELOPE_SUBJECT_TYPE: &str = "private_artifact"; 19 const ENVELOPE_CONTEXT_DOMAIN: &[u8] = b"radroots.envelope_context.v1"; 20 const ENVELOPE_CONTEXT_VERSION: u16 = 1; 21 22 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] 23 pub struct PrivateArtifactId([u8; 16]); 24 25 impl PrivateArtifactId { 26 pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> { 27 if bytes_are_zero(&bytes) { 28 return Err(Error::InvalidPrivateArtifactId); 29 } 30 Ok(Self(bytes)) 31 } 32 pub const fn as_bytes(&self) -> &[u8; 16] { 33 &self.0 34 } 35 } 36 37 impl fmt::Debug for PrivateArtifactId { 38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 39 formatter.write_str("PrivateArtifactId(<redacted>)") 40 } 41 } 42 43 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 44 pub struct ArtifactKind(String); 45 46 impl ArtifactKind { 47 pub fn parse(value: impl Into<String>) -> Result<Self, Error> { 48 let value = value.into(); 49 if !valid_namespaced(value.as_str(), ARTIFACT_KIND_MAX_BYTES, 2) { 50 return Err(Error::InvalidPrivateArtifactKind); 51 } 52 Ok(Self(value)) 53 } 54 pub fn as_str(&self) -> &str { 55 self.0.as_str() 56 } 57 } 58 59 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 60 pub struct ArtifactSchemaId(String); 61 62 impl ArtifactSchemaId { 63 pub fn parse(value: impl Into<String>) -> Result<Self, Error> { 64 let value = value.into(); 65 if !valid_schema(value.as_str()) { 66 return Err(Error::InvalidPrivateArtifactSchema); 67 } 68 Ok(Self(value)) 69 } 70 pub fn as_str(&self) -> &str { 71 self.0.as_str() 72 } 73 } 74 75 /// Opaque envelope context derived only from immutable artifact metadata. 76 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] 77 pub struct PrivateArtifactEnvelopeContext { 78 purpose: String, 79 subject_type: &'static str, 80 subject: String, 81 payload_schema: String, 82 } 83 84 impl PrivateArtifactEnvelopeContext { 85 fn derive( 86 artifact_id: PrivateArtifactId, 87 kind: &ArtifactKind, 88 schema_id: &ArtifactSchemaId, 89 ) -> Self { 90 Self { 91 purpose: format!( 92 "{PRIVATE_ARTIFACT_ENVELOPE_PURPOSE_PREFIX}{}", 93 kind.as_str() 94 ), 95 subject_type: PRIVATE_ARTIFACT_ENVELOPE_SUBJECT_TYPE, 96 subject: hex_artifact_id(artifact_id), 97 payload_schema: schema_id.as_str().to_owned(), 98 } 99 } 100 101 pub fn purpose(&self) -> &str { 102 self.purpose.as_str() 103 } 104 pub const fn subject_type(&self) -> &'static str { 105 self.subject_type 106 } 107 pub fn subject(&self) -> &str { 108 self.subject.as_str() 109 } 110 pub fn payload_schema(&self) -> &str { 111 self.payload_schema.as_str() 112 } 113 pub fn fingerprint(&self) -> [u8; 32] { 114 Sha256::digest(self.canonical_bytes()).into() 115 } 116 117 fn canonical_bytes(&self) -> Vec<u8> { 118 let mut encoded = Vec::new(); 119 encoded.extend_from_slice(&ENVELOPE_CONTEXT_VERSION.to_be_bytes()); 120 encoded.extend_from_slice(ENVELOPE_CONTEXT_DOMAIN); 121 for value in [ 122 self.purpose.as_bytes(), 123 self.subject_type.as_bytes(), 124 self.subject.as_bytes(), 125 self.payload_schema.as_bytes(), 126 ] { 127 let length = u16::try_from(value.len()) 128 .expect("validated private-artifact envelope context fits u16"); 129 encoded.extend_from_slice(&length.to_be_bytes()); 130 encoded.extend_from_slice(value); 131 } 132 encoded 133 } 134 } 135 136 impl fmt::Debug for PrivateArtifactEnvelopeContext { 137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 138 formatter 139 .debug_struct("PrivateArtifactEnvelopeContext") 140 .field("purpose", &"<derived>") 141 .field("subject_type", &self.subject_type) 142 .field("subject", &"<redacted>") 143 .field("payload_schema", &"<derived>") 144 .finish() 145 } 146 } 147 148 /// SHA-256 commitment to the exact protected representation. 149 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 150 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 151 pub struct ArtifactCommitment([u8; 32]); 152 153 impl ArtifactCommitment { 154 pub const fn new(bytes: [u8; 32]) -> Self { 155 Self(bytes) 156 } 157 pub const fn as_bytes(&self) -> &[u8; 32] { 158 &self.0 159 } 160 } 161 162 /// Persistable provider reference metadata, never a secret capability itself. 163 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] 164 pub struct DurableSecretReference { 165 provider: String, 166 opaque_reference: String, 167 key_version: u32, 168 } 169 170 impl DurableSecretReference { 171 pub fn new( 172 provider: impl Into<String>, 173 opaque_reference: impl Into<String>, 174 key_version: u32, 175 ) -> Result<Self, Error> { 176 let provider = provider.into(); 177 let opaque_reference = opaque_reference.into(); 178 if !valid_label(provider.as_str(), SECRET_PROVIDER_MAX_BYTES) 179 || opaque_reference.is_empty() 180 || opaque_reference.len() > SECRET_REFERENCE_MAX_BYTES 181 || opaque_reference != opaque_reference.trim() 182 || opaque_reference.chars().any(char::is_control) 183 || key_version == 0 184 { 185 return Err(Error::InvalidPrivateArtifactSecretReference); 186 } 187 Ok(Self { 188 provider, 189 opaque_reference, 190 key_version, 191 }) 192 } 193 pub fn provider(&self) -> &str { 194 self.provider.as_str() 195 } 196 pub fn opaque_reference(&self) -> &str { 197 self.opaque_reference.as_str() 198 } 199 pub const fn key_version(&self) -> u32 { 200 self.key_version 201 } 202 } 203 204 impl fmt::Debug for DurableSecretReference { 205 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 206 formatter 207 .debug_struct("DurableSecretReference") 208 .field("provider", &self.provider) 209 .field("opaque_reference", &"[REDACTED]") 210 .field("key_version", &self.key_version) 211 .finish() 212 } 213 } 214 215 #[cfg(feature = "serde")] 216 impl serde::Serialize for DurableSecretReference { 217 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 218 where 219 S: serde::Serializer, 220 { 221 use serde::ser::SerializeStruct; 222 let mut state = serializer.serialize_struct("DurableSecretReference", 3)?; 223 state.serialize_field("provider", &self.provider)?; 224 state.serialize_field("opaque_reference", &self.opaque_reference)?; 225 state.serialize_field("key_version", &self.key_version)?; 226 state.end() 227 } 228 } 229 230 #[cfg(feature = "serde")] 231 impl<'de> serde::Deserialize<'de> for DurableSecretReference { 232 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 233 where 234 D: serde::Deserializer<'de>, 235 { 236 #[derive(serde::Deserialize)] 237 #[serde(deny_unknown_fields)] 238 struct Wire { 239 provider: String, 240 opaque_reference: String, 241 key_version: u32, 242 } 243 let wire = Wire::deserialize(deserializer)?; 244 Self::new(wire.provider, wire.opaque_reference, wire.key_version) 245 .map_err(serde::de::Error::custom) 246 } 247 } 248 249 /// Minimum retention and optional automatic expiry policy. 250 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 251 pub struct RetentionPolicy { 252 delete_not_before_unix_ms: Option<u64>, 253 expires_at_unix_ms: Option<u64>, 254 } 255 256 impl RetentionPolicy { 257 pub const fn indefinite() -> Self { 258 Self { 259 delete_not_before_unix_ms: None, 260 expires_at_unix_ms: None, 261 } 262 } 263 pub const fn new( 264 delete_not_before_unix_ms: Option<u64>, 265 expires_at_unix_ms: Option<u64>, 266 ) -> Result<Self, Error> { 267 if matches!(delete_not_before_unix_ms, Some(0)) || matches!(expires_at_unix_ms, Some(0)) { 268 return Err(Error::InvalidPrivateArtifactRetention); 269 } 270 Ok(Self { 271 delete_not_before_unix_ms, 272 expires_at_unix_ms, 273 }) 274 } 275 pub const fn delete_not_before_unix_ms(self) -> Option<u64> { 276 self.delete_not_before_unix_ms 277 } 278 pub const fn expires_at_unix_ms(self) -> Option<u64> { 279 self.expires_at_unix_ms 280 } 281 pub const fn is_expired_at(self, unix_ms: u64) -> bool { 282 matches!(self.expires_at_unix_ms, Some(expires) if unix_ms >= expires) 283 } 284 pub const fn permits_deletion_at(self, unix_ms: u64) -> bool { 285 !matches!(self.delete_not_before_unix_ms, Some(not_before) if unix_ms < not_before) 286 } 287 } 288 289 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] 290 pub struct PrivateArtifactRevision(u64); 291 292 impl PrivateArtifactRevision { 293 pub const INITIAL: Self = Self(1); 294 pub const fn new(value: u64) -> Result<Self, Error> { 295 if value == 0 { 296 Err(Error::InvalidPrivateArtifactRevision) 297 } else { 298 Ok(Self(value)) 299 } 300 } 301 pub const fn get(self) -> u64 { 302 self.0 303 } 304 fn next(self) -> Result<Self, Error> { 305 self.0 306 .checked_add(1) 307 .map(Self) 308 .ok_or(Error::CorruptPrivateArtifactMetadata) 309 } 310 } 311 312 /// Host-generated idempotency identity for one reseal commit. 313 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] 314 pub struct PrivateArtifactResealId([u8; 16]); 315 316 impl PrivateArtifactResealId { 317 pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> { 318 if bytes_are_zero(&bytes) { 319 return Err(Error::InvalidPrivateArtifactResealId); 320 } 321 Ok(Self(bytes)) 322 } 323 pub const fn as_bytes(&self) -> &[u8; 16] { 324 &self.0 325 } 326 } 327 328 impl fmt::Debug for PrivateArtifactResealId { 329 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 330 formatter.write_str("PrivateArtifactResealId(<redacted>)") 331 } 332 } 333 334 /// Backend-neutral metadata fence for one atomic envelope reseal. 335 #[derive(Clone, Eq, PartialEq)] 336 pub struct PrivateArtifactResealRequest { 337 reseal_id: PrivateArtifactResealId, 338 artifact_id: PrivateArtifactId, 339 expected_revision: PrivateArtifactRevision, 340 expected_commitment: ArtifactCommitment, 341 next_commitment: ArtifactCommitment, 342 next_protected_size_bytes: u64, 343 next_secret_reference: DurableSecretReference, 344 committed_at_unix_ms: u64, 345 } 346 347 impl fmt::Debug for PrivateArtifactResealRequest { 348 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 349 formatter 350 .debug_struct("PrivateArtifactResealRequest") 351 .field("reseal_id", &self.reseal_id) 352 .field("artifact_id", &self.artifact_id) 353 .field("expected_revision", &self.expected_revision) 354 .field("expected_commitment", &"<commitment>") 355 .field("next_commitment", &"<commitment>") 356 .field("next_protected_size_bytes", &self.next_protected_size_bytes) 357 .field("next_secret_reference", &self.next_secret_reference) 358 .field("committed_at_unix_ms", &self.committed_at_unix_ms) 359 .finish() 360 } 361 } 362 363 impl PrivateArtifactResealRequest { 364 #[allow(clippy::too_many_arguments)] 365 pub fn new( 366 reseal_id: PrivateArtifactResealId, 367 artifact_id: PrivateArtifactId, 368 expected_revision: PrivateArtifactRevision, 369 expected_commitment: ArtifactCommitment, 370 next_commitment: ArtifactCommitment, 371 next_protected_size_bytes: u64, 372 next_secret_reference: DurableSecretReference, 373 committed_at_unix_ms: u64, 374 ) -> Result<Self, Error> { 375 if expected_commitment == next_commitment 376 || next_protected_size_bytes == 0 377 || committed_at_unix_ms == 0 378 { 379 return Err(Error::InvalidPrivateArtifactResealRequest); 380 } 381 Ok(Self { 382 reseal_id, 383 artifact_id, 384 expected_revision, 385 expected_commitment, 386 next_commitment, 387 next_protected_size_bytes, 388 next_secret_reference, 389 committed_at_unix_ms, 390 }) 391 } 392 393 pub const fn reseal_id(&self) -> PrivateArtifactResealId { 394 self.reseal_id 395 } 396 pub const fn artifact_id(&self) -> PrivateArtifactId { 397 self.artifact_id 398 } 399 pub const fn expected_revision(&self) -> PrivateArtifactRevision { 400 self.expected_revision 401 } 402 pub const fn expected_commitment(&self) -> ArtifactCommitment { 403 self.expected_commitment 404 } 405 pub const fn next_commitment(&self) -> ArtifactCommitment { 406 self.next_commitment 407 } 408 pub const fn next_protected_size_bytes(&self) -> u64 { 409 self.next_protected_size_bytes 410 } 411 pub const fn next_secret_reference(&self) -> &DurableSecretReference { 412 &self.next_secret_reference 413 } 414 pub const fn committed_at_unix_ms(&self) -> u64 { 415 self.committed_at_unix_ms 416 } 417 pub fn fingerprint(&self) -> [u8; 32] { 418 let mut hasher = Sha256::new(); 419 hasher.update(self.reseal_id.as_bytes()); 420 hasher.update(self.artifact_id.as_bytes()); 421 hasher.update(self.expected_revision.get().to_be_bytes()); 422 hasher.update(self.expected_commitment.as_bytes()); 423 hasher.update(self.next_commitment.as_bytes()); 424 hasher.update(self.next_protected_size_bytes.to_be_bytes()); 425 hash_string(&mut hasher, self.next_secret_reference.provider()); 426 hash_string(&mut hasher, self.next_secret_reference.opaque_reference()); 427 hasher.update(self.next_secret_reference.key_version().to_be_bytes()); 428 hasher.update(self.committed_at_unix_ms.to_be_bytes()); 429 hasher.finalize().into() 430 } 431 } 432 433 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 434 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 435 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 436 pub enum PrivateArtifactResealDisposition { 437 Committed, 438 Replayed, 439 } 440 441 /// Durable receipt used to distinguish exact replay from conflicting reuse. 442 #[derive(Clone, Copy, Eq, PartialEq)] 443 pub struct PrivateArtifactResealReceipt { 444 reseal_id: PrivateArtifactResealId, 445 artifact_id: PrivateArtifactId, 446 committed_revision: PrivateArtifactRevision, 447 request_fingerprint: [u8; 32], 448 disposition: PrivateArtifactResealDisposition, 449 } 450 451 impl PrivateArtifactResealReceipt { 452 pub fn committed( 453 request: &PrivateArtifactResealRequest, 454 committed_revision: PrivateArtifactRevision, 455 ) -> Self { 456 Self { 457 reseal_id: request.reseal_id, 458 artifact_id: request.artifact_id, 459 committed_revision, 460 request_fingerprint: request.fingerprint(), 461 disposition: PrivateArtifactResealDisposition::Committed, 462 } 463 } 464 465 pub fn replay(&self, request: &PrivateArtifactResealRequest) -> Result<Self, Error> { 466 if self.reseal_id != request.reseal_id 467 || self.artifact_id != request.artifact_id 468 || self.request_fingerprint != request.fingerprint() 469 { 470 return Err(Error::PrivateArtifactResealConflict); 471 } 472 Ok(Self { 473 disposition: PrivateArtifactResealDisposition::Replayed, 474 ..*self 475 }) 476 } 477 478 pub const fn reseal_id(self) -> PrivateArtifactResealId { 479 self.reseal_id 480 } 481 pub const fn artifact_id(self) -> PrivateArtifactId { 482 self.artifact_id 483 } 484 pub const fn committed_revision(self) -> PrivateArtifactRevision { 485 self.committed_revision 486 } 487 pub const fn disposition(self) -> PrivateArtifactResealDisposition { 488 self.disposition 489 } 490 491 pub const fn request_fingerprint(self) -> [u8; 32] { 492 self.request_fingerprint 493 } 494 } 495 496 impl fmt::Debug for PrivateArtifactResealReceipt { 497 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 498 formatter 499 .debug_struct("PrivateArtifactResealReceipt") 500 .field("reseal_id", &self.reseal_id) 501 .field("artifact_id", &self.artifact_id) 502 .field("committed_revision", &self.committed_revision) 503 .field("request_fingerprint", &"<commitment>") 504 .field("disposition", &self.disposition) 505 .finish() 506 } 507 } 508 509 /// Bounded migration inventory without artifact or user identity. 510 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 511 pub struct PrivateArtifactEnvelopeMigrationStatus { 512 pub v1_pending: u64, 513 pub v2_current: u64, 514 pub corrupt: u64, 515 pub blocked_provider: u64, 516 pub conflicted: u64, 517 } 518 519 impl PrivateArtifactEnvelopeMigrationStatus { 520 pub fn total(self) -> Option<u64> { 521 self.v1_pending 522 .checked_add(self.v2_current)? 523 .checked_add(self.corrupt)? 524 .checked_add(self.blocked_provider)? 525 .checked_add(self.conflicted) 526 } 527 } 528 529 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 530 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 531 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 532 pub enum PrivateArtifactStage { 533 Active, 534 Expired, 535 Tombstoned, 536 } 537 538 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 539 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 540 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 541 pub enum DeletionReason { 542 UserRequested, 543 RetentionExpired, 544 KeyRevoked, 545 IntegrityFailure, 546 OperatorRequested, 547 } 548 549 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 550 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 551 pub struct ArtifactTombstone { 552 deleted_at_unix_ms: u64, 553 reason: DeletionReason, 554 commitment: ArtifactCommitment, 555 } 556 557 impl ArtifactTombstone { 558 pub const fn deleted_at_unix_ms(self) -> u64 { 559 self.deleted_at_unix_ms 560 } 561 pub const fn reason(self) -> DeletionReason { 562 self.reason 563 } 564 pub const fn commitment(self) -> ArtifactCommitment { 565 self.commitment 566 } 567 } 568 569 /// Metadata for one protected artifact; no protected bytes are present. 570 #[derive(Clone, Debug, Eq, PartialEq)] 571 pub struct PrivateArtifactMetadata { 572 artifact_id: PrivateArtifactId, 573 kind: ArtifactKind, 574 schema_id: ArtifactSchemaId, 575 commitment: ArtifactCommitment, 576 protected_size_bytes: u64, 577 secret_reference: DurableSecretReference, 578 retention: RetentionPolicy, 579 revision: PrivateArtifactRevision, 580 stage: PrivateArtifactStage, 581 created_at_unix_ms: u64, 582 updated_at_unix_ms: u64, 583 tombstone: Option<ArtifactTombstone>, 584 } 585 586 impl PrivateArtifactMetadata { 587 #[allow(clippy::too_many_arguments)] 588 pub fn new( 589 artifact_id: PrivateArtifactId, 590 kind: ArtifactKind, 591 schema_id: ArtifactSchemaId, 592 commitment: ArtifactCommitment, 593 protected_size_bytes: u64, 594 secret_reference: DurableSecretReference, 595 retention: RetentionPolicy, 596 created_at_unix_ms: u64, 597 ) -> Result<Self, Error> { 598 if protected_size_bytes == 0 599 || created_at_unix_ms == 0 600 || matches!(retention.delete_not_before_unix_ms(), Some(value) if value < created_at_unix_ms) 601 || matches!(retention.expires_at_unix_ms(), Some(value) if value < created_at_unix_ms) 602 { 603 return Err(Error::InvalidPrivateArtifactMetadata); 604 } 605 Ok(Self { 606 artifact_id, 607 kind, 608 schema_id, 609 commitment, 610 protected_size_bytes, 611 secret_reference, 612 retention, 613 revision: PrivateArtifactRevision::INITIAL, 614 stage: PrivateArtifactStage::Active, 615 created_at_unix_ms, 616 updated_at_unix_ms: created_at_unix_ms, 617 tombstone: None, 618 }) 619 } 620 621 /// Reconstructs and validates metadata at a durable backend boundary. 622 #[allow(clippy::too_many_arguments)] 623 pub fn from_durable_parts( 624 artifact_id: PrivateArtifactId, 625 kind: ArtifactKind, 626 schema_id: ArtifactSchemaId, 627 commitment: ArtifactCommitment, 628 protected_size_bytes: u64, 629 secret_reference: DurableSecretReference, 630 retention: RetentionPolicy, 631 revision: PrivateArtifactRevision, 632 stage: PrivateArtifactStage, 633 created_at_unix_ms: u64, 634 updated_at_unix_ms: u64, 635 tombstone: Option<(u64, DeletionReason, ArtifactCommitment)>, 636 ) -> Result<Self, Error> { 637 let initial = Self::new( 638 artifact_id, 639 kind, 640 schema_id, 641 commitment, 642 protected_size_bytes, 643 secret_reference, 644 retention, 645 created_at_unix_ms, 646 )?; 647 if updated_at_unix_ms < created_at_unix_ms { 648 return Err(Error::CorruptPrivateArtifactMetadata); 649 } 650 let tombstone = 651 tombstone.map( 652 |(deleted_at_unix_ms, reason, tombstone_commitment)| ArtifactTombstone { 653 deleted_at_unix_ms, 654 reason, 655 commitment: tombstone_commitment, 656 }, 657 ); 658 let valid = match (stage, revision.get(), tombstone) { 659 (PrivateArtifactStage::Active, revision, None) => { 660 (revision == 1 && updated_at_unix_ms == created_at_unix_ms) 661 || (revision > 1 && updated_at_unix_ms > created_at_unix_ms) 662 } 663 (PrivateArtifactStage::Expired, revision, None) => { 664 revision >= 2 && retention.is_expired_at(updated_at_unix_ms) 665 } 666 (PrivateArtifactStage::Tombstoned, revision, Some(tombstone)) => { 667 revision >= 2 668 && tombstone.deleted_at_unix_ms == updated_at_unix_ms 669 && tombstone.commitment == commitment 670 && retention.permits_deletion_at(updated_at_unix_ms) 671 && (tombstone.reason != DeletionReason::RetentionExpired 672 || retention.is_expired_at(updated_at_unix_ms)) 673 } 674 _ => false, 675 }; 676 if !valid { 677 return Err(Error::CorruptPrivateArtifactMetadata); 678 } 679 Ok(Self { 680 revision, 681 stage, 682 updated_at_unix_ms, 683 tombstone, 684 ..initial 685 }) 686 } 687 pub const fn artifact_id(&self) -> PrivateArtifactId { 688 self.artifact_id 689 } 690 pub const fn kind(&self) -> &ArtifactKind { 691 &self.kind 692 } 693 pub const fn schema_id(&self) -> &ArtifactSchemaId { 694 &self.schema_id 695 } 696 pub const fn commitment(&self) -> ArtifactCommitment { 697 self.commitment 698 } 699 pub const fn protected_size_bytes(&self) -> u64 { 700 self.protected_size_bytes 701 } 702 pub const fn secret_reference(&self) -> &DurableSecretReference { 703 &self.secret_reference 704 } 705 pub const fn retention(&self) -> RetentionPolicy { 706 self.retention 707 } 708 pub const fn revision(&self) -> PrivateArtifactRevision { 709 self.revision 710 } 711 pub const fn stage(&self) -> PrivateArtifactStage { 712 self.stage 713 } 714 pub const fn created_at_unix_ms(&self) -> u64 { 715 self.created_at_unix_ms 716 } 717 pub const fn updated_at_unix_ms(&self) -> u64 { 718 self.updated_at_unix_ms 719 } 720 pub const fn tombstone_record(&self) -> Option<ArtifactTombstone> { 721 self.tombstone 722 } 723 724 /// Derives the only valid envelope context for this artifact. 725 pub fn envelope_context(&self) -> PrivateArtifactEnvelopeContext { 726 PrivateArtifactEnvelopeContext::derive(self.artifact_id, &self.kind, &self.schema_id) 727 } 728 729 /// Applies the metadata half of a fenced envelope reseal. 730 pub fn resealed(&self, request: &PrivateArtifactResealRequest) -> Result<Self, Error> { 731 if self.stage != PrivateArtifactStage::Active 732 || request.artifact_id != self.artifact_id 733 || request.expected_revision != self.revision 734 || request.expected_commitment != self.commitment 735 { 736 return Err(Error::PrivateArtifactResealConflict); 737 } 738 if request.committed_at_unix_ms <= self.updated_at_unix_ms { 739 return Err(Error::InvalidPrivateArtifactTimestamp); 740 } 741 let mut next = self.clone(); 742 next.commitment = request.next_commitment; 743 next.protected_size_bytes = request.next_protected_size_bytes; 744 next.secret_reference = request.next_secret_reference.clone(); 745 next.revision = self.revision.next()?; 746 next.updated_at_unix_ms = request.committed_at_unix_ms; 747 Ok(next) 748 } 749 750 pub fn mark_expired( 751 &self, 752 expected_revision: PrivateArtifactRevision, 753 at_unix_ms: u64, 754 ) -> Result<Self, Error> { 755 self.validate_transition(expected_revision, at_unix_ms)?; 756 if self.stage != PrivateArtifactStage::Active || !self.retention.is_expired_at(at_unix_ms) { 757 return Err(Error::PrivateArtifactNotExpired); 758 } 759 let mut next = self.clone(); 760 next.revision = self.revision.next()?; 761 next.stage = PrivateArtifactStage::Expired; 762 next.updated_at_unix_ms = at_unix_ms; 763 Ok(next) 764 } 765 766 pub fn tombstone( 767 &self, 768 expected_revision: PrivateArtifactRevision, 769 at_unix_ms: u64, 770 reason: DeletionReason, 771 ) -> Result<Self, Error> { 772 self.validate_transition(expected_revision, at_unix_ms)?; 773 if self.stage == PrivateArtifactStage::Tombstoned { 774 return Err(Error::PrivateArtifactTombstoned); 775 } 776 if !self.retention.permits_deletion_at(at_unix_ms) { 777 return Err(Error::PrivateArtifactRetentionActive); 778 } 779 if reason == DeletionReason::RetentionExpired && !self.retention.is_expired_at(at_unix_ms) { 780 return Err(Error::PrivateArtifactNotExpired); 781 } 782 let mut next = self.clone(); 783 next.revision = self.revision.next()?; 784 next.stage = PrivateArtifactStage::Tombstoned; 785 next.updated_at_unix_ms = at_unix_ms; 786 next.tombstone = Some(ArtifactTombstone { 787 deleted_at_unix_ms: at_unix_ms, 788 reason, 789 commitment: self.commitment, 790 }); 791 Ok(next) 792 } 793 794 fn validate_transition( 795 &self, 796 expected_revision: PrivateArtifactRevision, 797 at_unix_ms: u64, 798 ) -> Result<(), Error> { 799 if expected_revision != self.revision { 800 return Err(Error::PrivateArtifactRevisionConflict); 801 } 802 if at_unix_ms < self.updated_at_unix_ms { 803 return Err(Error::InvalidPrivateArtifactTimestamp); 804 } 805 Ok(()) 806 } 807 } 808 809 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 810 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 811 pub struct PrivateArtifactStatus { 812 pub active: u64, 813 pub expired: u64, 814 pub tombstoned: u64, 815 } 816 817 impl PrivateArtifactStatus { 818 pub fn total(self) -> Option<u64> { 819 self.active 820 .checked_add(self.expired)? 821 .checked_add(self.tombstoned) 822 } 823 } 824 825 /// Backend-neutral metadata-only private-artifact SPI. 826 pub trait PrivateArtifactStore: Send + Sync { 827 fn put_metadata( 828 &self, 829 metadata: PrivateArtifactMetadata, 830 ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>; 831 fn metadata( 832 &self, 833 artifact_id: PrivateArtifactId, 834 ) -> BoxFuture<'_, Result<Option<PrivateArtifactMetadata>, Error>>; 835 fn reseal_metadata( 836 &self, 837 request: PrivateArtifactResealRequest, 838 ) -> BoxFuture<'_, Result<PrivateArtifactResealReceipt, Error>>; 839 fn mark_expired( 840 &self, 841 artifact_id: PrivateArtifactId, 842 expected_revision: PrivateArtifactRevision, 843 at_unix_ms: u64, 844 ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>; 845 fn tombstone( 846 &self, 847 artifact_id: PrivateArtifactId, 848 expected_revision: PrivateArtifactRevision, 849 at_unix_ms: u64, 850 reason: DeletionReason, 851 ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>; 852 fn expired( 853 &self, 854 at_unix_ms: u64, 855 limit: u16, 856 ) -> BoxFuture<'_, Result<Vec<PrivateArtifactMetadata>, Error>>; 857 fn status(&self) -> BoxFuture<'_, Result<PrivateArtifactStatus, Error>>; 858 } 859 860 #[cfg(feature = "serde")] 861 impl serde::Serialize for PrivateArtifactId { 862 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 863 where 864 S: serde::Serializer, 865 { 866 self.0.serialize(serializer) 867 } 868 } 869 870 #[cfg(feature = "serde")] 871 impl<'de> serde::Deserialize<'de> for PrivateArtifactId { 872 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 873 where 874 D: serde::Deserializer<'de>, 875 { 876 let bytes = <[u8; 16]>::deserialize(deserializer)?; 877 Self::new(bytes).map_err(serde::de::Error::custom) 878 } 879 } 880 881 #[cfg(feature = "serde")] 882 impl serde::Serialize for ArtifactKind { 883 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 884 where 885 S: serde::Serializer, 886 { 887 self.0.serialize(serializer) 888 } 889 } 890 891 #[cfg(feature = "serde")] 892 impl<'de> serde::Deserialize<'de> for ArtifactKind { 893 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 894 where 895 D: serde::Deserializer<'de>, 896 { 897 let value = String::deserialize(deserializer)?; 898 Self::parse(value).map_err(serde::de::Error::custom) 899 } 900 } 901 902 #[cfg(feature = "serde")] 903 impl serde::Serialize for ArtifactSchemaId { 904 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 905 where 906 S: serde::Serializer, 907 { 908 self.0.serialize(serializer) 909 } 910 } 911 912 #[cfg(feature = "serde")] 913 impl<'de> serde::Deserialize<'de> for ArtifactSchemaId { 914 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 915 where 916 D: serde::Deserializer<'de>, 917 { 918 let value = String::deserialize(deserializer)?; 919 Self::parse(value).map_err(serde::de::Error::custom) 920 } 921 } 922 923 #[cfg(feature = "serde")] 924 impl serde::Serialize for RetentionPolicy { 925 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 926 where 927 S: serde::Serializer, 928 { 929 use serde::ser::SerializeStruct; 930 let mut state = serializer.serialize_struct("RetentionPolicy", 2)?; 931 state.serialize_field("delete_not_before_unix_ms", &self.delete_not_before_unix_ms)?; 932 state.serialize_field("expires_at_unix_ms", &self.expires_at_unix_ms)?; 933 state.end() 934 } 935 } 936 937 #[cfg(feature = "serde")] 938 impl<'de> serde::Deserialize<'de> for RetentionPolicy { 939 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 940 where 941 D: serde::Deserializer<'de>, 942 { 943 #[derive(serde::Deserialize)] 944 #[serde(deny_unknown_fields)] 945 struct Wire { 946 delete_not_before_unix_ms: Option<u64>, 947 expires_at_unix_ms: Option<u64>, 948 } 949 let wire = Wire::deserialize(deserializer)?; 950 Self::new(wire.delete_not_before_unix_ms, wire.expires_at_unix_ms) 951 .map_err(serde::de::Error::custom) 952 } 953 } 954 955 #[cfg(feature = "serde")] 956 impl serde::Serialize for PrivateArtifactRevision { 957 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 958 where 959 S: serde::Serializer, 960 { 961 self.0.serialize(serializer) 962 } 963 } 964 965 #[cfg(feature = "serde")] 966 impl<'de> serde::Deserialize<'de> for PrivateArtifactRevision { 967 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 968 where 969 D: serde::Deserializer<'de>, 970 { 971 Self::new(u64::deserialize(deserializer)?).map_err(serde::de::Error::custom) 972 } 973 } 974 975 #[cfg(feature = "serde")] 976 impl serde::Serialize for PrivateArtifactResealId { 977 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 978 where 979 S: serde::Serializer, 980 { 981 self.0.serialize(serializer) 982 } 983 } 984 985 #[cfg(feature = "serde")] 986 impl<'de> serde::Deserialize<'de> for PrivateArtifactResealId { 987 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 988 where 989 D: serde::Deserializer<'de>, 990 { 991 let bytes = <[u8; 16]>::deserialize(deserializer)?; 992 Self::new(bytes).map_err(serde::de::Error::custom) 993 } 994 } 995 996 #[cfg(feature = "serde")] 997 impl serde::Serialize for PrivateArtifactMetadata { 998 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 999 where 1000 S: serde::Serializer, 1001 { 1002 use serde::ser::SerializeStruct; 1003 let mut state = serializer.serialize_struct("PrivateArtifactMetadata", 12)?; 1004 state.serialize_field("artifact_id", &self.artifact_id)?; 1005 state.serialize_field("kind", &self.kind)?; 1006 state.serialize_field("schema_id", &self.schema_id)?; 1007 state.serialize_field("commitment", &self.commitment)?; 1008 state.serialize_field("protected_size_bytes", &self.protected_size_bytes)?; 1009 state.serialize_field("secret_reference", &self.secret_reference)?; 1010 state.serialize_field("retention", &self.retention)?; 1011 state.serialize_field("revision", &self.revision)?; 1012 state.serialize_field("stage", &self.stage)?; 1013 state.serialize_field("created_at_unix_ms", &self.created_at_unix_ms)?; 1014 state.serialize_field("updated_at_unix_ms", &self.updated_at_unix_ms)?; 1015 state.serialize_field("tombstone", &self.tombstone)?; 1016 state.end() 1017 } 1018 } 1019 1020 #[cfg(feature = "serde")] 1021 impl<'de> serde::Deserialize<'de> for PrivateArtifactMetadata { 1022 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 1023 where 1024 D: serde::Deserializer<'de>, 1025 { 1026 #[derive(serde::Deserialize)] 1027 #[serde(deny_unknown_fields)] 1028 struct Wire { 1029 artifact_id: PrivateArtifactId, 1030 kind: ArtifactKind, 1031 schema_id: ArtifactSchemaId, 1032 commitment: ArtifactCommitment, 1033 protected_size_bytes: u64, 1034 secret_reference: DurableSecretReference, 1035 retention: RetentionPolicy, 1036 revision: PrivateArtifactRevision, 1037 stage: PrivateArtifactStage, 1038 created_at_unix_ms: u64, 1039 updated_at_unix_ms: u64, 1040 tombstone: Option<ArtifactTombstone>, 1041 } 1042 let wire = Wire::deserialize(deserializer)?; 1043 Self::from_durable_parts( 1044 wire.artifact_id, 1045 wire.kind, 1046 wire.schema_id, 1047 wire.commitment, 1048 wire.protected_size_bytes, 1049 wire.secret_reference, 1050 wire.retention, 1051 wire.revision, 1052 wire.stage, 1053 wire.created_at_unix_ms, 1054 wire.updated_at_unix_ms, 1055 wire.tombstone.map(|tombstone| { 1056 ( 1057 tombstone.deleted_at_unix_ms(), 1058 tombstone.reason(), 1059 tombstone.commitment(), 1060 ) 1061 }), 1062 ) 1063 .map_err(serde::de::Error::custom) 1064 } 1065 } 1066 1067 fn valid_label(value: &str, max: usize) -> bool { 1068 !value.is_empty() 1069 && value.len() <= max 1070 && value == value.trim() 1071 && value.bytes().all(|byte| { 1072 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.') 1073 }) 1074 } 1075 1076 fn valid_schema(value: &str) -> bool { 1077 if !valid_namespaced(value, ARTIFACT_SCHEMA_MAX_BYTES, 3) { 1078 return false; 1079 } 1080 value.rsplit('.').next().is_some_and(|last| { 1081 last.strip_prefix('v').is_some_and(|version| { 1082 !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit()) 1083 }) 1084 }) 1085 } 1086 1087 fn valid_namespaced(value: &str, max: usize, minimum_segments: usize) -> bool { 1088 valid_label(value, max) 1089 && value.split('.').count() >= minimum_segments 1090 && value.split('.').all(|segment| { 1091 let mut bytes = segment.bytes(); 1092 bytes.next().is_some_and(|byte| byte.is_ascii_lowercase()) 1093 && bytes.all(|byte| { 1094 byte.is_ascii_lowercase() 1095 || byte.is_ascii_digit() 1096 || matches!(byte, b'_' | b'-') 1097 }) 1098 }) 1099 } 1100 1101 fn hex_artifact_id(artifact_id: PrivateArtifactId) -> String { 1102 const HEX: &[u8; 16] = b"0123456789abcdef"; 1103 let mut encoded = String::with_capacity(32); 1104 for byte in artifact_id.as_bytes() { 1105 encoded.push(char::from(HEX[usize::from(byte >> 4)])); 1106 encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); 1107 } 1108 encoded 1109 } 1110 1111 fn hash_string(hasher: &mut Sha256, value: &str) { 1112 let length = u32::try_from(value.len()).expect("validated private-artifact field fits u32"); 1113 hasher.update(length.to_be_bytes()); 1114 hasher.update(value.as_bytes()); 1115 } 1116 1117 const fn bytes_are_zero(bytes: &[u8; 16]) -> bool { 1118 let mut index = 0; 1119 while index < bytes.len() { 1120 if bytes[index] != 0 { 1121 return false; 1122 } 1123 index += 1; 1124 } 1125 true 1126 }