mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
backup: keep Proton key passphrases, never the password
The stored session used to hold the mailbox password, which on a single-password account is the login password. It now holds the per-key passphrases derived from it at login, which unlock the keys but cannot log in, change the password or turn 2FA off. Login also checks them against the account's keys, so a wrong mailbox password fails there rather than at the first backup. A session stored by an older build is upgraded on the next start: its key salts are already known, so the passphrases derive locally and the file is rewritten without the password.
This commit is contained in:
1 parent
13551469a0
commit
cd18f80c04
5 files changed
+233
-50
No files matched your search
Generated
+1
@@ -6089,6 +6089,7 @@ version = "1.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"zeroize_derive",
|
"zeroize_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ chrono-tz = "0.10"
|
|||||||
# opened a chunk at a time; Argon2id between the password and the key.
|
# opened a chunk at a time; Argon2id between the password and the key.
|
||||||
aes-gcm = { version = "0.10", features = ["stream"] }
|
aes-gcm = { version = "0.10", features = ["stream"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
zeroize = "1"
|
zeroize = { version = "1", features = ["serde"] }
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
proton-drive-rs = "0.9.2"
|
proton-drive-rs = "0.9.2"
|
||||||
|
|||||||
@@ -257,10 +257,14 @@ narl-workspace proton-login # or, in Docker:
|
|||||||
docker compose exec -it workspace narl-workspace proton-login
|
docker compose exec -it workspace narl-workspace proton-login
|
||||||
```
|
```
|
||||||
|
|
||||||
That stores a session — tokens, the mailbox password needed to rebuild the key
|
That checks the password against the account's keys and stores a session —
|
||||||
chain, and the account key salts — as a `0600` file next to the database. It
|
the tokens, and the key passphrases derived from the password at login — as a
|
||||||
sits on the same volume as the workspace it protects, and it is enough to read the
|
`0600` file next to the database. The password itself is never stored: the
|
||||||
account, so the volume is the thing to keep private. Built with
|
passphrases unlock the keys but cannot log in, change the password or turn 2FA
|
||||||
|
off. They are still enough to read the account, and the file sits on the same
|
||||||
|
volume as the workspace it protects, so the volume is the thing to keep private.
|
||||||
|
A session stored by an older build, which kept the password, is rewritten
|
||||||
|
without it on the next start; no new login is needed. Built with
|
||||||
`--features keyring` on a host that has a Secret Service, the session goes to
|
`--features keyring` on a host that has a Secret Service, the session goes to
|
||||||
the OS keyring instead and the file is only a fallback; the container has no
|
the OS keyring instead and the file is only a fallback; the container has no
|
||||||
session bus, so there it is always the file.
|
session bus, so there it is always the file.
|
||||||
|
|||||||
+106
-26
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
|
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
|
||||||
|
use proton_sdk::account::AccountClient;
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::session::ProtonApiSession;
|
use proton_sdk::session::ProtonApiSession;
|
||||||
|
|
||||||
use super::session::{SessionStore, StoredSession};
|
use super::session::{SessionStore, StoredSession};
|
||||||
@@ -60,22 +62,24 @@ pub async fn login(
|
|||||||
.context("Proton rejected the second-factor code")?;
|
.context("Proton rejected the second-factor code")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Salts are fetched here and only here: this access token still carries the
|
// The passphrases are derived here and only here: this access token still
|
||||||
// `locked` scope, and no refreshed one ever will.
|
// carries the `locked` scope the key salts need, and no refreshed one ever
|
||||||
|
// will. Unlocking checks them against the account's keys before anything
|
||||||
|
// is stored, so a wrong mailbox password fails now and not at the first
|
||||||
|
// backup.
|
||||||
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
|
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
|
||||||
let key_salts = client
|
let account = client.account();
|
||||||
.account()
|
let key_passphrases = account
|
||||||
.key_salts()
|
.key_passphrases()
|
||||||
.await
|
.await
|
||||||
.context("could not read the account key salts")?;
|
.context("could not derive the account key passphrases")?;
|
||||||
|
account.unlock().await.map_err(|e| match e {
|
||||||
|
ProtonError::KeysLocked => anyhow!("that password does not unlock the account's keys"),
|
||||||
|
e => anyhow::Error::from(e).context("could not unlock the account's keys"),
|
||||||
|
})?;
|
||||||
|
|
||||||
let tokens = session.current_tokens().await;
|
let tokens = session.current_tokens().await;
|
||||||
store.save(&StoredSession::from_session(
|
store.save(&StoredSession::from_session(&session, tokens, key_passphrases))?;
|
||||||
&session,
|
|
||||||
tokens,
|
|
||||||
password,
|
|
||||||
key_salts,
|
|
||||||
))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +87,15 @@ pub async fn login(
|
|||||||
/// been stored yet — that is the ordinary "backups are not set up" state, not
|
/// been stored yet — that is the ordinary "backups are not set up" state, not
|
||||||
/// an error.
|
/// an error.
|
||||||
pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
||||||
let Some(stored) = store.load()? else {
|
let Some(mut stored) = store.load()? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters())
|
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters())
|
||||||
.context("the stored Proton session could not be resumed")?;
|
.context("the stored Proton session could not be resumed")?;
|
||||||
|
|
||||||
if stored.key_salts.is_empty() {
|
if stored.key_passphrases.is_empty() {
|
||||||
return Err(anyhow!(
|
stored = upgrade(store, &session, stored).await?;
|
||||||
"the stored Proton session has no key salts — run `narl-workspace proton-login` again"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every 401 refresh mints a new refresh token and invalidates the old one,
|
// Every 401 refresh mints a new refresh token and invalidates the old one,
|
||||||
@@ -111,18 +113,50 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = ProtonDriveClient::with_key_salts(
|
let client = ProtonDriveClient::with_key_passphrases(&session, stored.key_passphrases)
|
||||||
&session,
|
// A workspace is mostly small files; one atomic request beats the
|
||||||
stored.mailbox_password.clone().into_bytes(),
|
// draft/block/commit dance for anything that fits in a single block.
|
||||||
stored.key_salts.clone(),
|
.with_small_file_upload(true);
|
||||||
)
|
|
||||||
// A workspace is mostly small files; one atomic request beats the
|
|
||||||
// draft/block/commit dance for anything that fits in a single block.
|
|
||||||
.with_small_file_upload(true);
|
|
||||||
|
|
||||||
Ok(Some(client))
|
Ok(Some(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rewrite a session stored before the passphrases were, which kept the
|
||||||
|
/// mailbox password and the key salts instead. The salts are already known, so
|
||||||
|
/// the passphrases derive locally, with no request and no new login, and the
|
||||||
|
/// password is gone from the store once the new session is written.
|
||||||
|
async fn upgrade(
|
||||||
|
store: &SessionStore,
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
stored: StoredSession,
|
||||||
|
) -> Result<StoredSession> {
|
||||||
|
let (Some(password), false) = (&stored.mailbox_password, stored.key_salts.is_empty()) else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"the stored Proton session has no key passphrases — run `narl-workspace proton-login` again"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let account = AccountClient::with_key_salts(
|
||||||
|
session,
|
||||||
|
password.clone().into_bytes(),
|
||||||
|
stored.key_salts.clone(),
|
||||||
|
);
|
||||||
|
let key_passphrases = account
|
||||||
|
.key_passphrases()
|
||||||
|
.await
|
||||||
|
.context("could not derive key passphrases from the stored session")?;
|
||||||
|
|
||||||
|
let upgraded = StoredSession {
|
||||||
|
key_passphrases,
|
||||||
|
mailbox_password: None,
|
||||||
|
key_salts: Vec::new(),
|
||||||
|
..stored
|
||||||
|
};
|
||||||
|
store.save(&upgraded)?;
|
||||||
|
tracing::info!("the stored Proton session now keeps key passphrases instead of the password");
|
||||||
|
Ok(upgraded)
|
||||||
|
}
|
||||||
|
|
||||||
/// The device this server owns, registered on first use.
|
/// The device this server owns, registered on first use.
|
||||||
///
|
///
|
||||||
/// A device is a sync root with its own share, which is what makes the workspace
|
/// A device is a sync root with its own share, which is what makes the workspace
|
||||||
@@ -152,7 +186,53 @@ pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Dev
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::app_version;
|
use super::*;
|
||||||
|
|
||||||
|
/// No request leaves for this: resuming only builds the HTTP client, and
|
||||||
|
/// with the salts already stored the passphrases derive locally.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_old_session_trades_its_password_for_passphrases() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("proton-session.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":1,
|
||||||
|
"mailbox_password":"hunter2hunter2",
|
||||||
|
"key_salts":[{"ID":"key-1","KeySalt":"c2FsdHNhbHRzYWx0c2FsdA=="}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = SessionStore::new(&path);
|
||||||
|
let stored = store.load().unwrap().unwrap();
|
||||||
|
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||||
|
|
||||||
|
let upgraded = upgrade(&store, &session, stored).await.unwrap();
|
||||||
|
assert_eq!(upgraded.key_passphrases.len(), 1);
|
||||||
|
assert!(upgraded.key_passphrases.get("key-1").is_some());
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!json.contains("hunter2"), "{json}");
|
||||||
|
let reloaded = store.load().unwrap().unwrap();
|
||||||
|
assert!(reloaded.key_passphrases == upgraded.key_passphrases);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_with_nothing_to_unlock_with_asks_for_a_login() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("proton-session.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":1}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = SessionStore::new(&path);
|
||||||
|
let stored = store.load().unwrap().unwrap();
|
||||||
|
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
|
||||||
|
|
||||||
|
let error = upgrade(&store, &session, stored).await.err().unwrap();
|
||||||
|
assert!(error.to_string().contains("proton-login"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identifies_itself_the_way_proton_requires() {
|
fn identifies_itself_the_way_proton_requires() {
|
||||||
|
|||||||
+117
-19
@@ -1,16 +1,22 @@
|
|||||||
//! The stored Proton session: everything needed to come back up unattended.
|
//! The stored Proton session: everything needed to come back up unattended.
|
||||||
//!
|
//!
|
||||||
//! A login is worth persisting whole, because none of it can be re-derived
|
//! A login is worth persisting whole, because none of it can be re-derived
|
||||||
//! without a human: the tokens (the session itself), the mailbox password
|
//! without a human: the tokens (the session itself) and the key passphrases
|
||||||
//! (which rebuilds the key chain on every start — the SDK needs it in memory to
|
//! (which rebuild the key chain on every start — the SDK needs them in memory
|
||||||
//! decrypt anything), and the account's key salts.
|
//! to decrypt anything).
|
||||||
//!
|
//!
|
||||||
//! The salts matter more than they look. `core/v4/keys/salts` requires the
|
//! The passphrases are what the password is for: each is
|
||||||
//! `locked` scope, and only the access token minted by a *password* login
|
//! `bcrypt(mailbox password, key salt)` and unlocks one account key. Keeping
|
||||||
//! carries it; once that token has been rotated through `auth/v4/refresh` — as
|
//! them instead of the password means the store never holds the password
|
||||||
//! it is on every restart after the first — the endpoint answers 403 and the
|
//! itself, which on a single-password account is the login password. They
|
||||||
//! key chain can no longer be unlocked. So the salts are captured at login and
|
//! read the account all the same, so they are no less secret — but they cannot
|
||||||
//! handed back on resume, exactly as the desktop client does it.
|
//! log in, change the password or turn 2FA off.
|
||||||
|
//!
|
||||||
|
//! They are derived at login and only then. Deriving needs the key salts, and
|
||||||
|
//! `core/v4/keys/salts` requires the `locked` scope, which only the access
|
||||||
|
//! token minted by a *password* login carries; once that token has been rotated
|
||||||
|
//! through `auth/v4/refresh` — as it is on every restart after the first — the
|
||||||
|
//! endpoint answers 403.
|
||||||
//!
|
//!
|
||||||
//! Refresh tokens are single-use: a rotation that is not written back leaves a
|
//! Refresh tokens are single-use: a rotation that is not written back leaves a
|
||||||
//! dead token on disk and the next resume fails. Hence the refresh hook in
|
//! dead token on disk and the next resume fails. Hence the refresh hook in
|
||||||
@@ -20,6 +26,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use proton_drive_rs::KeySalt;
|
use proton_drive_rs::KeySalt;
|
||||||
|
use proton_sdk::account::KeyPassphrases;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -33,12 +40,16 @@ pub struct StoredSession {
|
|||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
/// `1` = single password, `2` = dual (Proton's wire values).
|
/// `1` = single password, `2` = dual (Proton's wire values).
|
||||||
pub password_mode: u8,
|
pub password_mode: u8,
|
||||||
/// Mailbox (data) password. `ProtonDriveClient` derives the key chain from
|
/// Key id → the passphrase that unlocks it, derived at login. Empty only
|
||||||
/// it on every construction, so a backup that runs unattended has to hold
|
/// in a session written before they were, which still has the two fields
|
||||||
/// it — there is no token-only mode.
|
/// below.
|
||||||
pub mailbox_password: String,
|
#[serde(default, with = "passphrases")]
|
||||||
/// Captured at login, because a refreshed token may no longer fetch them.
|
pub key_passphrases: KeyPassphrases,
|
||||||
#[serde(default)]
|
/// What a session stored before the passphrases kept instead of them.
|
||||||
|
/// Read once, to derive the passphrases, and never written again.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub mailbox_password: Option<String>,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
pub key_salts: Vec<KeySalt>,
|
pub key_salts: Vec<KeySalt>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +57,7 @@ impl StoredSession {
|
|||||||
pub fn from_session(
|
pub fn from_session(
|
||||||
session: &ProtonApiSession,
|
session: &ProtonApiSession,
|
||||||
tokens: proton_sdk::http::Tokens,
|
tokens: proton_sdk::http::Tokens,
|
||||||
mailbox_password: &str,
|
key_passphrases: KeyPassphrases,
|
||||||
key_salts: Vec<KeySalt>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
session_id: session.session_id().as_str().to_owned(),
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
@@ -60,8 +70,9 @@ impl StoredSession {
|
|||||||
PasswordMode::Single => 1,
|
PasswordMode::Single => 1,
|
||||||
PasswordMode::Dual => 2,
|
PasswordMode::Dual => 2,
|
||||||
},
|
},
|
||||||
mailbox_password: mailbox_password.to_owned(),
|
key_passphrases,
|
||||||
key_salts,
|
mailbox_password: None,
|
||||||
|
key_salts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +93,31 @@ impl StoredSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Passphrases are bytes; the session file is JSON. Each one is kept as
|
||||||
|
/// base64 under its key id.
|
||||||
|
mod passphrases {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use proton_sdk::account::KeyPassphrases;
|
||||||
|
use serde::{de::Error, Deserialize, Deserializer, Serializer};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(value: &KeyPassphrases, out: S) -> Result<S::Ok, S::Error> {
|
||||||
|
out.collect_map(value.iter().map(|(id, passphrase)| (id, STANDARD.encode(passphrase))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<KeyPassphrases, D::Error> {
|
||||||
|
let encoded = BTreeMap::<String, Zeroizing<String>>::deserialize(input)?;
|
||||||
|
let mut passphrases = KeyPassphrases::new();
|
||||||
|
for (id, passphrase) in encoded {
|
||||||
|
let bytes = STANDARD.decode(passphrase.as_bytes()).map_err(D::Error::custom)?;
|
||||||
|
passphrases.insert(id, bytes);
|
||||||
|
}
|
||||||
|
Ok(passphrases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where a session is kept.
|
/// Where a session is kept.
|
||||||
///
|
///
|
||||||
/// The OS keyring is the right home for this and is what the desktop client
|
/// The OS keyring is the right home for this and is what the desktop client
|
||||||
@@ -202,3 +238,65 @@ fn keyring_clear() -> Result<()> {
|
|||||||
Ok(Err(e)) => Err(e.into()),
|
Ok(Err(e)) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn stored(key_passphrases: KeyPassphrases) -> StoredSession {
|
||||||
|
StoredSession {
|
||||||
|
session_id: "session".into(),
|
||||||
|
username: "someone".into(),
|
||||||
|
user_id: "user".into(),
|
||||||
|
access_token: "access".into(),
|
||||||
|
refresh_token: "refresh".into(),
|
||||||
|
scopes: vec!["full".into()],
|
||||||
|
password_mode: 1,
|
||||||
|
key_passphrases,
|
||||||
|
mailbox_password: None,
|
||||||
|
key_salts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrases_survive_the_file_and_the_password_never_reaches_it() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = SessionStore::new(dir.path().join("proton-session.json"));
|
||||||
|
let passphrases =
|
||||||
|
KeyPassphrases::from_iter([("key-1", b"one\0\xff".to_vec()), ("key-2", b"two".to_vec())]);
|
||||||
|
|
||||||
|
// Even a session that still holds the old fields in memory writes
|
||||||
|
// neither of them.
|
||||||
|
let mut session = stored(passphrases.clone());
|
||||||
|
session.mailbox_password = Some("hunter2hunter2".into());
|
||||||
|
store.save(&session).unwrap();
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(store.path()).unwrap();
|
||||||
|
assert!(!json.contains("hunter2"), "{json}");
|
||||||
|
assert!(!json.contains("mailbox_password"), "{json}");
|
||||||
|
assert!(!json.contains("key_salts"), "{json}");
|
||||||
|
|
||||||
|
let loaded = store.load().unwrap().unwrap();
|
||||||
|
assert!(loaded.key_passphrases == passphrases);
|
||||||
|
assert!(loaded.mailbox_password.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_from_before_the_passphrases_still_loads() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("proton-session.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":1,
|
||||||
|
"mailbox_password":"hunter2hunter2",
|
||||||
|
"key_salts":[{"ID":"key-1","KeySalt":"c2FsdHNhbHRzYWx0c2FsdA=="}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = SessionStore::new(path).load().unwrap().unwrap();
|
||||||
|
assert!(loaded.key_passphrases.is_empty());
|
||||||
|
assert_eq!(loaded.mailbox_password.as_deref(), Some("hunter2hunter2"));
|
||||||
|
assert_eq!(loaded.key_salts.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user