diff --git a/Cargo.lock b/Cargo.lock
index 0510c55..92ad0a6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -385,6 +385,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
[[package]]
name = "chrono"
version = "0.4.41"
@@ -408,6 +432,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
+ "zeroize",
]
[[package]]
@@ -1000,6 +1025,7 @@ dependencies = [
"clap_complete",
"colored",
"console",
+ "crossterm",
"crypto-hash",
"ctrlc",
"envx_sdk",
@@ -1021,6 +1047,7 @@ dependencies = [
"serde_json",
"shlex",
"smallvec",
+ "snow",
"tempfile",
"textwrap",
"thiserror 1.0.69",
@@ -2313,6 +2340,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3048,6 +3086,23 @@ dependencies = [
"syn",
]
+[[package]]
+name = "snow"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
+dependencies = [
+ "aes-gcm",
+ "blake2",
+ "chacha20poly1305",
+ "curve25519-dalek",
+ "getrandom 0.3.3",
+ "ring",
+ "rustc_version",
+ "sha2",
+ "subtle",
+]
+
[[package]]
name = "socket2"
version = "0.5.9"
diff --git a/Cargo.toml b/Cargo.toml
index e27e517..2da338d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -74,6 +74,8 @@ once_cell = "1.21.3"
uuid = { version = "^1.8", features = ["v4"] }
shlex = "1.3.0"
rusqlite = { version = "0.32", features = ["bundled"] }
+snow = "0.10"
+crossterm = "0.25"
[dev-dependencies]
tempfile = "3"
diff --git a/README.md b/README.md
index 60eb813..ea1e71a 100644
--- a/README.md
+++ b/README.md
@@ -98,3 +98,14 @@ Options:
This project is licensed under the GPLv3 License. A copy of the GPLv3 License can be found in the [LICENSE](LICENSE) file.
This project uses code from the [Railway's CLIv3](https://github.com/railwayapp/cli), copyright (c) [2023] Railway Corp. The Railway CLI is licensed under the MIT License. A copy of the MIT License can be found in the [attributions/railway/LICENSE](attributions/railway/LICENSE) file.
+
+### Log in on another machine
+
+Run `envx auth link` on your existing machine and keep its terminal open. Run the
+printed `envx auth login ''` command on the new machine, enter its verification
+code on the original machine, then enter your existing identity passphrase on the
+new machine. Links contain only a pairing identifier; identity material travels
+through an encrypted, verified channel. See [pairing and its security contract](docs/auth-pairing.md).
+
+Identity commands are also available under `auth`: `status`, `gen`, `register`,
+and `export`. Existing command spellings remain supported.
diff --git a/docs/auth-pairing.md b/docs/auth-pairing.md
new file mode 100644
index 0000000..19c1765
--- /dev/null
+++ b/docs/auth-pairing.md
@@ -0,0 +1,69 @@
+# Pair an existing identity with another machine
+
+On the machine where envx already works:
+
+```sh
+envx auth link
+```
+
+Keep that terminal open. Copy the printed `envx auth login 'https://…'` command
+and run it on the new machine. The invitation contains a server URL and a random
+session identifier, not your identity or a decryption secret. It can be sent
+through a messaging service; someone with the invitation can race to request
+pairing, but cannot obtain your identity merely by possessing the invitation.
+
+The new machine shows a verification code. Copy that complete code into the
+original machine's waiting terminal. Only enter a code from a terminal you
+control. This explicitly approves that receiving machine. A mismatch cancels
+the transfer before any identity material is sent. If someone else claims your
+invitation first, cancel and generate a fresh one.
+
+The new machine asks for your existing identity passphrase. The passphrase is
+never transferred. Login verifies the identity and authenticates it against the
+server before installing it. Existing identities and key files are not replaced.
+The OS keyring may cache the passphrase, just as it does for `envx gen`; it is
+never saved into `config.json` by login. Run `envx link` to connect a local project
+directory after login.
+
+Both machines need the pairing-capable CLI and API. Pairings expire after ten
+minutes. Ctrl-C cancels a pending transfer. Interrupted transfers can be restarted
+with a fresh invitation. If installation fails after key files are written, the
+passphrase-protected files are retained. A fresh pairing can resume installation
+when those files match the transferred identity exactly; different files are never
+overwritten or deleted. Restoring an identity does not copy machine-local configuration,
+project directory links, aliases or trust pins.
+
+## Security contract
+
+The clients use `Noise_XX_25519_ChaChaPoly_SHA256` via `snow`, with fresh handshake
+keys for every pairing and the server URL and session identifier bound into the prologue. The
+verification code is the complete 256-bit handshake hash. The source releases the
+identity only after the user enters the receiving terminal's exact code. The
+server does not receive that code. Do not accept a code supplied by a stranger.
+
+The server relays bounded opaque handshake messages and an authenticated encrypted
+identity bundle. The original OpenPGP private key remains passphrase-protected
+inside that bundle. The bundle contains no passphrase. A receiver capability is
+generated locally and hashed in server storage; it is not included in the link.
+A database snapshot or invitation alone cannot decrypt a transfer. A malicious
+relay can interrupt or substitute a peer, but substitution changes the verification
+code and must be rejected by the user. This is not protection against a compromised
+endpoint or a user approving an attacker's terminal.
+
+One receiver may claim each invitation. Active sessions are capped at three per
+account, twelve per client IPv4 address or IPv6 /64, and 1,024 globally. Server transitions are atomic, source
+operations require the existing account, receiver operations require its separate
+capability, and acknowledgement or cancellation deletes the relay row. A periodic
+sweep removes expired rows; the ten-minute access deadline is enforced on requests,
+not only by cleanup. Backups may retain ciphertext but have no Noise decryption key.
+
+The new machine receives the same private identity. It has the same authority as
+the original machine; this does not introduce independently revocable device keys.
+
+## Commands
+
+`auth status` checks server authentication. `auth gen`, `auth register`, and
+`auth export` group the existing identity commands. Legacy `envx auth`, `gen`,
+`upload`, and `export` remain supported. Project invites are unchanged.
+
+Protocol references: https://noiseprotocol.org/noise.html and https://docs.rs/snow/0.10.0/.
diff --git a/src/commands/auth/channel.rs b/src/commands/auth/channel.rs
new file mode 100644
index 0000000..538e1cf
--- /dev/null
+++ b/src/commands/auth/channel.rs
@@ -0,0 +1,108 @@
+//! Noise handles key agreement, transcript binding and authenticated encryption.
+use anyhow::{bail, Result};
+const PATTERN: &str = "Noise_XX_25519_ChaChaPoly_SHA256";
+pub struct Handshake(snow::HandshakeState);
+pub struct Channel {
+ state: snow::TransportState,
+ code: String,
+}
+impl Handshake {
+ pub fn new(session: &str, initiator: bool) -> Result {
+ let builder = snow::Builder::new(PATTERN.parse()?);
+ let pair = builder.generate_keypair()?;
+ let prologue = format!("envx-auth-pairing-v1:{session}");
+ let builder = builder
+ .local_private_key(&pair.private)?
+ .prologue(prologue.as_bytes())?;
+ Ok(Self(if initiator {
+ builder.build_initiator()?
+ } else {
+ builder.build_responder()?
+ }))
+ }
+ pub fn write(&mut self) -> Result {
+ let mut output = [0u8; 1024];
+ let len = self.0.write_message(&[], &mut output)?;
+ Ok(hex::encode(&output[..len]))
+ }
+ pub fn read(&mut self, message: &str) -> Result<()> {
+ if message.len() > 2048 {
+ bail!("Oversized pairing handshake");
+ }
+ let mut output = [0u8; 1024];
+ if self.0.read_message(&hex::decode(message)?, &mut output)? != 0 {
+ bail!("Unexpected handshake payload");
+ }
+ Ok(())
+ }
+ pub fn finish(self) -> Result {
+ if !self.0.is_handshake_finished() {
+ bail!("Incomplete handshake");
+ }
+ // Compare the complete transcript hash, not a grindable short numeric code.
+ let code = hex::encode(self.0.get_handshake_hash());
+ Ok(Channel {
+ state: self.0.into_transport_mode()?,
+ code,
+ })
+ }
+}
+impl Channel {
+ pub fn code(&self) -> &str {
+ &self.code
+ }
+ pub fn encrypt(&mut self, plaintext: &[u8]) -> Result {
+ if plaintext.len() > 65000 {
+ bail!("Identity is too large to transfer");
+ }
+ let mut output = vec![0u8; 65535];
+ let len = self.state.write_message(plaintext, &mut output)?;
+ Ok(hex::encode(&output[..len]))
+ }
+ pub fn decrypt(&mut self, ciphertext: &str) -> Result> {
+ if ciphertext.len() > 131070 {
+ bail!("Oversized pairing payload");
+ }
+ let mut output = vec![0u8; 65535];
+ let len = self
+ .state
+ .read_message(&hex::decode(ciphertext)?, &mut output)?;
+ output.truncate(len);
+ Ok(output)
+ }
+}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn handshake(id: &str) -> (Channel, Channel) {
+ let mut a = Handshake::new(id, true).unwrap();
+ let mut b = Handshake::new(id, false).unwrap();
+ b.read(&a.write().unwrap()).unwrap();
+ a.read(&b.write().unwrap()).unwrap();
+ b.read(&a.write().unwrap()).unwrap();
+ (a.finish().unwrap(), b.finish().unwrap())
+ }
+ #[test]
+ fn authenticated_transport_and_peer_substitution() {
+ let (mut a, mut b) = handshake("session-one");
+ assert_eq!(a.code(), b.code());
+ let (c, _) = handshake("session-one");
+ assert_ne!(a.code(), c.code());
+ let ciphertext = a.encrypt(b"synthetic identity").unwrap();
+ assert!(!ciphertext.contains("synthetic identity"));
+ assert_eq!(b.decrypt(&ciphertext).unwrap(), b"synthetic identity");
+ assert!(b.decrypt(&ciphertext).is_err());
+ let mut wrong = Handshake::new("other-session", false).unwrap();
+ let mut source = Handshake::new("session", true).unwrap();
+ wrong.read(&source.write().unwrap()).unwrap();
+ assert!(source.read(&wrong.write().unwrap()).is_err());
+ }
+ #[test]
+ fn modified_ciphertext_fails_closed() {
+ let (mut a, mut b) = handshake("session");
+ let encrypted = a.encrypt(b"synthetic identity").unwrap();
+ let mut bytes = hex::decode(encrypted).unwrap();
+ bytes[0] ^= 1;
+ assert!(b.decrypt(&hex::encode(bytes)).is_err());
+ }
+}
diff --git a/src/commands/auth/identity.rs b/src/commands/auth/identity.rs
new file mode 100644
index 0000000..070ccbf
--- /dev/null
+++ b/src/commands/auth/identity.rs
@@ -0,0 +1,262 @@
+use crate::utils::{config::Config, key::Key, rpgp::get_vault_location};
+use anyhow::{bail, Context, Result};
+use pgp::{
+ composed::{
+ ArmorOptions, Deserializable, MessageBuilder, SignedPublicKey,
+ SignedSecretKey,
+ },
+ crypto::hash::HashAlgorithm,
+ types::KeyDetails,
+};
+use serde::{Deserialize, Serialize};
+use std::{fs, io::Write};
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Bundle {
+ version: u8,
+ user_id: String,
+ fingerprint: String,
+ secret_key: String,
+}
+impl Bundle {
+ pub fn from_key(key: &Key) -> Result {
+ let bundle = Self {
+ version: 1,
+ user_id: key.uuid.clone().context("Identity is not registered")?,
+ fingerprint: key.fingerprint.clone(),
+ secret_key: key.secret_key_str()?,
+ };
+ bundle.parse()?;
+ Ok(bundle)
+ }
+ fn parse(&self) -> Result {
+ if self.version != 1 || self.secret_key.len() > 60000 {
+ bail!("Unsupported identity bundle");
+ }
+ uuid::Uuid::parse_str(&self.user_id).context("Invalid account ID")?;
+ let (secret, _) = SignedSecretKey::from_string(&self.secret_key)
+ .context("Invalid transferred identity")?;
+ secret.verify().context("Invalid identity self-signature")?;
+ if hex::encode(secret.fingerprint().as_bytes()) != self.fingerprint {
+ bail!("Identity fingerprint mismatch");
+ }
+ if !secret.primary_key.secret_params().is_encrypted()
+ || secret
+ .secret_subkeys
+ .iter()
+ .any(|s| !s.key.secret_params().is_encrypted())
+ {
+ bail!("Only passphrase-protected identities can be transferred");
+ }
+ Ok(secret)
+ }
+ pub fn validate(&self, password: &str) -> Result<(Key, String, String)> {
+ crate::utils::key::validate_passphrase_not_empty(password)?;
+ let secret = self.parse()?;
+ secret
+ .primary_key
+ .unlock(&password.into(), |_, _| Ok(()))?
+ .context("Passphrase does not unlock transferred key")?;
+ for sub in &secret.secret_subkeys {
+ sub.key
+ .unlock(&password.into(), |_, _| Ok(()))?
+ .context("Passphrase does not unlock transferred subkey")?;
+ }
+ let public = SignedPublicKey::from(secret.clone());
+ let public_armor = public.to_armored_string(ArmorOptions::default())?;
+ let mut builder =
+ MessageBuilder::from_bytes("", chrono::Utc::now().to_string());
+ builder.sign(
+ &secret.primary_key,
+ password.into(),
+ HashAlgorithm::Sha3_512,
+ );
+ let signature = builder
+ .to_armored_string(rand::rngs::OsRng, ArmorOptions::default())?;
+ let token = crate::utils::auth_token::AuthToken::new(
+ self.user_id.clone(),
+ signature,
+ )
+ .to_string();
+ let user = public
+ .details
+ .users
+ .first()
+ .context("Identity has no user ID")?;
+ let key = Key {
+ fingerprint: self.fingerprint.clone(),
+ uuid: Some(self.user_id.clone()),
+ note: "Primary Key".into(),
+ primary_user_id: String::from_utf8_lossy(user.id.id()).into_owned(),
+ pubkey_only: Some(false),
+ };
+ Ok((key, public_armor, token))
+ }
+ pub fn install(
+ &self,
+ config: &mut Config,
+ server: &str,
+ key: Key,
+ public: &str,
+ ) -> Result<()> {
+ let vault = get_vault_location()?;
+ fs::create_dir_all(&vault)?;
+ let destination = vault.join(&key.fingerprint);
+ if destination.exists() {
+ // Recover a prior publication whose final settings write failed.
+ if fs::read_to_string(destination.join("private.key"))?
+ == self.secret_key
+ && fs::read_to_string(destination.join("public.key"))? == public
+ {
+ return config.install_identity(key, server);
+ }
+ bail!("Different key files already exist; login will not overwrite them");
+ }
+ let temporary =
+ vault.join(format!(".pairing-{}", uuid::Uuid::new_v4()));
+ let mut directory = fs::DirBuilder::new();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::DirBuilderExt;
+ directory.mode(0o700);
+ }
+ directory.create(&temporary)?;
+ let result = (|| -> Result<()> {
+ for (name, data) in [
+ ("private.key", self.secret_key.as_str()),
+ ("public.key", public),
+ ] {
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let mut file = options.open(temporary.join(name))?;
+ file.write_all(data.as_bytes())?;
+ file.sync_all()?;
+ }
+ fs::rename(&temporary, &destination)?;
+ // Publish settings last. A failed settings write leaves recoverable encrypted files.
+ config.install_identity(key, server)?;
+ Ok(())
+ })();
+ if temporary.exists() {
+ let _ = fs::remove_dir_all(&temporary);
+ }
+ result
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Bundle;
+ use pgp::{
+ composed::{ArmorOptions, KeyType, SecretKeyParamsBuilder},
+ types::KeyDetails,
+ };
+ #[test]
+ fn bundle_requires_matching_protected_key_and_correct_password() {
+ let secret = SecretKeyParamsBuilder::default()
+ .key_type(KeyType::Ed25519)
+ .can_sign(true)
+ .primary_user_id("synthetic".into())
+ .passphrase(Some("fixture-passphrase".into()))
+ .build()
+ .unwrap()
+ .generate(rand::rngs::OsRng)
+ .unwrap()
+ .sign(rand::rngs::OsRng, &"fixture-passphrase".into())
+ .unwrap();
+ let mut bundle = Bundle {
+ version: 1,
+ user_id: uuid::Uuid::new_v4().to_string(),
+ fingerprint: hex::encode(secret.fingerprint().as_bytes()),
+ secret_key: secret
+ .to_armored_string(ArmorOptions::default())
+ .unwrap(),
+ };
+ assert!(bundle.validate("fixture-passphrase").is_ok());
+ assert!(bundle.validate("wrong-password").is_err());
+ bundle.fingerprint = "../../escape".into();
+ assert!(bundle.validate("fixture-passphrase").is_err());
+ }
+ use crate::utils::{config::Config, key::Key};
+ #[test]
+ fn interrupted_install_can_resume_only_with_identical_key_files() {
+ let mut config = Config::load().unwrap();
+ let key = Key {
+ fingerprint: "c".repeat(40),
+ note: "test".into(),
+ primary_user_id: "test".into(),
+ pubkey_only: Some(false),
+ uuid: Some(uuid::Uuid::new_v4().to_string()),
+ };
+ let directory = crate::utils::rpgp::get_vault_location()
+ .unwrap()
+ .join(&key.fingerprint);
+ std::fs::create_dir_all(&directory).unwrap();
+ std::fs::write(directory.join("private.key"), "protected-fixture")
+ .unwrap();
+ std::fs::write(directory.join("public.key"), "public-fixture").unwrap();
+ let mut bundle = Bundle {
+ version: 1,
+ user_id: key.uuid.clone().unwrap(),
+ fingerprint: key.fingerprint.clone(),
+ secret_key: "different-fixture".into(),
+ };
+ assert!(bundle
+ .install(
+ &mut config,
+ "https://api.envx.sh/",
+ key.clone(),
+ "public-fixture"
+ )
+ .is_err());
+ assert!(Config::load().unwrap().primary_key.is_none());
+ bundle.secret_key = "protected-fixture".into();
+ bundle
+ .install(
+ &mut config,
+ "https://api.envx.sh/",
+ key.clone(),
+ "public-fixture",
+ )
+ .unwrap();
+ assert_eq!(
+ Config::load().unwrap().primary_key.unwrap().fingerprint,
+ key.fingerprint
+ );
+ assert_eq!(
+ std::fs::read_to_string(directory.join("private.key")).unwrap(),
+ "protected-fixture"
+ );
+ }
+ #[test]
+ fn concurrent_identity_install_never_replaces_an_existing_account() {
+ let mut first = Config::load().unwrap();
+ let mut stale = Config::load().unwrap();
+ let key = Key {
+ fingerprint: "a".repeat(40),
+ note: "test".into(),
+ primary_user_id: "test".into(),
+ pubkey_only: Some(false),
+ uuid: Some(uuid::Uuid::new_v4().to_string()),
+ };
+ first
+ .install_identity(key.clone(), "https://api.envx.sh/")
+ .unwrap();
+ let mut other = key.clone();
+ other.fingerprint = "b".repeat(40);
+ assert!(stale
+ .install_identity(other, "https://api.envx.sh/")
+ .is_err());
+ assert_eq!(
+ Config::load().unwrap().primary_key.unwrap().fingerprint,
+ key.fingerprint
+ );
+ assert!(stale.primary_key.is_none());
+ }
+}
diff --git a/src/commands/auth/mod.rs b/src/commands/auth/mod.rs
new file mode 100644
index 0000000..56163c0
--- /dev/null
+++ b/src/commands/auth/mod.rs
@@ -0,0 +1,61 @@
+use super::*;
+mod channel;
+mod identity;
+mod pairing;
+mod status;
+#[cfg(test)]
+mod tests;
+
+/// Manage your identity and securely pair another machine
+#[derive(Parser)]
+pub struct Args {
+ #[command(subcommand)]
+ command: Option,
+ /// Show diagnostics for the legacy authentication check
+ #[arg(long)]
+ verbose: bool,
+ #[arg(short, long)]
+ debug: bool,
+}
+#[derive(clap::Subcommand)]
+enum Commands {
+ /// Verify authentication with the server
+ Status(status::Args),
+ /// Generate a new identity
+ Gen(super::gen::Args),
+ /// Register an existing local key
+ Register(super::upload::Args),
+ /// Export a public or secret identity key
+ Export(super::export::Args),
+ /// Create a pairing invitation and approve the receiving machine
+ Link,
+ /// Receive your existing identity from another machine
+ Login { link: String },
+}
+pub async fn command(
+ args: Args,
+ config: &mut crate::utils::config::Config,
+) -> anyhow::Result<()> {
+ match args.command {
+ Some(Commands::Link) => pairing::link(config).await,
+ Some(Commands::Login { link }) => pairing::login(config, &link).await,
+ Some(Commands::Gen(args)) => super::gen::command(args, config).await,
+ Some(Commands::Register(args)) => {
+ super::upload::command(args, config).await
+ }
+ Some(Commands::Export(args)) => {
+ super::export::command(args, config).await
+ }
+ Some(Commands::Status(args)) => status::command(args, config).await,
+ None => {
+ status::command(
+ status::Args {
+ verbose: args.verbose,
+ debug: args.debug,
+ },
+ config,
+ )
+ .await
+ }
+ }
+}
diff --git a/src/commands/auth/pairing.rs b/src/commands/auth/pairing.rs
new file mode 100644
index 0000000..5601b22
--- /dev/null
+++ b/src/commands/auth/pairing.rs
@@ -0,0 +1,370 @@
+use super::{channel::Handshake, identity::Bundle};
+use crate::utils::{
+ config::Config, key::UnlockedKey, prompt::require_interactive,
+};
+use anyhow::{bail, Context, Result};
+use serde::{de::DeserializeOwned, Deserialize};
+use serde_json::json;
+use std::time::Duration;
+use url::Url;
+use uuid::Uuid;
+
+const PREFIX: &str = "envx-pair-v1=";
+#[derive(Deserialize)]
+struct Created {
+ id: String,
+}
+#[derive(Deserialize)]
+struct Reply {
+ phase: u8,
+ message: Option,
+}
+struct Relay<'a> {
+ http: reqwest::Client,
+ base: Url,
+ id: String,
+ token: Option,
+ source_key: Option<&'a UnlockedKey>,
+ auth: std::sync::Mutex