1use rand::RngCore;
45use serde::{Deserialize, Serialize};
46use zeroize::Zeroizing;
47
48use crate::clock::Clock;
49use crate::confirm::{ConfirmOutcome, ConfirmRequest, Confirmer};
50use crate::error::CoreError;
51use crate::keypair;
52use crate::policy;
53use crate::record::SecretRecord;
54use crate::secret::SecretValue;
55
56pub const PACKAGE_SCHEMA_VERSION: u32 = 1;
58
59pub const PACKAGE_MAGIC: &[u8; 4] = b"KVPK";
62
63const TOKEN_SECRET_LEN: usize = 32;
65
66const HEADER_LEN: usize = 4 + 4 + 8; #[derive(Debug, Serialize, Deserialize)]
75pub struct PackagePayload {
76 pub schema_version: u32,
78 pub environment: String,
81 pub created: String,
83 pub expires_at: u64,
86 pub token_commitment: String,
89 pub entries: Vec<SecretRecord>,
93}
94
95impl PackagePayload {
96 pub fn new(
99 environment: impl Into<String>,
100 created: impl Into<String>,
101 expires_at: u64,
102 entries: Vec<SecretRecord>,
103 ) -> Self {
104 Self {
105 schema_version: PACKAGE_SCHEMA_VERSION,
106 environment: environment.into(),
107 created: created.into(),
108 expires_at,
109 token_commitment: String::new(),
110 entries,
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Package {
123 pub version: u32,
125 pub expires_at: u64,
127 sealed: Vec<u8>,
129}
130
131impl Package {
132 fn new(version: u32, expires_at: u64, sealed: Vec<u8>) -> Self {
135 Self {
136 version,
137 expires_at,
138 sealed,
139 }
140 }
141
142 pub fn fingerprint(&self) -> String {
146 blake3::hash(&self.sealed).to_hex().to_string()
147 }
148
149 pub fn to_bytes(&self) -> Vec<u8> {
151 let mut out = Vec::with_capacity(HEADER_LEN + self.sealed.len());
152 out.extend_from_slice(PACKAGE_MAGIC);
153 out.extend_from_slice(&self.version.to_le_bytes());
154 out.extend_from_slice(&self.expires_at.to_le_bytes());
155 out.extend_from_slice(&self.sealed);
156 out
157 }
158
159 pub fn from_bytes(bytes: &[u8]) -> Result<Self, CoreError> {
162 if bytes.len() < HEADER_LEN || &bytes[..4] != PACKAGE_MAGIC {
163 return Err(CoreError::Package("not a kovra package frame".to_string()));
164 }
165 let version = u32::from_le_bytes(bytes[4..8].try_into().expect("checked length"));
166 if version != PACKAGE_SCHEMA_VERSION {
167 return Err(CoreError::Package(format!(
168 "unsupported package version {version}"
169 )));
170 }
171 let expires_at = u64::from_le_bytes(bytes[8..16].try_into().expect("checked length"));
172 Ok(Self::new(version, expires_at, bytes[HEADER_LEN..].to_vec()))
173 }
174}
175
176#[derive(Debug, Serialize, Deserialize)]
184pub struct AccessToken {
185 pub version: u32,
187 pub package_fingerprint: String,
190 pub expires_at: u64,
192 pub secret: SecretValue,
195}
196
197impl AccessToken {
198 pub fn to_bytes(&self) -> Result<Vec<u8>, CoreError> {
201 serde_json::to_vec(self).map_err(|e| CoreError::Serialization(e.to_string()))
202 }
203
204 pub fn from_bytes(bytes: &[u8]) -> Result<Self, CoreError> {
206 serde_json::from_slice(bytes).map_err(|e| CoreError::Serialization(e.to_string()))
207 }
208}
209
210pub fn seal(
219 mut payload: PackagePayload,
220 recipient_public_openssh: &str,
221) -> Result<(Package, AccessToken), CoreError> {
222 for entry in &payload.entries {
225 if policy::prod_not_packageable(entry.environment()) {
226 return Err(CoreError::Package(format!(
227 "refusing to package prod secret `{}` (I4a: prod is never packaged)",
228 entry.canonical_path()
229 )));
230 }
231 }
232
233 let mut secret = Zeroizing::new(vec![0u8; TOKEN_SECRET_LEN]);
236 rand::rngs::OsRng.fill_bytes(&mut secret);
237 payload.token_commitment = blake3::hash(&secret).to_hex().to_string();
238 payload.schema_version = PACKAGE_SCHEMA_VERSION;
239
240 let expires_at = payload.expires_at;
241 let plaintext = Zeroizing::new(
244 serde_json::to_vec(&payload).map_err(|e| CoreError::Serialization(e.to_string()))?,
245 );
246 let sealed = keypair::encrypt_to(recipient_public_openssh, &plaintext)?;
247 let package = Package::new(PACKAGE_SCHEMA_VERSION, expires_at, sealed);
248
249 let token = AccessToken {
250 version: PACKAGE_SCHEMA_VERSION,
251 package_fingerprint: package.fingerprint(),
252 expires_at,
253 secret: SecretValue::new(secret.to_vec()),
254 };
255 Ok((package, token))
256}
257
258pub fn open_attended(
265 package: &Package,
266 recipient_private_openssh: &str,
267 clock: &dyn Clock,
268) -> Result<PackagePayload, CoreError> {
269 if clock.unix_secs() > package.expires_at {
270 return Err(CoreError::Package("package has expired".to_string()));
271 }
272 let plaintext = keypair::decrypt(recipient_private_openssh, &package.sealed)?;
273 let payload: PackagePayload =
274 serde_json::from_slice(&plaintext).map_err(|e| CoreError::Serialization(e.to_string()))?;
275 Ok(payload)
276}
277
278pub fn open_unattended(
288 package: &Package,
289 token: &AccessToken,
290 recipient_private_openssh: &str,
291 clock: &dyn Clock,
292) -> Result<PackagePayload, CoreError> {
293 let payload = open_attended(package, recipient_private_openssh, clock)?;
294 verify_token(package, &payload, token, clock)?;
295 enforce_no_prod_unattended(&payload)?;
296 Ok(payload)
297}
298
299pub fn verify_token(
303 package: &Package,
304 payload: &PackagePayload,
305 token: &AccessToken,
306 clock: &dyn Clock,
307) -> Result<(), CoreError> {
308 if clock.unix_secs() > token.expires_at {
309 return Err(CoreError::Package("access token has expired".to_string()));
310 }
311 if token.package_fingerprint != package.fingerprint() {
312 return Err(CoreError::Package(
313 "access token does not match this package".to_string(),
314 ));
315 }
316 let presented = blake3::hash(token.secret.expose()).to_hex().to_string();
317 if presented != payload.token_commitment {
318 return Err(CoreError::Package(
319 "access token secret is not valid for this package".to_string(),
320 ));
321 }
322 Ok(())
323}
324
325pub fn enforce_no_prod_unattended(payload: &PackagePayload) -> Result<(), CoreError> {
328 for entry in &payload.entries {
329 if policy::prod_blocks_unattended(entry.environment()) {
330 return Err(CoreError::Package(format!(
331 "prod secret `{}` cannot be delivered unattended (I4b)",
332 entry.canonical_path()
333 )));
334 }
335 }
336 Ok(())
337}
338
339pub struct TokenConfirmer {
345 approved: bool,
346}
347
348impl TokenConfirmer {
349 pub fn new(
352 package: &Package,
353 payload: &PackagePayload,
354 token: &AccessToken,
355 clock: &dyn Clock,
356 ) -> Self {
357 Self {
358 approved: verify_token(package, payload, token, clock).is_ok(),
359 }
360 }
361}
362
363impl Confirmer for TokenConfirmer {
364 fn confirm(&self, _req: &ConfirmRequest, _timeout: std::time::Duration) -> ConfirmOutcome {
365 if self.approved {
366 ConfirmOutcome::Approved
367 } else {
368 ConfirmOutcome::Denied
369 }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::clock::MockClock;
377 use crate::keypair::{KeyAlgorithm, generate};
378 use crate::sensitivity::Sensitivity;
379
380 const HOUR: u64 = 3600;
381
382 fn now() -> u64 {
383 MockClock::default().unix_secs()
384 }
385
386 fn literal(env: &str, key: &str, value: &str) -> SecretRecord {
387 SecretRecord::Literal {
388 value: SecretValue::from(value),
389 sensitivity: Sensitivity::Medium,
390 revealable: false,
391 environment: env.to_string(),
392 component: "app".to_string(),
393 key: key.to_string(),
394 description: None,
395 created: "2026-05-30T00:00:00Z".to_string(),
396 updated: "2026-05-30T00:00:00Z".to_string(),
397 }
398 }
399
400 fn reference(env: &str, key: &str, uri: &str) -> SecretRecord {
401 SecretRecord::Reference {
402 reference: uri.to_string(),
403 sensitivity: Sensitivity::Medium,
404 revealable: false,
405 environment: env.to_string(),
406 component: "app".to_string(),
407 key: key.to_string(),
408 description: None,
409 created: "2026-05-30T00:00:00Z".to_string(),
410 updated: "2026-05-30T00:00:00Z".to_string(),
411 }
412 }
413
414 fn payload(entries: Vec<SecretRecord>) -> PackagePayload {
415 PackagePayload::new("dev", "2026-05-30T00:00:00Z", now() + HOUR, entries)
416 }
417
418 #[test]
421 fn seal_open_round_trips_and_wrong_identity_fails() {
422 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
423 let clock = MockClock::default();
424 let (package, _token) = seal(
425 payload(vec![literal("dev", "token", "s3cr3t-dev-value")]),
426 &recipient.public_openssh,
427 )
428 .unwrap();
429
430 let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
431 match &opened.entries[0] {
432 SecretRecord::Literal { value, .. } => assert_eq!(value.expose(), b"s3cr3t-dev-value"),
433 other => panic!("expected literal, got {other:?}"),
434 }
435
436 let other = generate(KeyAlgorithm::Ed25519).unwrap();
438 assert!(open_attended(&package, &other.private_openssh, &clock).is_err());
439 }
440
441 #[test]
443 fn all_modalities_round_trip() {
444 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
445 let clock = MockClock::default();
446 let shared = generate(KeyAlgorithm::Ed25519).unwrap();
447 let entries = vec![
448 literal("dev", "db", "db-pass"),
449 reference("dev", "api", "azure-kv://corp-kv/api-key"),
450 SecretRecord::Keypair {
451 algorithm: KeyAlgorithm::Ed25519,
452 private: Some(SecretValue::from(shared.private_openssh.as_str())),
453 public: shared.public_openssh.clone(),
454 sensitivity: Sensitivity::High,
455 revealable: false,
456 environment: "dev".to_string(),
457 component: "ssh".to_string(),
458 key: "deploy".to_string(),
459 description: None,
460 created: "2026-05-30T00:00:00Z".to_string(),
461 updated: "2026-05-30T00:00:00Z".to_string(),
462 },
463 SecretRecord::Totp {
464 seed: SecretValue::from("totp-seed-bytes"),
465 algorithm: crate::totp::TotpAlgorithm::Sha1,
466 digits: 6,
467 period: 30,
468 sensitivity: Sensitivity::High,
469 revealable: false,
470 environment: "dev".to_string(),
471 component: "auth".to_string(),
472 key: "mfa".to_string(),
473 description: None,
474 created: "2026-05-30T00:00:00Z".to_string(),
475 updated: "2026-05-30T00:00:00Z".to_string(),
476 },
477 ];
478 let (package, _token) = seal(payload(entries), &recipient.public_openssh).unwrap();
479 let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
480 assert_eq!(opened.entries.len(), 4);
481 match &opened.entries[2] {
483 SecretRecord::Keypair { private, .. } => {
484 assert_eq!(
485 private.as_ref().unwrap().expose(),
486 shared.private_openssh.as_bytes()
487 );
488 }
489 other => panic!("expected keypair, got {other:?}"),
490 }
491 match &opened.entries[3] {
493 SecretRecord::Totp { seed, .. } => assert_eq!(seed.expose(), b"totp-seed-bytes"),
494 other => panic!("expected totp, got {other:?}"),
495 }
496 }
497
498 #[test]
501 fn i4a_packaging_a_prod_secret_is_refused() {
502 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
503 let entries = vec![
504 literal("dev", "ok", "fine"),
505 literal("prod", "db", "prod-only-value"),
506 ];
507 let err = seal(payload(entries), &recipient.public_openssh).unwrap_err();
508 match err {
509 CoreError::Package(msg) => {
510 assert!(msg.contains("prod/app/db"), "names the coordinate: {msg}");
511 assert!(msg.contains("I4a"));
512 assert!(
513 !msg.contains("prod-only-value"),
514 "error must not carry the value"
515 );
516 }
517 other => panic!("expected Package error, got {other:?}"),
518 }
519 }
520
521 #[test]
525 fn i4b_prod_entry_refused_under_a_valid_token() {
526 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
527 let clock = MockClock::default();
528 let (package, token) =
531 seal_forged_with_prod(&recipient.public_openssh, now() + HOUR).unwrap();
532
533 let payload = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
535 assert!(verify_token(&package, &payload, &token, &clock).is_ok());
536
537 let err =
539 open_unattended(&package, &token, &recipient.private_openssh, &clock).unwrap_err();
540 match err {
541 CoreError::Package(msg) => {
542 assert!(msg.contains("I4b"), "I4b denial: {msg}");
543 assert!(msg.contains("prod/app/secret"));
544 }
545 other => panic!("expected Package error, got {other:?}"),
546 }
547 }
548
549 fn seal_forged_with_prod(
552 recipient_public_openssh: &str,
553 expires_at: u64,
554 ) -> Result<(Package, AccessToken), CoreError> {
555 let mut p = PackagePayload::new(
556 "prod",
557 "2026-05-30T00:00:00Z",
558 expires_at,
559 vec![literal("prod", "secret", "forged-prod-value")],
560 );
561 let mut secret = vec![0u8; TOKEN_SECRET_LEN];
562 rand::rngs::OsRng.fill_bytes(&mut secret);
563 p.token_commitment = blake3::hash(&secret).to_hex().to_string();
564 let plaintext = serde_json::to_vec(&p).unwrap();
565 let sealed = keypair::encrypt_to(recipient_public_openssh, &plaintext)?;
566 let package = Package::new(PACKAGE_SCHEMA_VERSION, expires_at, sealed);
567 let token = AccessToken {
568 version: PACKAGE_SCHEMA_VERSION,
569 package_fingerprint: package.fingerprint(),
570 expires_at,
571 secret: SecretValue::new(secret),
572 };
573 Ok((package, token))
574 }
575
576 #[test]
580 fn i8_reference_travels_as_pointer_only() {
581 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
582 let clock = MockClock::default();
583 let (package, _token) = seal(
584 payload(vec![reference("dev", "api", "azure-kv://corp-kv/api-key")]),
585 &recipient.public_openssh,
586 )
587 .unwrap();
588 let opened = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
589 match &opened.entries[0] {
590 SecretRecord::Reference { reference, .. } => {
591 assert_eq!(reference, "azure-kv://corp-kv/api-key");
592 }
593 other => panic!("expected reference, got {other:?}"),
594 }
595 assert_eq!(
597 opened.entries[0].reference(),
598 Some("azure-kv://corp-kv/api-key")
599 );
600 }
601
602 #[test]
605 fn token_ttl_and_fingerprint_binding() {
606 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
607 let (package, token) = seal(
608 payload(vec![literal("dev", "k", "v")]),
609 &recipient.public_openssh,
610 )
611 .unwrap();
612
613 let early = MockClock::default();
615 let payload = open_attended(&package, &recipient.private_openssh, &early).unwrap();
616 assert!(verify_token(&package, &payload, &token, &early).is_ok());
617
618 let late = MockClock::at(now() + 2 * HOUR);
620 assert!(open_attended(&package, &recipient.private_openssh, &late).is_err());
621 assert!(verify_token(&package, &payload, &token, &late).is_err());
622
623 let (_other_pkg, other_token) =
625 seal(payload_for_other(), &recipient.public_openssh).unwrap();
626 assert!(verify_token(&package, &payload, &other_token, &early).is_err());
627 }
628
629 fn payload_for_other() -> PackagePayload {
630 PackagePayload::new(
631 "dev",
632 "2026-05-30T00:00:00Z",
633 now() + HOUR,
634 vec![literal("dev", "other", "other")],
635 )
636 }
637
638 #[test]
641 fn token_confirmer_is_two_factor() {
642 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
643 let clock = MockClock::default();
644 let (package, token) = seal(
645 payload(vec![literal("dev", "k", "v")]),
646 &recipient.public_openssh,
647 )
648 .unwrap();
649 let payload = open_attended(&package, &recipient.private_openssh, &clock).unwrap();
650
651 let good = TokenConfirmer::new(&package, &payload, &token, &clock);
652 assert!(
653 good.confirm(
654 &ConfirmRequest::new(
655 "dev/app/k",
656 Sensitivity::High,
657 "dev",
658 crate::scope::Origin::Human
659 ),
660 std::time::Duration::ZERO
661 )
662 .is_approved()
663 );
664
665 let forged = AccessToken {
667 version: PACKAGE_SCHEMA_VERSION,
668 package_fingerprint: package.fingerprint(),
669 expires_at: token.expires_at,
670 secret: SecretValue::from("not-the-real-secret"),
671 };
672 let bad = TokenConfirmer::new(&package, &payload, &forged, &clock);
673 assert_eq!(
674 bad.confirm(
675 &ConfirmRequest::new(
676 "dev/app/k",
677 Sensitivity::High,
678 "dev",
679 crate::scope::Origin::Human
680 ),
681 std::time::Duration::ZERO
682 ),
683 ConfirmOutcome::Denied
684 );
685 }
686
687 #[test]
690 fn tampered_package_fails_to_open() {
691 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
692 let clock = MockClock::default();
693 let (package, _token) = seal(
694 payload(vec![literal("dev", "k", "v")]),
695 &recipient.public_openssh,
696 )
697 .unwrap();
698 let mut bytes = package.to_bytes();
699 let last = bytes.len() - 1;
700 bytes[last] ^= 0xff;
701 let tampered = Package::from_bytes(&bytes).unwrap();
702 assert!(open_attended(&tampered, &recipient.private_openssh, &clock).is_err());
703 }
704
705 #[test]
708 fn debug_is_redacted_and_frame_round_trips() {
709 let recipient = generate(KeyAlgorithm::Ed25519).unwrap();
710 let (package, token) = seal(
711 payload(vec![literal("dev", "k", "top-secret-literal")]),
712 &recipient.public_openssh,
713 )
714 .unwrap();
715
716 let opened = {
717 let clock = MockClock::default();
718 open_attended(&package, &recipient.private_openssh, &clock).unwrap()
719 };
720 let dbg = format!("{opened:?}");
721 assert!(dbg.contains("REDACTED"));
722 assert!(!dbg.contains("top-secret-literal"));
723
724 let token_dbg = format!("{token:?}");
725 assert!(token_dbg.contains("REDACTED"));
726
727 let back = Package::from_bytes(&package.to_bytes()).unwrap();
729 assert_eq!(back, package);
730
731 let token2 = AccessToken::from_bytes(&token.to_bytes().unwrap()).unwrap();
733 assert_eq!(token2.secret.expose(), token.secret.expose());
734 assert_eq!(token2.package_fingerprint, token.package_fingerprint);
735 }
736
737 #[test]
740 fn foreign_frame_is_rejected() {
741 assert!(Package::from_bytes(b"not a package").is_err());
742 }
743}