Skip to main content

wekan_cli/
credentials.rs

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    /// Checks whether any native-vault entry exists without decoding it.
169    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    /// Deletes only the expected record. Implementations must serialize this
178    /// comparison and deletion with credential saves for the same account.
179    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}