1use serde::{Deserialize, Serialize};
21use zeroize::Zeroizing;
22
23#[derive(Clone, Serialize, Deserialize)]
27pub struct ServiceEntry {
28 #[serde(default)]
29 pub entry_id: Option<i64>,
30 pub title: String,
31 pub username: String,
32 #[serde(default)]
33 pub password: Zeroizing<String>,
34 #[serde(default)]
35 pub url: Option<String>,
36 #[serde(default)]
37 pub notes: Option<String>,
38 #[serde(default = "default_credential_type")]
40 pub credential_type: String,
41 #[serde(default)]
43 pub created_at: i64,
44 #[serde(default)]
46 pub modified_at: i64,
47 #[serde(default)]
48 pub favorite: bool,
49}
50
51impl std::fmt::Debug for ServiceEntry {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("ServiceEntry")
54 .field("entry_id", &self.entry_id)
55 .field("title", &self.title)
56 .field("username", &self.username)
57 .field("password", &"[REDACTED]")
58 .field("url", &self.url)
59 .field("notes", &self.notes)
60 .field("credential_type", &self.credential_type)
61 .field("created_at", &self.created_at)
62 .field("modified_at", &self.modified_at)
63 .field("favorite", &self.favorite)
64 .finish()
65 }
66}
67
68fn default_credential_type() -> String {
69 "password".to_string()
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ServiceEntrySummary {
75 pub entry_id: i64,
76 pub title: String,
77 pub username: String,
78 pub credential_type: String,
79 pub favorite: bool,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ServiceTotpMetadata {
85 pub algorithm: String,
86 pub digits: u8,
87 pub period: u32,
88 pub issuer: Option<String>,
89 pub account_name: Option<String>,
90}
91
92#[derive(Clone, Serialize, Deserialize)]
95pub struct ServiceSshKey {
96 pub key_id: i64,
97 pub name: String,
98 pub comment: Option<String>,
99 pub key_type: String,
100 pub public_key: String,
101 #[serde(default)]
102 pub private_key: Option<Zeroizing<String>>,
103 pub fingerprint: String,
104 pub created_at: i64,
105}
106
107impl std::fmt::Debug for ServiceSshKey {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("ServiceSshKey")
110 .field("key_id", &self.key_id)
111 .field("name", &self.name)
112 .field("comment", &self.comment)
113 .field("key_type", &self.key_type)
114 .field("public_key", &self.public_key)
115 .field(
116 "private_key",
117 &self.private_key.as_ref().map(|_| "[REDACTED]"),
118 )
119 .field("fingerprint", &self.fingerprint)
120 .field("created_at", &self.created_at)
121 .finish()
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ServiceSshKeySummary {
128 pub key_id: i64,
129 pub name: String,
130 pub comment: Option<String>,
131 pub key_type: String,
132 pub fingerprint: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ServiceEntity {
138 pub entity_id: String,
139 pub name: String,
140 pub kind: String,
142 pub criticality: String,
144 pub notes: Option<String>,
145 pub rotation_interval_days_override: Option<i64>,
146 pub created_at: i64,
147 pub modified_at: i64,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ServiceSyncDeviceInfo {
153 pub device_id: String,
154 pub device_name: String,
155 pub device_type: String,
156 pub revoked: bool,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ServiceVaultStatus {
162 pub unlocked: bool,
163 pub key_epoch: i64,
165 #[serde(default)]
168 pub maintenance: bool,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ServiceSyncStatus {
174 pub enabled: bool,
175 pub device_id: Option<String>,
176 pub device_name: Option<String>,
177 pub relay_url: Option<String>,
178 pub last_sync_at: Option<i64>,
179 pub pending_changes: u64,
180 #[serde(default)]
183 pub conflicts: u64,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ServiceBiometricStatus {
189 pub method_name: String,
190 pub available: bool,
191 pub enrolled: bool,
192 pub configured: bool,
195}
196
197#[derive(Clone, Serialize, Deserialize)]
214pub enum VaultOp {
215 VaultCreate {
220 master_password: Zeroizing<String>,
221 },
222 VaultStatus,
223
224 EntryAdd {
226 entry: ServiceEntry,
227 },
228 EntryGet {
229 entry_id: i64,
230 },
231 EntryList,
232 EntryUpdate {
233 entry_id: i64,
234 entry: ServiceEntry,
235 },
236 EntryDelete {
237 entry_id: i64,
238 },
239
240 TotpAdd {
245 entry_id: i64,
246 secret: Zeroizing<String>,
247 algorithm: Option<String>,
248 digits: Option<u8>,
249 period: Option<u32>,
250 issuer: Option<String>,
251 account_name: Option<String>,
252 },
253 TotpCode {
254 entry_id: i64,
255 },
256 TotpMetadata {
257 entry_id: i64,
258 },
259 TotpRemove {
260 entry_id: i64,
261 },
262
263 SshKeyAdd {
265 name: String,
266 comment: Option<String>,
267 key_type: String,
268 public_key: String,
269 private_key: Zeroizing<String>,
270 fingerprint: String,
271 },
272 SshKeyList,
273 SshKeyGet {
274 key_id: i64,
275 include_private: bool,
276 },
277 SshKeyDelete {
278 key_id: i64,
279 },
280
281 RegistryOverview {
286 include_strength: bool,
287 },
288 RegistrySweep,
289 EntityList,
290 EntityAdd {
291 name: String,
292 kind: String,
293 criticality: String,
294 notes: Option<String>,
295 rotation_interval_days: Option<i64>,
296 },
297 EntityDelete {
298 name: String,
299 },
300 EntryAssign {
301 entry_id: i64,
302 entity: String,
303 label: Option<String>,
304 },
305 EntryUnassign {
306 entry_id: i64,
307 },
308 EntryMarkRotated {
309 entry_id: i64,
310 },
311 EntrySetExpiresAt {
312 entry_id: i64,
313 expires_at: Option<i64>,
314 },
315
316 HealthReport,
322 AuditVerify,
324
325 BiometricStatusGet,
327 BiometricEnable {
328 master_password: Zeroizing<String>,
329 },
330 BiometricDisable,
331
332 ExportAll,
337 ImportEntries {
339 entries: Vec<ServiceEntry>,
340 },
341
342 SyncInit {
344 relay_url: String,
345 device_name: Option<String>,
346 },
347 SyncDisable,
348 SyncDeviceList,
349 SyncDeviceRevoke {
350 device_id: String,
351 },
352 SyncStatus,
354 SyncNow,
357 SyncDeadLetterList,
359 SyncMigrateClaim,
364 SyncMigrateAuthoritative {
368 new_relay_vault: String,
369 },
370 SyncConflictList,
374 SyncConflictResolve {
379 object_id: String,
380 take_remote: bool,
381 },
382 SyncDeadLetterPurge {
387 server_sequence: Option<i64>,
388 },
389
390 SyncPairStart,
395 SyncPairJoin {
400 relay_url: String,
401 code: String,
402 salt: String,
403 },
404}
405
406impl std::fmt::Debug for VaultOp {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 f.write_str("VaultOp::")
412 }
413}
414
415#[derive(Clone, Serialize, Deserialize)]
421pub enum VaultOpResult {
422 Ok,
423 EntryId(i64),
424 Entry(Box<ServiceEntry>),
425 EntryList(Vec<ServiceEntrySummary>),
426 Entries(Vec<ServiceEntry>),
427 Imported(Vec<i64>),
429 TotpCode {
430 code: String,
431 seconds_remaining: u32,
432 },
433 TotpMetadata(Option<ServiceTotpMetadata>),
434 SshKey(Box<ServiceSshKey>),
435 SshKeyList(Vec<ServiceSshKeySummary>),
436 Entity(Box<ServiceEntity>),
437 EntityList(Vec<ServiceEntity>),
438 Report(serde_json::Value),
442 Status(ServiceVaultStatus),
443 Biometric(ServiceBiometricStatus),
444 SyncDevices(Vec<ServiceSyncDeviceInfo>),
445 SyncStatus(ServiceSyncStatus),
446}
447
448impl std::fmt::Debug for VaultOpResult {
449 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 f.write_str(self.kind())
451 }
452}
453
454impl VaultOpResult {
455 fn kind(&self) -> &'static str {
456 match self {
457 Self::Ok => "VaultOpResult::Ok",
458 Self::EntryId(_) => "VaultOpResult::EntryId",
459 Self::Entry(_) => "VaultOpResult::Entry",
460 Self::EntryList(_) => "VaultOpResult::EntryList",
461 Self::Entries(_) => "VaultOpResult::Entries",
462 Self::Imported(_) => "VaultOpResult::Imported",
463 Self::TotpCode { .. } => "VaultOpResult::TotpCode",
464 Self::TotpMetadata(_) => "VaultOpResult::TotpMetadata",
465 Self::SshKey(_) => "VaultOpResult::SshKey",
466 Self::SshKeyList(_) => "VaultOpResult::SshKeyList",
467 Self::Entity(_) => "VaultOpResult::Entity",
468 Self::EntityList(_) => "VaultOpResult::EntityList",
469 Self::Report(_) => "VaultOpResult::Report",
470 Self::Status(_) => "VaultOpResult::Status",
471 Self::Biometric(_) => "VaultOpResult::Biometric",
472 Self::SyncDevices(_) => "VaultOpResult::SyncDevices",
473 Self::SyncStatus(_) => "VaultOpResult::SyncStatus",
474 }
475 }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ServiceError {
482 pub code: String,
483 pub message: String,
484}
485
486impl ServiceError {
487 pub fn new(code: &str, message: impl Into<String>) -> Self {
488 Self {
489 code: code.to_string(),
490 message: message.into(),
491 }
492 }
493}
494
495impl std::fmt::Display for ServiceError {
496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497 write!(f, "{}: {}", self.code, self.message)
498 }
499}
500
501impl std::error::Error for ServiceError {}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(tag = "status", rename_all = "snake_case")]
506pub enum ServiceOutcome {
507 Ok { result: VaultOpResult },
508 Err { error: ServiceError },
509}
510
511impl From<VaultOpResult> for ServiceOutcome {
512 fn from(result: VaultOpResult) -> Self {
513 Self::Ok { result }
514 }
515}
516
517impl From<ServiceError> for ServiceOutcome {
518 fn from(error: ServiceError) -> Self {
519 Self::Err { error }
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn service_entry_round_trips_with_defaults() {
529 let entry = ServiceEntry {
530 entry_id: Some(7),
531 title: "Example".to_string(),
532 username: "user@example.com".to_string(),
533 password: Zeroizing::new("secret".to_string()),
534 url: Some("https://example.com".to_string()),
535 notes: None,
536 credential_type: "api_key".to_string(),
537 created_at: 1_700_000_000,
538 modified_at: 1_700_000_001,
539 favorite: true,
540 };
541 let json = serde_json::to_string(&entry).unwrap();
542 let back: ServiceEntry = serde_json::from_str(&json).unwrap();
543 assert_eq!(back.entry_id, Some(7));
544 assert_eq!(back.password.as_str(), "secret");
545 assert_eq!(back.credential_type, "api_key");
546 }
547
548 #[test]
551 fn legacy_service_entry_parses_with_defaults() {
552 let legacy = r#"{"title":"T","username":"u","password":"p"}"#;
553 let entry: ServiceEntry = serde_json::from_str(legacy).unwrap();
554 assert_eq!(entry.credential_type, "password");
555 assert_eq!(entry.entry_id, None);
556 assert!(!entry.favorite);
557 }
558
559 #[test]
560 fn vault_op_and_result_round_trip() {
561 let op = VaultOp::TotpAdd {
562 entry_id: 3,
563 secret: Zeroizing::new("JBSWY3DPEHPK3PXP".to_string()),
564 algorithm: Some("sha256".to_string()),
565 digits: Some(8),
566 period: Some(60),
567 issuer: Some("Example".to_string()),
568 account_name: None,
569 };
570 let json = serde_json::to_string(&op).unwrap();
571 let back: VaultOp = serde_json::from_str(&json).unwrap();
572 match back {
573 VaultOp::TotpAdd {
574 entry_id, digits, ..
575 } => {
576 assert_eq!(entry_id, 3);
577 assert_eq!(digits, Some(8));
578 }
579 other => panic!("unexpected op: {other:?}"),
580 }
581
582 let result = VaultOpResult::Report(serde_json::json!({ "ok": true }));
583 let json = serde_json::to_string(&result).unwrap();
584 let back: VaultOpResult = serde_json::from_str(&json).unwrap();
585 match back {
586 VaultOpResult::Report(v) => assert_eq!(v["ok"], serde_json::json!(true)),
587 other => panic!("unexpected result: {other:?}"),
588 }
589 }
590
591 #[test]
592 fn service_outcome_is_tagged_and_both_branches_round_trip() {
593 let ok = ServiceOutcome::from(VaultOpResult::EntryId(11));
594 let json = serde_json::to_string(&ok).unwrap();
595 assert!(json.contains("\"status\":\"ok\""), "tagged: {json}");
596 let back: ServiceOutcome = serde_json::from_str(&json).unwrap();
597 match back {
598 ServiceOutcome::Ok {
599 result: VaultOpResult::EntryId(id),
600 } => assert_eq!(id, 11),
601 other => panic!("unexpected outcome: {other:?}"),
602 }
603
604 let err = ServiceOutcome::from(ServiceError::new("vault_locked", "vault is locked"));
605 let json = serde_json::to_string(&err).unwrap();
606 assert!(json.contains("\"status\":\"err\""), "tagged: {json}");
607 let back: ServiceOutcome = serde_json::from_str(&json).unwrap();
608 match back {
609 ServiceOutcome::Err { error } => {
610 assert_eq!(error.code, "vault_locked");
611 assert_eq!(error.message, "vault is locked");
612 }
613 other => panic!("unexpected outcome: {other:?}"),
614 }
615 }
616
617 #[test]
620 fn debug_of_secret_bearing_types_redacts() {
621 let entry = ServiceEntry {
622 entry_id: None,
623 title: "T".to_string(),
624 username: "u".to_string(),
625 password: Zeroizing::new("plain-secret-value".to_string()),
626 url: None,
627 notes: None,
628 credential_type: "password".to_string(),
629 created_at: 0,
630 modified_at: 0,
631 favorite: false,
632 };
633 let rendered = format!("{:?}", entry);
634 assert!(!rendered.contains("plain-secret-value"), "{rendered}");
635 assert!(rendered.contains("[REDACTED]"), "{rendered}");
636
637 let op = VaultOp::VaultCreate {
638 master_password: Zeroizing::new("master-secret-value".to_string()),
639 };
640 let rendered = format!("{op:?}");
641 assert!(!rendered.contains("master-secret-value"), "{rendered}");
642
643 let key = ServiceSshKey {
644 key_id: 1,
645 name: "k".to_string(),
646 comment: None,
647 key_type: "ed25519".to_string(),
648 public_key: "ssh-ed25519 AAA".to_string(),
649 private_key: Some(Zeroizing::new("private-material".to_string())),
650 fingerprint: "SHA256:xyz".to_string(),
651 created_at: 0,
652 };
653 let rendered = format!("{key:?}");
654 assert!(!rendered.contains("private-material"), "{rendered}");
655
656 let result = VaultOpResult::TotpCode {
658 code: "123456".to_string(),
659 seconds_remaining: 30,
660 };
661 let rendered = format!("{result:?}");
662 assert!(!rendered.contains("123456"), "{rendered}");
663
664 let result = VaultOpResult::Entry(Box::new(entry));
665 let rendered = format!("{result:?}");
666 assert!(!rendered.contains("plain-secret-value"), "{rendered}");
667 }
668}