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    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    /// Read exact UTF-8 bytes through EOF, including line endings and empty input.
44    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    /// Checks whether any native-vault entry exists without decoding it.
182    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    /// Deletes only the expected record. Implementations must serialize this
191    /// comparison and deletion with credential saves for the same account.
192    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}