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