1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
//! Common error types for the OpenVTC library.
//!
//! All fallible operations in the crate return [`OpenVTCError`] so that callers
//! can match on specific failure categories.
use affinidi_data_integrity::DataIntegrityError;
use affinidi_tdk::{common::errors::TDKError, didcomm, messaging::errors::ATMError};
use didwebvh_rs::DIDWebVHError;
use thiserror::Error;
/// Unified error type for all OpenVTC operations.
#[derive(Error, Debug)]
pub enum OpenVTCError {
/// An unrecognised DIDComm message type URL was encountered.
#[error("Invalid Message Type: {0}")]
InvalidMessage(String),
/// A required secret key could not be found in the secrets resolver.
#[error("Missing Secret Key Material. Key-ID: {0}")]
MissingSecretKeyMaterial(String),
/// JSON serialization or deserialization failed.
#[error("Serialize/Deserialize Error: {0}")]
Serde(#[from] serde_json::Error),
/// A data-integrity proof operation failed.
#[error("DataIntegrityProof Error: {0}")]
DataIntegrityProof(#[from] DataIntegrityError),
/// An error from the Affinidi Trusted Messaging (ATM) layer.
#[error("ATM Error: {0}")]
ATM(#[from] ATMError),
/// A DIDComm protocol-level error.
#[error("DIDComm Error: {0}")]
DIDComm(#[from] didcomm::DIDCommError),
/// A BIP32 key derivation error.
#[error("BIP32 Error: {0}")]
BIP32(String),
/// An error related to secret key material (creation, decoding, etc.).
#[error("Key Secret Error: {0}")]
Secret(String),
/// Base64 decoding failed.
#[error("BASE64 Decode Error: {0}")]
Base64Decode(#[from] base64::DecodeError),
/// DID resolution failed.
#[error("DID Resolver Error: {0}")]
Resolver(String),
/// A general configuration error.
#[error("Config Error: {0}")]
Config(String),
/// A VTA connection, session, or transport failure (e.g. a DIDComm session
/// could not be opened against the mediator). Distinct from [`Self::Config`]:
/// this is a retryable runtime fault — the on-disk config is not corrupt, so
/// the caller should advise checking VTA reachability / retrying rather than
/// resetting the configuration.
#[error("VTA Error: {0}")]
Vta(String),
/// A VTA authentication failure (e.g. the challenge-response handshake was
/// rejected). Distinct from [`Self::Config`]: the on-disk config is not
/// corrupt, so the caller should advise re-authenticating rather than
/// resetting the configuration.
#[error("Auth Error: {0}")]
Auth(String),
/// The configuration file could not be found at the expected path.
#[error("Config Not Found! path({0}): {1}")]
ConfigNotFound(String, std::io::Error),
/// An operation against the OS secure store (keychain / credential manager
/// / Secret Service / kernel keyring) failed.
///
/// Distinct from [`Self::Config`] because the remedies do not overlap: a
/// missing credential needs a restore-or-reset, a locked store needs the
/// user to unlock it, and a corrupt blob needs a reset. Collapsing all
/// three into one `Config` string is what produced the "check your
/// network" advice for a purely local failure (dev-guide R6.4).
#[error("Secure store error ({fault}) for profile '{profile}': {detail}")]
SecureStore {
/// Which class of secure-store failure this is.
fault: SecureStoreFault,
/// The config profile whose credential was being addressed.
profile: String,
/// The underlying store's own message, kept verbatim for the log.
detail: String,
},
/// The on-disk config predates the current [`crate::config::public_config::CONFIG_VERSION`]
/// and cannot be migrated in place (T1 breaking reset, D13/R-RST). The caller
/// must warn the user, delete the old config + keyring entries, and re-run setup.
#[error("Config version {found} is incompatible with required version {expected}")]
ConfigVersionUnsupported {
/// The `config_version` read from disk.
found: u32,
/// The version this build requires.
expected: u32,
},
/// An error from a hardware security token (e.g. OpenPGP card / YubiKey).
#[cfg(feature = "openpgp-card")]
#[error("Token Error: {0}")]
Token(String),
/// The PIN provided to the hardware token was incorrect.
#[cfg(feature = "openpgp-card")]
#[error("Token Bad Pin")]
TokenBadPin,
/// Symmetric encryption failed.
#[error("Encrypt Error: {0}")]
Encrypt(String),
/// Symmetric decryption failed.
#[error("Decrypt Error: {0}")]
Decrypt(String),
/// A contacts/address-book operation failed.
#[error("Contacts Error: {0}")]
Contact(String),
/// An error from the `did:webvh` DID method library.
#[error("WebVH DID error: {0}")]
WebVH(#[from] DIDWebVHError),
/// An error from the TDK (Trust Development Kit) layer.
#[error("TDK error: {0}")]
TDK(#[from] TDKError),
/// A `Mutex` was found in a poisoned state.
#[error("Mutex poisoned: {0}")]
MutexPoisoned(String),
/// Another instance of openvtc is already running for this profile.
#[error("Duplicate instance running for profile '{0}'")]
DuplicateInstance(String),
/// A process lock-file operation (create, read, or remove) failed.
#[error("Lock file error: {0}")]
LockFile(String),
}
/// Which class of OS-secure-store failure occurred.
///
/// The point of the split is triage: each variant maps to a different remedy,
/// and [`crate::diagnostics`] turns each into its own set of checks and fixes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecureStoreFault {
/// The store is reachable but holds no credential for this profile.
///
/// On a profile that previously worked this means the credential was
/// deleted, expired (the Linux kernel keyring is RAM-only), or the config
/// file was copied to a machine/user whose store never held it.
Missing,
/// The store itself could not be opened or read — a locked login keychain,
/// no D-Bus session, no Secret Service daemon, a denied access prompt.
Unavailable,
/// More than one credential matched this service/user pair.
Ambiguous,
/// A credential exists but its contents are not a readable `SecuredConfig`.
Corrupt,
/// The store refused to hold this secret. Currently only the encrypted-file
/// store does this, and only for an unencrypted blob.
Rejected,
}
impl std::fmt::Display for SecureStoreFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
SecureStoreFault::Missing => "no credential stored",
SecureStoreFault::Unavailable => "store unavailable",
SecureStoreFault::Ambiguous => "ambiguous credential",
SecureStoreFault::Corrupt => "stored credential unreadable",
SecureStoreFault::Rejected => "store refused the secret",
};
f.write_str(s)
}
}
impl OpenVTCError {
/// Classify a [`keyring_core::Error`] into a typed [`Self::SecureStore`].
///
/// Kept here rather than at the call sites so every keyring touchpoint
/// classifies identically — the previous `format!` at each site is exactly
/// how `NoEntry` and "keychain is locked" ended up indistinguishable.
#[must_use]
pub fn from_keyring(err: &keyring_core::Error, profile: &str) -> Self {
let fault = match err {
keyring_core::Error::NoEntry => SecureStoreFault::Missing,
keyring_core::Error::NoStorageAccess(_)
| keyring_core::Error::PlatformFailure(_)
| keyring_core::Error::NoDefaultStore
| keyring_core::Error::NotSupportedByStore(_) => SecureStoreFault::Unavailable,
keyring_core::Error::Ambiguous(_) => SecureStoreFault::Ambiguous,
keyring_core::Error::BadEncoding(_)
| keyring_core::Error::BadDataFormat(_, _)
| keyring_core::Error::BadStoreFormat(_) => SecureStoreFault::Corrupt,
// `Error` is `#[non_exhaustive]`: an unrecognised variant is more
// likely a store-level problem than a missing credential, and
// guessing `Missing` would advise a reset that destroys keys.
_ => SecureStoreFault::Unavailable,
};
OpenVTCError::SecureStore {
fault,
profile: profile.to_string(),
detail: err.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display_messages_are_meaningful() {
let cases: Vec<Box<dyn std::fmt::Display>> = vec![
Box::new(OpenVTCError::InvalidMessage("bad msg".into())),
Box::new(OpenVTCError::MissingSecretKeyMaterial("key-1".into())),
Box::new(OpenVTCError::BIP32("derivation failed".into())),
Box::new(OpenVTCError::Secret("missing seed".into())),
Box::new(OpenVTCError::Resolver("timeout".into())),
Box::new(OpenVTCError::Config("not found".into())),
Box::new(OpenVTCError::Vta("session open failed".into())),
Box::new(OpenVTCError::Auth("challenge rejected".into())),
Box::new(OpenVTCError::ConfigNotFound(
"/tmp/missing".into(),
std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
)),
Box::new(OpenVTCError::Encrypt("aes failure".into())),
Box::new(OpenVTCError::Decrypt("bad key".into())),
Box::new(OpenVTCError::Contact("unknown".into())),
Box::new(OpenVTCError::MutexPoisoned("lock failed".into())),
];
for err in &cases {
let msg = format!("{}", err);
assert!(!msg.is_empty(), "Error display message should not be empty");
}
}
#[test]
fn test_error_display_contains_inner_message() {
let err = OpenVTCError::Config("something went wrong".to_string());
let msg = format!("{}", err);
assert!(
msg.contains("something went wrong"),
"Display should include the inner message, got: {}",
msg
);
}
#[test]
fn test_vta_and_auth_variants_render_their_messages() {
let vta = OpenVTCError::Vta("DIDComm session open failed: timeout".to_string());
let vta_msg = format!("{vta}");
assert!(
vta_msg.starts_with("VTA Error:"),
"Vta should render its #[error(...)] prefix, got: {vta_msg}"
);
assert!(vta_msg.contains("DIDComm session open failed: timeout"));
let auth = OpenVTCError::Auth("VTA authentication failed: 401".to_string());
let auth_msg = format!("{auth}");
assert!(
auth_msg.starts_with("Auth Error:"),
"Auth should render its #[error(...)] prefix, got: {auth_msg}"
);
assert!(auth_msg.contains("VTA authentication failed: 401"));
}
/// A retryable VTA/auth failure must NOT be classified as a `Config` error:
/// `Config` triggers reset-style guidance in callers, whereas these are
/// "check VTA / re-auth" runtime faults (R18).
#[test]
fn test_vta_and_auth_are_distinct_from_config() {
assert!(matches!(
OpenVTCError::Vta("x".into()),
OpenVTCError::Vta(_)
));
assert!(!matches!(
OpenVTCError::Vta("x".into()),
OpenVTCError::Config(_)
));
assert!(!matches!(
OpenVTCError::Auth("x".into()),
OpenVTCError::Config(_)
));
}
#[test]
fn test_error_debug_is_nonempty() {
let err = OpenVTCError::BIP32("test".into());
let dbg = format!("{:?}", err);
assert!(!dbg.is_empty(), "Debug output should not be empty");
}
}