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