1mod keyring;
2mod prompt;
3mod source;
4mod stdin;
5
6use std::io::{self, IsTerminal};
7
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use time::{OffsetDateTime, format_description::well_known::Rfc3339};
12
13pub use keyring::KeyringCredentialStore;
14pub use source::{SecretInputFuture, SecretSource};
15
16const PASSWORD_STDIN_GUIDANCE: &str = "pass --password-stdin to read the password from stdin";
17const TWO_FACTOR_STDIN_GUIDANCE: &str = "replace --code with --password-stdin --code-stdin to read the password and two-factor code from stdin";
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum LoginSecretMode {
21 PromptPassword,
22 PromptPasswordAndCode,
23 StdinPassword,
24 RawStdinPassword,
25 StdinPasswordAndCode,
26}
27
28#[derive(Debug)]
29pub struct LoginSecrets {
30 password: SecretString,
31 code: Option<SecretString>,
32}
33
34impl LoginSecrets {
35 pub fn new(password: SecretString, code: Option<SecretString>) -> Self {
36 Self { password, code }
37 }
38
39 pub fn into_parts(self) -> (SecretString, Option<SecretString>) {
40 (self.password, self.code)
41 }
42}
43
44pub trait SecretInputProvider: Send + Sync {
45 fn read_storage_secret(&self, _source: SecretSource, _limit: usize) -> SecretInputFuture<'_> {
47 Box::pin(async {
48 Err(crate::error::AppError::invalid_input(
49 "storage credential input is unavailable",
50 ))
51 })
52 }
53
54 fn read_raw_secret(&self) -> Result<SecretString, crate::error::AppError> {
56 Err(crate::error::AppError::invalid_input(
57 "raw secret input is unavailable",
58 ))
59 }
60 fn read_new_account_password(
61 &self,
62 from_stdin: bool,
63 ) -> Result<SecretString, crate::error::AppError>;
64
65 fn read_login_secrets(
66 &self,
67 mode: LoginSecretMode,
68 ) -> Result<LoginSecrets, crate::error::AppError>;
69}
70
71#[derive(Clone, Copy, Debug, Default)]
72pub struct SystemSecretInputProvider;
73
74impl SecretInputProvider for SystemSecretInputProvider {
75 fn read_storage_secret(&self, source: SecretSource, limit: usize) -> SecretInputFuture<'_> {
76 Box::pin(source::read_storage_secret(source, limit))
77 }
78
79 fn read_raw_secret(&self) -> Result<SecretString, crate::error::AppError> {
80 stdin::read_raw_secret(io::stdin().lock())
81 }
82 fn read_new_account_password(
83 &self,
84 from_stdin: bool,
85 ) -> Result<SecretString, crate::error::AppError> {
86 if from_stdin {
87 return stdin::read_password_line(io::stdin().lock());
88 }
89 require_terminal(PASSWORD_STDIN_GUIDANCE)?;
90
91 prompt::read_confirmed_password()
92 }
93
94 fn read_login_secrets(
95 &self,
96 mode: LoginSecretMode,
97 ) -> Result<LoginSecrets, crate::error::AppError> {
98 match mode {
99 LoginSecretMode::RawStdinPassword => self
100 .read_raw_secret()
101 .map(|password| LoginSecrets::new(password, None)),
102 LoginSecretMode::PromptPassword => {
103 require_terminal(PASSWORD_STDIN_GUIDANCE)?;
104 let password = prompt::read_password()?;
105 Ok(LoginSecrets::new(password, None))
106 }
107 LoginSecretMode::PromptPasswordAndCode => {
108 require_terminal(TWO_FACTOR_STDIN_GUIDANCE)?;
109 let password = prompt::read_password()?;
110 let code = prompt::read_code()?;
111 Ok(LoginSecrets::new(password, Some(code)))
112 }
113 LoginSecretMode::StdinPassword | LoginSecretMode::StdinPasswordAndCode => {
114 let mut input = io::stdin().lock();
115 let password = stdin::read_password_line(&mut input)?;
116 let code = if mode == LoginSecretMode::StdinPasswordAndCode {
117 Some(stdin::read_code_line(&mut input)?)
118 } else {
119 None
120 };
121 Ok(LoginSecrets::new(password, code))
122 }
123 }
124 }
125}
126
127fn require_terminal(guidance: &str) -> Result<(), crate::error::AppError> {
128 if io::stdin().is_terminal() {
129 Ok(())
130 } else {
131 Err(crate::error::AppError::invalid_input(format!(
132 "standard input is not a terminal; {guidance}"
133 )))
134 }
135}
136
137fn validate_password(
138 password: String,
139 confirmation: Option<String>,
140) -> Result<SecretString, crate::error::AppError> {
141 if password.is_empty() {
142 return Err(crate::error::AppError::invalid_input(
143 "password must not be empty",
144 ));
145 }
146 if confirmation.is_some_and(|confirmation| password != confirmation) {
147 return Err(crate::error::AppError::invalid_input(
148 "password and confirmation do not match",
149 ));
150 }
151 Ok(SecretString::from(password))
152}
153
154fn validate_code(code: String) -> Result<SecretString, crate::error::AppError> {
155 if code.is_empty() {
156 return Err(crate::error::AppError::invalid_input(
157 "two-factor code must not be empty",
158 ));
159 }
160 Ok(SecretString::from(code))
161}
162
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct CredentialTarget {
165 account: String,
166 server_url: String,
167}
168
169impl CredentialTarget {
170 pub fn profile_in_store(name: &str, store_namespace: &str, server_url: String) -> Self {
171 Self {
172 account: format!("profile:{store_namespace}:{name}"),
173 server_url,
174 }
175 }
176
177 pub fn account(&self) -> &str {
178 &self.account
179 }
180
181 pub fn server_url(&self) -> &str {
182 &self.server_url
183 }
184
185 pub(crate) fn server_lock_key(&self) -> String {
186 format!("server:{}", self.server_url)
187 }
188
189 pub(crate) fn account_lock_key(&self) -> &str {
190 &self.account
191 }
192}
193
194pub trait CredentialStore: Send + Sync {
195 fn check_available(&self, target: &CredentialTarget) -> Result<(), CredentialError>;
196 fn exists(&self, target: &CredentialTarget) -> Result<bool, CredentialError>;
198 fn load(&self, target: &CredentialTarget) -> Result<Option<CredentialRecord>, CredentialError>;
199 fn create(
200 &self,
201 target: &CredentialTarget,
202 record: &CredentialRecord,
203 ) -> Result<CredentialCreateOutcome, CredentialError>;
204 fn delete(&self, target: &CredentialTarget) -> Result<bool, CredentialError>;
205 fn delete_if_matches(
208 &self,
209 target: &CredentialTarget,
210 expected: &CredentialRecord,
211 ) -> Result<CredentialDeleteOutcome, CredentialError>;
212
213 fn lock_mutation(
214 &self,
215 target: &CredentialTarget,
216 ) -> Result<Box<dyn CredentialMutation + Send + '_>, CredentialError> {
217 Ok(Box::new(UnlockedCredentialMutation {
218 store: self,
219 target: target.clone(),
220 }))
221 }
222}
223
224pub trait CredentialMutation: Send {
225 fn exists(&self) -> Result<bool, CredentialError>;
226 fn load(&self) -> Result<Option<CredentialRecord>, CredentialError>;
227 fn create(&self, record: &CredentialRecord)
228 -> Result<CredentialCreateOutcome, CredentialError>;
229 fn delete(&self) -> Result<bool, CredentialError>;
230 fn delete_if_matches(
231 &self,
232 expected: &CredentialRecord,
233 ) -> Result<CredentialDeleteOutcome, CredentialError>;
234}
235
236struct UnlockedCredentialMutation<'a, S: CredentialStore + ?Sized> {
237 store: &'a S,
238 target: CredentialTarget,
239}
240
241impl<S: CredentialStore + ?Sized> CredentialMutation for UnlockedCredentialMutation<'_, S> {
242 fn exists(&self) -> Result<bool, CredentialError> {
243 self.store.exists(&self.target)
244 }
245
246 fn load(&self) -> Result<Option<CredentialRecord>, CredentialError> {
247 self.store.load(&self.target)
248 }
249
250 fn create(
251 &self,
252 record: &CredentialRecord,
253 ) -> Result<CredentialCreateOutcome, CredentialError> {
254 self.store.create(&self.target, record)
255 }
256
257 fn delete(&self) -> Result<bool, CredentialError> {
258 self.store.delete(&self.target)
259 }
260
261 fn delete_if_matches(
262 &self,
263 expected: &CredentialRecord,
264 ) -> Result<CredentialDeleteOutcome, CredentialError> {
265 self.store.delete_if_matches(&self.target, expected)
266 }
267}
268
269#[derive(Clone, Copy, Debug, Eq, PartialEq)]
270pub enum CredentialCreateOutcome {
271 Created,
272 AlreadyExists,
273}
274
275#[derive(Clone, Copy, Debug, Eq, PartialEq)]
276pub enum CredentialDeleteOutcome {
277 Removed,
278 Absent,
279 Mismatch,
280}
281
282#[derive(Clone, Debug)]
283pub struct CredentialRecord {
284 server_url: String,
285 user_id: String,
286 token: SecretString,
287 token_expires: OffsetDateTime,
288}
289
290impl CredentialRecord {
291 pub fn new(
292 server_url: String,
293 user_id: String,
294 token: SecretString,
295 token_expires: OffsetDateTime,
296 ) -> Self {
297 Self {
298 server_url,
299 user_id,
300 token,
301 token_expires,
302 }
303 }
304
305 pub fn server_url(&self) -> &str {
306 &self.server_url
307 }
308
309 pub fn user_id(&self) -> &str {
310 &self.user_id
311 }
312
313 pub fn token(&self) -> &SecretString {
314 &self.token
315 }
316
317 pub const fn token_expires(&self) -> OffsetDateTime {
318 self.token_expires
319 }
320
321 pub fn matches(&self, other: &Self) -> bool {
322 self.server_url == other.server_url
323 && self.user_id == other.user_id
324 && self.token.expose_secret() == other.token.expose_secret()
325 && self.token_expires == other.token_expires
326 }
327
328 fn decode(expected_server_url: &str, encoded: &[u8]) -> Result<Self, CredentialError> {
329 let stored: StoredCredentialOwned =
330 serde_json::from_slice(encoded).map_err(CredentialError::Deserialize)?;
331 if stored.version != 1 {
332 return Err(CredentialError::UnsupportedVersion(stored.version));
333 }
334 if stored.server_url != expected_server_url {
335 return Err(CredentialError::InvalidRecord(
336 "the stored server URL did not match the selected credential target",
337 ));
338 }
339 if stored.user_id.is_empty() {
340 return Err(CredentialError::InvalidRecord(
341 "the stored user id was empty",
342 ));
343 }
344 if stored.token.is_empty() {
345 return Err(CredentialError::InvalidRecord(
346 "the stored login token was empty",
347 ));
348 }
349 let token_expires = OffsetDateTime::parse(&stored.token_expires, &Rfc3339)
350 .map_err(CredentialError::TimestampParse)?;
351
352 Ok(Self::new(
353 stored.server_url,
354 stored.user_id,
355 SecretString::from(stored.token),
356 token_expires,
357 ))
358 }
359
360 fn encode(&self) -> Result<Vec<u8>, CredentialError> {
361 let token_expires = self
362 .token_expires
363 .format(&Rfc3339)
364 .map_err(CredentialError::Timestamp)?;
365 let stored = StoredCredential {
366 version: 1,
367 server_url: &self.server_url,
368 user_id: &self.user_id,
369 token: self.token.expose_secret(),
370 token_expires: &token_expires,
371 };
372 serde_json::to_vec(&stored).map_err(CredentialError::Serialize)
373 }
374}
375
376#[derive(Serialize)]
377struct StoredCredential<'a> {
378 version: u8,
379 server_url: &'a str,
380 user_id: &'a str,
381 token: &'a str,
382 token_expires: &'a str,
383}
384
385#[derive(Deserialize)]
386#[serde(deny_unknown_fields)]
387struct StoredCredentialOwned {
388 version: u8,
389 server_url: String,
390 user_id: String,
391 token: String,
392 token_expires: String,
393}
394
395#[derive(Debug, Error)]
396pub enum CredentialError {
397 #[error("the operating-system credential store is unavailable: {0}")]
398 Unavailable(String),
399 #[error("the credential could not be stored: {0}")]
400 Store(String),
401 #[error("the credential could not be loaded: {0}")]
402 Load(String),
403 #[error("the credential could not be deleted: {0}")]
404 Delete(String),
405 #[error("the credential mutation could not be synchronized: {0}")]
406 Lock(String),
407 #[error("the credential record could not be serialized")]
408 Serialize(#[source] serde_json::Error),
409 #[error("the credential record could not be decoded")]
410 Deserialize(#[source] serde_json::Error),
411 #[error("credential record version {0} is not supported")]
412 UnsupportedVersion(u8),
413 #[error("the credential record is invalid: {0}")]
414 InvalidRecord(&'static str),
415 #[error("the credential expiry could not be formatted")]
416 Timestamp(#[source] time::error::Format),
417 #[error("the stored credential expiry is not RFC 3339")]
418 TimestampParse(#[source] time::error::Parse),
419}
420
421#[cfg(test)]
422mod tests {
423 use secrecy::{ExposeSecret, SecretString};
424 use time::{OffsetDateTime, format_description::well_known::Rfc3339};
425
426 use super::{
427 CredentialError, CredentialRecord, CredentialTarget, PASSWORD_STDIN_GUIDANCE,
428 TWO_FACTOR_STDIN_GUIDANCE, validate_password,
429 };
430
431 #[test]
432 fn namespaced_profile_targets_are_isolated_but_share_a_server_transaction_key() {
433 let server = "https://wekan.example/".to_owned();
434 let first = CredentialTarget::profile_in_store("work", "store-one", server.clone());
435 let second = CredentialTarget::profile_in_store("work", "store-two", server);
436
437 assert_eq!(first.account(), "profile:store-one:work");
438 assert_eq!(second.account(), "profile:store-two:work");
439 assert_ne!(first.account(), second.account());
440 assert_eq!(first.server_lock_key(), second.server_lock_key());
441 assert_ne!(first.account_lock_key(), second.account_lock_key());
442 }
443
444 #[test]
445 fn interactive_confirmation_must_match() {
446 let error = validate_password("first".to_owned(), Some("second".to_owned()))
447 .expect_err("mismatched confirmation must fail");
448 assert_eq!(error.message(), "password and confirmation do not match");
449 }
450
451 #[test]
452 fn login_password_does_not_require_confirmation() {
453 assert!(validate_password("password".to_owned(), None).is_ok());
454 }
455
456 #[test]
457 fn non_terminal_guidance_distinguishes_two_factor_login() {
458 assert_eq!(
459 PASSWORD_STDIN_GUIDANCE,
460 "pass --password-stdin to read the password from stdin"
461 );
462 assert_eq!(
463 TWO_FACTOR_STDIN_GUIDANCE,
464 "replace --code with --password-stdin --code-stdin to read the password and two-factor code from stdin"
465 );
466 }
467
468 #[test]
469 fn encodes_a_version_1_credential_record() {
470 let expiry = OffsetDateTime::parse("2030-01-02T03:04:05Z", &Rfc3339).unwrap();
471 let record = CredentialRecord::new(
472 "https://wekan.example/".to_owned(),
473 "user-1".to_owned(),
474 SecretString::from("server-token".to_owned()),
475 expiry,
476 );
477
478 let value: serde_json::Value = serde_json::from_slice(&record.encode().unwrap()).unwrap();
479 assert_eq!(value["version"], 1);
480 assert_eq!(value["server_url"], "https://wekan.example/");
481 assert_eq!(value["user_id"], "user-1");
482 assert_eq!(value["token"], "server-token");
483 assert_eq!(value["token_expires"], "2030-01-02T03:04:05Z");
484 assert!(!format!("{record:?}").contains("server-token"));
485 }
486
487 #[test]
488 fn decodes_and_validates_a_version_1_credential_record() {
489 let encoded = serde_json::to_vec(&serde_json::json!({
490 "version": 1,
491 "server_url": "https://wekan.example/",
492 "user_id": "user-1",
493 "token": "server-token",
494 "token_expires": "2030-01-02T03:04:05Z"
495 }))
496 .unwrap();
497
498 let record = CredentialRecord::decode("https://wekan.example/", &encoded).unwrap();
499
500 assert_eq!(record.server_url(), "https://wekan.example/");
501 assert_eq!(record.user_id(), "user-1");
502 assert_eq!(record.token().expose_secret(), "server-token");
503 assert_eq!(
504 record.token_expires().format(&Rfc3339).unwrap(),
505 "2030-01-02T03:04:05Z"
506 );
507 }
508
509 #[test]
510 fn rejects_malformed_and_non_version_1_credential_records() {
511 assert!(matches!(
512 CredentialRecord::decode("https://wekan.example/", b"not json"),
513 Err(CredentialError::Deserialize(_))
514 ));
515
516 let unsupported = serde_json::to_vec(&serde_json::json!({
517 "version": 0,
518 "server_url": "https://wekan.example/",
519 "user_id": "user-1",
520 "token": "server-token",
521 "token_expires": "2030-01-02T03:04:05Z"
522 }))
523 .unwrap();
524 assert!(matches!(
525 CredentialRecord::decode("https://wekan.example/", &unsupported),
526 Err(CredentialError::UnsupportedVersion(0))
527 ));
528 }
529
530 #[test]
531 fn rejects_inconsistent_or_incomplete_credential_records() {
532 fn encoded(overrides: serde_json::Value) -> Vec<u8> {
533 let mut value = serde_json::json!({
534 "version": 1,
535 "server_url": "https://wekan.example/",
536 "user_id": "user-1",
537 "token": "server-token",
538 "token_expires": "2030-01-02T03:04:05Z"
539 });
540 for (key, value_override) in overrides.as_object().unwrap() {
541 value[key] = value_override.clone();
542 }
543 serde_json::to_vec(&value).unwrap()
544 }
545
546 for invalid in [
547 encoded(serde_json::json!({"user_id": ""})),
548 encoded(serde_json::json!({"token": ""})),
549 encoded(serde_json::json!({"token_expires": "not-a-time"})),
550 ] {
551 assert!(CredentialRecord::decode("https://wekan.example/", &invalid).is_err());
552 }
553
554 let mismatched = encoded(serde_json::json!({}));
555 assert!(matches!(
556 CredentialRecord::decode("https://other.example/", &mismatched),
557 Err(CredentialError::InvalidRecord(_))
558 ));
559 }
560}