use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Clone, Serialize, Deserialize)]
pub struct ServiceEntry {
#[serde(default)]
pub entry_id: Option<i64>,
pub title: String,
pub username: String,
#[serde(default)]
pub password: Zeroizing<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default = "default_credential_type")]
pub credential_type: String,
#[serde(default)]
pub created_at: i64,
#[serde(default)]
pub modified_at: i64,
#[serde(default)]
pub favorite: bool,
}
impl std::fmt::Debug for ServiceEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServiceEntry")
.field("entry_id", &self.entry_id)
.field("title", &self.title)
.field("username", &self.username)
.field("password", &"[REDACTED]")
.field("url", &self.url)
.field("notes", &self.notes)
.field("credential_type", &self.credential_type)
.field("created_at", &self.created_at)
.field("modified_at", &self.modified_at)
.field("favorite", &self.favorite)
.finish()
}
}
fn default_credential_type() -> String {
"password".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceEntrySummary {
pub entry_id: i64,
pub title: String,
pub username: String,
pub credential_type: String,
pub favorite: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceTotpMetadata {
pub algorithm: String,
pub digits: u8,
pub period: u32,
pub issuer: Option<String>,
pub account_name: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ServiceSshKey {
pub key_id: i64,
pub name: String,
pub comment: Option<String>,
pub key_type: String,
pub public_key: String,
#[serde(default)]
pub private_key: Option<Zeroizing<String>>,
pub fingerprint: String,
pub created_at: i64,
}
impl std::fmt::Debug for ServiceSshKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServiceSshKey")
.field("key_id", &self.key_id)
.field("name", &self.name)
.field("comment", &self.comment)
.field("key_type", &self.key_type)
.field("public_key", &self.public_key)
.field(
"private_key",
&self.private_key.as_ref().map(|_| "[REDACTED]"),
)
.field("fingerprint", &self.fingerprint)
.field("created_at", &self.created_at)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceSshKeySummary {
pub key_id: i64,
pub name: String,
pub comment: Option<String>,
pub key_type: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceEntity {
pub entity_id: String,
pub name: String,
pub kind: String,
pub criticality: String,
pub notes: Option<String>,
pub rotation_interval_days_override: Option<i64>,
pub created_at: i64,
pub modified_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceSyncDeviceInfo {
pub device_id: String,
pub device_name: String,
pub device_type: String,
pub revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceVaultStatus {
pub unlocked: bool,
pub key_epoch: i64,
#[serde(default)]
pub maintenance: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceSyncStatus {
pub enabled: bool,
pub device_id: Option<String>,
pub device_name: Option<String>,
pub relay_url: Option<String>,
pub last_sync_at: Option<i64>,
pub pending_changes: u64,
#[serde(default)]
pub conflicts: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceBiometricStatus {
pub method_name: String,
pub available: bool,
pub enrolled: bool,
pub configured: bool,
}
#[derive(Clone, Serialize, Deserialize)]
pub enum VaultOp {
VaultCreate {
master_password: Zeroizing<String>,
},
VaultStatus,
EntryAdd {
entry: ServiceEntry,
},
EntryGet {
entry_id: i64,
},
EntryList,
EntryUpdate {
entry_id: i64,
entry: ServiceEntry,
},
EntryDelete {
entry_id: i64,
},
TotpAdd {
entry_id: i64,
secret: Zeroizing<String>,
algorithm: Option<String>,
digits: Option<u8>,
period: Option<u32>,
issuer: Option<String>,
account_name: Option<String>,
},
TotpCode {
entry_id: i64,
},
TotpMetadata {
entry_id: i64,
},
TotpRemove {
entry_id: i64,
},
SshKeyAdd {
name: String,
comment: Option<String>,
key_type: String,
public_key: String,
private_key: Zeroizing<String>,
fingerprint: String,
},
SshKeyList,
SshKeyGet {
key_id: i64,
include_private: bool,
},
SshKeyDelete {
key_id: i64,
},
RegistryOverview {
include_strength: bool,
},
RegistrySweep,
EntityList,
EntityAdd {
name: String,
kind: String,
criticality: String,
notes: Option<String>,
rotation_interval_days: Option<i64>,
},
EntityDelete {
name: String,
},
EntryAssign {
entry_id: i64,
entity: String,
label: Option<String>,
},
EntryUnassign {
entry_id: i64,
},
EntryMarkRotated {
entry_id: i64,
},
EntrySetExpiresAt {
entry_id: i64,
expires_at: Option<i64>,
},
HealthReport,
AuditVerify,
BiometricStatusGet,
BiometricEnable {
master_password: Zeroizing<String>,
},
BiometricDisable,
ExportAll,
ImportEntries {
entries: Vec<ServiceEntry>,
},
SyncInit {
relay_url: String,
device_name: Option<String>,
},
SyncDisable,
SyncDeviceList,
SyncDeviceRevoke {
device_id: String,
},
SyncStatus,
SyncNow,
SyncDeadLetterList,
SyncMigrateClaim,
SyncMigrateAuthoritative {
new_relay_vault: String,
},
SyncConflictList,
SyncConflictResolve {
object_id: String,
take_remote: bool,
},
SyncDeadLetterPurge {
server_sequence: Option<i64>,
},
SyncPairStart,
SyncPairJoin {
relay_url: String,
code: String,
salt: String,
},
}
impl std::fmt::Debug for VaultOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("VaultOp::")
}
}
#[derive(Clone, Serialize, Deserialize)]
pub enum VaultOpResult {
Ok,
EntryId(i64),
Entry(Box<ServiceEntry>),
EntryList(Vec<ServiceEntrySummary>),
Entries(Vec<ServiceEntry>),
Imported(Vec<i64>),
TotpCode {
code: String,
seconds_remaining: u32,
},
TotpMetadata(Option<ServiceTotpMetadata>),
SshKey(Box<ServiceSshKey>),
SshKeyList(Vec<ServiceSshKeySummary>),
Entity(Box<ServiceEntity>),
EntityList(Vec<ServiceEntity>),
Report(serde_json::Value),
Status(ServiceVaultStatus),
Biometric(ServiceBiometricStatus),
SyncDevices(Vec<ServiceSyncDeviceInfo>),
SyncStatus(ServiceSyncStatus),
}
impl std::fmt::Debug for VaultOpResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.kind())
}
}
impl VaultOpResult {
fn kind(&self) -> &'static str {
match self {
Self::Ok => "VaultOpResult::Ok",
Self::EntryId(_) => "VaultOpResult::EntryId",
Self::Entry(_) => "VaultOpResult::Entry",
Self::EntryList(_) => "VaultOpResult::EntryList",
Self::Entries(_) => "VaultOpResult::Entries",
Self::Imported(_) => "VaultOpResult::Imported",
Self::TotpCode { .. } => "VaultOpResult::TotpCode",
Self::TotpMetadata(_) => "VaultOpResult::TotpMetadata",
Self::SshKey(_) => "VaultOpResult::SshKey",
Self::SshKeyList(_) => "VaultOpResult::SshKeyList",
Self::Entity(_) => "VaultOpResult::Entity",
Self::EntityList(_) => "VaultOpResult::EntityList",
Self::Report(_) => "VaultOpResult::Report",
Self::Status(_) => "VaultOpResult::Status",
Self::Biometric(_) => "VaultOpResult::Biometric",
Self::SyncDevices(_) => "VaultOpResult::SyncDevices",
Self::SyncStatus(_) => "VaultOpResult::SyncStatus",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceError {
pub code: String,
pub message: String,
}
impl ServiceError {
pub fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
}
}
}
impl std::fmt::Display for ServiceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ServiceError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ServiceOutcome {
Ok { result: VaultOpResult },
Err { error: ServiceError },
}
impl From<VaultOpResult> for ServiceOutcome {
fn from(result: VaultOpResult) -> Self {
Self::Ok { result }
}
}
impl From<ServiceError> for ServiceOutcome {
fn from(error: ServiceError) -> Self {
Self::Err { error }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_entry_round_trips_with_defaults() {
let entry = ServiceEntry {
entry_id: Some(7),
title: "Example".to_string(),
username: "user@example.com".to_string(),
password: Zeroizing::new("secret".to_string()),
url: Some("https://example.com".to_string()),
notes: None,
credential_type: "api_key".to_string(),
created_at: 1_700_000_000,
modified_at: 1_700_000_001,
favorite: true,
};
let json = serde_json::to_string(&entry).unwrap();
let back: ServiceEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.entry_id, Some(7));
assert_eq!(back.password.as_str(), "secret");
assert_eq!(back.credential_type, "api_key");
}
#[test]
fn legacy_service_entry_parses_with_defaults() {
let legacy = r#"{"title":"T","username":"u","password":"p"}"#;
let entry: ServiceEntry = serde_json::from_str(legacy).unwrap();
assert_eq!(entry.credential_type, "password");
assert_eq!(entry.entry_id, None);
assert!(!entry.favorite);
}
#[test]
fn vault_op_and_result_round_trip() {
let op = VaultOp::TotpAdd {
entry_id: 3,
secret: Zeroizing::new("JBSWY3DPEHPK3PXP".to_string()),
algorithm: Some("sha256".to_string()),
digits: Some(8),
period: Some(60),
issuer: Some("Example".to_string()),
account_name: None,
};
let json = serde_json::to_string(&op).unwrap();
let back: VaultOp = serde_json::from_str(&json).unwrap();
match back {
VaultOp::TotpAdd {
entry_id, digits, ..
} => {
assert_eq!(entry_id, 3);
assert_eq!(digits, Some(8));
}
other => panic!("unexpected op: {other:?}"),
}
let result = VaultOpResult::Report(serde_json::json!({ "ok": true }));
let json = serde_json::to_string(&result).unwrap();
let back: VaultOpResult = serde_json::from_str(&json).unwrap();
match back {
VaultOpResult::Report(v) => assert_eq!(v["ok"], serde_json::json!(true)),
other => panic!("unexpected result: {other:?}"),
}
}
#[test]
fn service_outcome_is_tagged_and_both_branches_round_trip() {
let ok = ServiceOutcome::from(VaultOpResult::EntryId(11));
let json = serde_json::to_string(&ok).unwrap();
assert!(json.contains("\"status\":\"ok\""), "tagged: {json}");
let back: ServiceOutcome = serde_json::from_str(&json).unwrap();
match back {
ServiceOutcome::Ok {
result: VaultOpResult::EntryId(id),
} => assert_eq!(id, 11),
other => panic!("unexpected outcome: {other:?}"),
}
let err = ServiceOutcome::from(ServiceError::new("vault_locked", "vault is locked"));
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"status\":\"err\""), "tagged: {json}");
let back: ServiceOutcome = serde_json::from_str(&json).unwrap();
match back {
ServiceOutcome::Err { error } => {
assert_eq!(error.code, "vault_locked");
assert_eq!(error.message, "vault is locked");
}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn debug_of_secret_bearing_types_redacts() {
let entry = ServiceEntry {
entry_id: None,
title: "T".to_string(),
username: "u".to_string(),
password: Zeroizing::new("plain-secret-value".to_string()),
url: None,
notes: None,
credential_type: "password".to_string(),
created_at: 0,
modified_at: 0,
favorite: false,
};
let rendered = format!("{:?}", entry);
assert!(!rendered.contains("plain-secret-value"), "{rendered}");
assert!(rendered.contains("[REDACTED]"), "{rendered}");
let op = VaultOp::VaultCreate {
master_password: Zeroizing::new("master-secret-value".to_string()),
};
let rendered = format!("{op:?}");
assert!(!rendered.contains("master-secret-value"), "{rendered}");
let key = ServiceSshKey {
key_id: 1,
name: "k".to_string(),
comment: None,
key_type: "ed25519".to_string(),
public_key: "ssh-ed25519 AAA".to_string(),
private_key: Some(Zeroizing::new("private-material".to_string())),
fingerprint: "SHA256:xyz".to_string(),
created_at: 0,
};
let rendered = format!("{key:?}");
assert!(!rendered.contains("private-material"), "{rendered}");
let result = VaultOpResult::TotpCode {
code: "123456".to_string(),
seconds_remaining: 30,
};
let rendered = format!("{result:?}");
assert!(!rendered.contains("123456"), "{rendered}");
let result = VaultOpResult::Entry(Box::new(entry));
let rendered = format!("{result:?}");
assert!(!rendered.contains("plain-secret-value"), "{rendered}");
}
}