Skip to main content

wekan_cli/
credentials.rs

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    /// Read a bounded UTF-8 storage credential, removing one final LF or CRLF.
46    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    /// Read exact UTF-8 bytes through EOF, including line endings and empty input.
55    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    /// Checks whether any native-vault entry exists without decoding it.
197    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    /// Deletes only the expected record. Implementations must serialize this
206    /// comparison and deletion with credential saves for the same account.
207    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}