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
304
305
306
307
308
309
310
311
312
313
use thiserror::Error;
/// Errors returned by all pqfile operations.
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum PqfileError {
/// Underlying I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// File does not begin with the `PQFL` magic bytes.
#[error("invalid magic bytes: expected PQFL")]
InvalidMagic,
/// Version byte in the header is not recognized.
#[error("unsupported version: {0:#04x}")]
UnsupportedVersion(u8),
/// KEM variant field in the header is not recognized.
#[error("unsupported KEM variant: {0}")]
UnsupportedKem(u16),
/// The decryption key uses a different KEM variant than the file was encrypted with.
#[error("KEM variant mismatch: key is {key}, file uses {file}")]
KemVariantMismatch {
/// KEM variant identifier of the supplied private key.
key: u16,
/// KEM variant identifier recorded in the file header.
file: u16,
},
/// An encryption or key-encapsulation operation failed.
#[error("encryption failure")]
EncryptionFailure,
/// AEAD authentication tag did not verify; ciphertext is corrupt or tampered.
#[error("decryption failure: authentication tag mismatch")]
DecryptionFailure,
/// PEM data could not be parsed.
#[error("invalid PEM: {0}")]
InvalidPem(String),
/// Key material has an unexpected byte length.
#[error("invalid key length: expected {expected}, got {got}")]
InvalidKeyLength {
/// Expected byte length.
expected: usize,
/// Actual byte length encountered.
got: usize,
},
/// Output file already exists and `--force` was not specified.
#[error("output file already exists (use --force to overwrite): {0}")]
OutputExists(std::path::PathBuf),
/// Passphrase did not decrypt the key, or the encrypted key body is corrupt.
#[error("wrong passphrase or corrupted key")]
WrongPassphrase,
/// Operation requires a passphrase but none was supplied.
#[error("private key is passphrase-protected; provide a passphrase to decrypt it")]
PassphraseRequired,
/// New-passphrase confirmation did not match.
#[error("passphrases do not match")]
PassphraseMismatch,
/// Signature bytes are malformed (wrong length or structure).
#[error("invalid signature: malformed bytes")]
InvalidSignature,
/// ML-DSA signature is well-formed but does not verify against the data.
#[error("signature verification failed")]
SignatureVerificationFailed,
/// None of the recipient slots in a v4/v7/v8/v9 file matched the supplied private key.
#[error("no matching recipient found: {slots_tried} slot(s) tried with the given private key")]
NoMatchingRecipient {
/// Number of recipient slots that were attempted during decryption.
slots_tried: usize,
},
/// The key has been explicitly revoked.
#[error("key has been revoked (fingerprint: {fingerprint}): {reason}")]
KeyRevoked {
/// Colon-separated hex fingerprint of the revoked key.
fingerprint: String,
/// Human-readable reason supplied at revocation time.
reason: String,
},
/// v6 compressed files require the `zstd` feature, which is absent in this build.
#[error("compressed .pqf files (v6) are not supported in this build")]
CompressionNotSupported,
/// The private key was encrypted with legacy Argon2id parameters (p=1, pqfile < 4.0).
/// Run `pqfile repassphrase --from-legacy` to upgrade it before use.
#[error(
"private key uses legacy Argon2id parameters (p=1); \
run `pqfile repassphrase --from-legacy` to upgrade it to p=4 before use"
)]
LegacyKeyFormat,
/// Shamir share fingerprints did not agree; shares may be from different keys.
#[error("key share reconstruction failed: fingerprint mismatch, ensure you have the correct shares for this key")]
ShareVerificationFailed,
/// Argon2id parameters in a v10 passphrase-only file exceed the configured ceiling.
///
/// A crafted file could specify extreme Argon2 parameters to force excessive memory or
/// CPU use on decryption. The ceiling is checked before the KDF runs. Raise
/// `--max-kdf-mem` / `--max-kdf-time` flags to accept a higher cost, or reject the file.
#[error(
"KDF parameters in file (m={m_kib} KiB, t={t}) exceed ceiling \
(max_m={max_m_kib} KiB, max_t={max_t})"
)]
KdfLimitExceeded {
/// Memory cost (KiB) requested by the file.
m_kib: u32,
/// Time cost (iterations) requested by the file.
t: u32,
/// Maximum memory cost (KiB) the caller will allow.
max_m_kib: u32,
/// Maximum time cost the caller will allow.
max_t: u32,
},
/// The `.pqf` stream ended without a terminal chunk (is_last flag never seen).
/// The file was most likely truncated during download or transfer; re-downloading
/// is the appropriate recovery action. Distinguished from [`DecryptionFailure`]
/// which indicates bytes are present but the authentication tag is wrong.
#[error("stream truncated: file ended before the final authenticated chunk was seen; re-download and try again")]
Truncated,
/// The v10 file was encrypted with a keyfile second factor but none was supplied.
#[error(
"this file requires a keyfile: it was encrypted with a keyfile second factor; \
pass --keyfile <PATH> with the same keyfile used at encryption"
)]
KeyfileRequired,
/// A keyfile was supplied but the v10 file was not encrypted with one.
/// Failing loudly here avoids silently deriving a wrong key (which would
/// surface as an opaque authentication failure) and tells the caller the
/// keyfile is not protecting this file.
#[error("file was not encrypted with a keyfile; remove --keyfile and retry")]
KeyfileNotRequired,
/// The v10 header carries feature flag bits this build does not understand.
/// The file was likely produced by a newer pqfile; upgrade to decrypt it.
#[error(
"unsupported header flags: {0:#04x}; file was likely written by a newer pqfile version"
)]
UnsupportedHeaderFlags(u8),
/// The v10 file was encrypted with a FIDO2 hardware token second factor but
/// none was supplied (or a keyfile was supplied instead).
#[error(
"this file requires a FIDO2 security key: it was encrypted with a hardware token second \
factor; pass --fido2 <ENROLLMENT_FILE> with the same enrollment used at encryption"
)]
Fido2Required,
/// A FIDO2 token was supplied but the v10 file was not encrypted with one
/// (or was encrypted with a keyfile instead). Mirrors [`KeyfileNotRequired`]:
/// failing loudly here avoids silently deriving a wrong key.
///
/// [`KeyfileNotRequired`]: PqfileError::KeyfileNotRequired
#[error("file was not encrypted with a FIDO2 second factor; remove --fido2 and retry")]
Fido2NotRequired,
/// A certificate's signature verified, but `now` fell outside its validity window.
#[error(
"certificate is not valid at this time: window is {not_before}..={not_after}, now is {now}"
)]
CertNotValid {
/// Validity window start, Unix seconds (inclusive).
not_before: u64,
/// Validity window end, Unix seconds (inclusive).
not_after: u64,
/// The time the certificate was checked against, Unix seconds.
now: u64,
},
/// A certificate was presented for a use its `allowed_use` bitmask does not permit.
#[error("certificate does not permit the requested use: required {required:#04x}, allowed {allowed:#04x}")]
CertUseNotPermitted {
/// Bitmask of [`crate::cert::cert_use`] flags the caller required.
required: u8,
/// Bitmask of [`crate::cert::cert_use`] flags the certificate allows.
allowed: u8,
},
/// A certificate presented as a recipient or verifying key appears in a
/// verified [`crate::cert::RevocationList`] the caller chose to consult.
#[error("certificate has been revoked (id: {cert_id}): {reason}")]
CertRevoked {
/// Colon-separated hex prefix of the revoked certificate's [`crate::cert::cert_id`].
cert_id: String,
/// Human-readable reason supplied at revocation time.
reason: String,
},
/// A tlock (`tlock` feature) file cannot be decrypted yet: the drand relay
/// reports the target round has not fired. Not an error condition per se;
/// retry after the round's expected time.
#[cfg(feature = "tlock")]
#[error("time-lock round {round} has not been reached yet; try again later")]
TlockRoundNotReached {
/// The target round recorded in the file header.
round: u64,
},
/// Fetching the drand beacon failed for a reason other than "not yet reached"
/// (network error, relay unreachable, chain hash mismatch, malformed response).
#[cfg(feature = "tlock")]
#[error("failed to fetch drand beacon: {0}")]
TlockBeaconFetchFailed(String),
/// The beacon signature was fetched successfully (the round has fired) but the
/// tlock IBE decryption or the subsequent AEAD authentication failed. Since this
/// can only happen once the round has fired, it indicates ciphertext corruption
/// or tampering, not an early decryption attempt.
#[cfg(feature = "tlock")]
#[error("time-lock decryption failed: ciphertext is corrupt or was tampered with")]
TlockDecryptionFailed,
/// The v10 file was encrypted with a WebAuthn `prf` extension second
/// factor but none was supplied (or a different second factor was
/// supplied instead). Browser-native equivalent of [`Fido2Required`].
///
/// [`Fido2Required`]: PqfileError::Fido2Required
#[error(
"this file requires a WebAuthn passkey: it was encrypted with a browser PRF second \
factor; present the same enrolled credential used at encryption"
)]
WebauthnPrfRequired,
/// A WebAuthn `prf` output was supplied but the v10 file was not
/// encrypted with one (or was encrypted with a different second factor
/// instead). Mirrors [`Fido2NotRequired`].
///
/// [`Fido2NotRequired`]: PqfileError::Fido2NotRequired
#[error("file was not encrypted with a WebAuthn PRF second factor; remove it and retry")]
WebauthnPrfNotRequired,
/// A [`crate::sealed_sender`] deniable-authentication tag did not verify
/// against the claimed sender's identity key. Unlike
/// [`SignatureVerificationFailed`], this only means the specific
/// recipient's own key material could not reproduce the tag - by design,
/// no third party (including one holding the sender's identity public
/// key) can distinguish "wrong sender" from "the true recipient forged
/// this" after the fact.
///
/// [`SignatureVerificationFailed`]: PqfileError::SignatureVerificationFailed
#[error("sealed-sender authentication failed: the claimed sender's identity key did not produce a valid tag")]
SealedSenderAuthFailed,
}
impl PqfileError {
/// Stable numeric code for this error, as emitted in the CLI's `--json`
/// error output and documented in `docs/ERROR_CODES.md`.
///
/// Codes are append-only: new variants are always assigned new codes, and
/// existing codes are never reused or reassigned. The match below is
/// deliberately exhaustive (no `_` arm) so adding a variant without
/// assigning it a code is a compile error rather than a silent fallthrough.
#[must_use]
pub fn code(&self) -> u32 {
match self {
PqfileError::Io(_) => 1,
PqfileError::InvalidMagic => 2,
PqfileError::UnsupportedVersion(_) => 3,
PqfileError::UnsupportedKem(_) => 4,
PqfileError::KemVariantMismatch { .. } => 5,
PqfileError::EncryptionFailure => 6,
PqfileError::DecryptionFailure => 7,
PqfileError::InvalidPem(_) => 8,
PqfileError::InvalidKeyLength { .. } => 9,
PqfileError::OutputExists(_) => 10,
PqfileError::WrongPassphrase => 11,
PqfileError::PassphraseRequired => 12,
PqfileError::PassphraseMismatch => 13,
PqfileError::InvalidSignature => 14,
PqfileError::SignatureVerificationFailed => 15,
PqfileError::NoMatchingRecipient { .. } => 16,
PqfileError::KeyRevoked { .. } => 17,
PqfileError::CompressionNotSupported => 18,
PqfileError::LegacyKeyFormat => 19,
PqfileError::ShareVerificationFailed => 20,
PqfileError::Truncated => 21,
PqfileError::KdfLimitExceeded { .. } => 22,
PqfileError::KeyfileRequired => 23,
PqfileError::KeyfileNotRequired => 24,
PqfileError::UnsupportedHeaderFlags(_) => 25,
PqfileError::Fido2Required => 26,
PqfileError::Fido2NotRequired => 27,
PqfileError::CertNotValid { .. } => 28,
PqfileError::CertUseNotPermitted { .. } => 29,
#[cfg(feature = "tlock")]
PqfileError::TlockRoundNotReached { .. } => 30,
#[cfg(feature = "tlock")]
PqfileError::TlockBeaconFetchFailed(_) => 31,
#[cfg(feature = "tlock")]
PqfileError::TlockDecryptionFailed => 32,
PqfileError::WebauthnPrfRequired => 33,
PqfileError::WebauthnPrfNotRequired => 34,
PqfileError::SealedSenderAuthFailed => 35,
PqfileError::CertRevoked { .. } => 36,
}
}
}