diff --git a/pubky-common/Cargo.toml b/pubky-common/Cargo.toml index 042474c..0a9df3b 100644 --- a/pubky-common/Cargo.toml +++ b/pubky-common/Cargo.toml @@ -15,7 +15,7 @@ rand = "0.8.5" thiserror = "1.0.60" postcard = { version = "1.0.8", features = ["alloc"] } serde = { version = "1.0.204", features = ["derive"] } -crypto_secretbox = "0.1.1" +crypto_secretbox = { version = "0.1.1", features = ["std"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.69" diff --git a/pubky-common/src/crypto.rs b/pubky-common/src/crypto.rs index 5e57f51..a7adea5 100644 --- a/pubky-common/src/crypto.rs +++ b/pubky-common/src/crypto.rs @@ -30,10 +30,7 @@ pub fn random_bytes() -> [u8; N] { arr } -pub fn encrypt( - plain_text: &[u8], - encryption_key: &[u8; 32], -) -> Result, crypto_secretbox::Error> { +pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result, Error> { let cipher = XSalsa20Poly1305::new(encryption_key.into()); let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng); // unique per message let ciphertext = cipher.encrypt(&nonce, plain_text)?; @@ -45,12 +42,16 @@ pub fn encrypt( Ok(out) } -pub fn decrypt( - bytes: &[u8], - encryption_key: &[u8; 32], -) -> Result, crypto_secretbox::Error> { +pub fn decrypt(bytes: &[u8], encryption_key: &[u8; 32]) -> Result, Error> { let cipher = XSalsa20Poly1305::new(encryption_key.into()); - cipher.decrypt(bytes[..24].into(), &bytes[24..]) + + Ok(cipher.decrypt(bytes[..24].into(), &bytes[24..])?) +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + SecretBox(#[from] crypto_secretbox::Error), } #[cfg(test)] diff --git a/pubky/Cargo.toml b/pubky/Cargo.toml index 392402e..149b6be 100644 --- a/pubky/Cargo.toml +++ b/pubky/Cargo.toml @@ -17,6 +17,7 @@ url = "2.5.2" bytes = "1.6.1" pubky-common = { version = "0.1.0", path = "../pubky-common" } +argon2 = { version = "0.5.3", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] pkarr = { version="2.1.0", features = ["async"] } diff --git a/pubky/src/error.rs b/pubky/src/error.rs index 501168d..e27814b 100644 --- a/pubky/src/error.rs +++ b/pubky/src/error.rs @@ -12,6 +12,22 @@ pub enum Error { #[error("Generic error: {0}")] Generic(String), + #[error("Could not resolve endpoint for {0}")] + ResolveEndpoint(String), + + // === Recovery file == + #[error("Recovery file should start with a spec line, followed by a new line character")] + RecoveryFileMissingSpecLine, + + #[error("Recovery file should start with a spec line, followed by a new line character")] + RecoveryFileVersionNotSupported, + + #[error("Recovery file should contain an encrypted secret key after the new line character")] + RecoverFileMissingEncryptedSecretKey, + + #[error("Recovery file encrypted secret key should be 32 bytes, got {0}")] + RecoverFileInvalidSecretKeyLength(usize), + // === Transparent === #[error(transparent)] Dns(#[from] SimpleDnsError), @@ -28,8 +44,11 @@ pub enum Error { #[error(transparent)] Session(#[from] pubky_common::session::Error), - #[error("Could not resolve endpoint for {0}")] - ResolveEndpoint(String), + #[error(transparent)] + Crypto(#[from] pubky_common::crypto::Error), + + #[error(transparent)] + Argon(#[from] argon2::Error), } #[cfg(target_arch = "wasm32")] diff --git a/pubky/src/native.rs b/pubky/src/native.rs index 783bce6..620c94f 100644 --- a/pubky/src/native.rs +++ b/pubky/src/native.rs @@ -10,7 +10,11 @@ use pubky_common::session::Session; use reqwest::{Method, RequestBuilder, Response}; use url::Url; -use crate::{error::Result, PubkyClient}; +use crate::{ + error::Result, + shared::recovery_file::{create_recovery_file, decrypt_recovery_file}, + PubkyClient, +}; static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); @@ -99,6 +103,15 @@ impl PubkyClient { pub async fn delete>(&self, url: T) -> Result<()> { self.inner_delete(url).await } + + // === Helpers === + + pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> { + create_recovery_file(keypair, passphrase) + } + pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { + decrypt_recovery_file(recovery_file, passphrase) + } } // === Internals === diff --git a/pubky/src/shared/mod.rs b/pubky/src/shared/mod.rs index ec9bd27..49f11bd 100644 --- a/pubky/src/shared/mod.rs +++ b/pubky/src/shared/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod pkarr; pub mod public; +pub mod recovery_file; diff --git a/pubky/src/shared/recovery_file.rs b/pubky/src/shared/recovery_file.rs new file mode 100644 index 0000000..5f500ff --- /dev/null +++ b/pubky/src/shared/recovery_file.rs @@ -0,0 +1,81 @@ +use argon2::Argon2; +use pkarr::Keypair; +use pubky_common::crypto::{decrypt, encrypt}; + +use crate::{ + error::{Error, Result}, + PubkyClient, +}; + +static SPEC_NAME: &str = "recovery"; +static SPEC_LINE: &str = "pubky.org/recovery"; + +pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { + let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?; + + let mut split = recovery_file.split(|byte| byte == &10); + + match split.next() { + Some(bytes) => { + if !(bytes.starts_with(SPEC_LINE.as_bytes()) + || bytes.starts_with(b"pkarr.org/recovery")) + { + return Err(Error::RecoveryFileVersionNotSupported); + } + } + None => return Err(Error::RecoveryFileMissingSpecLine), + }; + + if let Some(encrypted) = split.next() { + let decrypted = decrypt(encrypted, &encryption_key)?; + let length = decrypted.len(); + let secret_key: [u8; 32] = decrypted + .try_into() + .map_err(|_| Error::RecoverFileInvalidSecretKeyLength(length))?; + + return Ok(Keypair::from_secret_key(&secret_key)); + }; + + Err(Error::RecoverFileMissingEncryptedSecretKey) +} + +pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> { + let encryption_key = recovery_file_encryption_key_from_passphrase(passphrase)?; + let secret_key = keypair.secret_key(); + + let encrypted_secret_key = encrypt(&secret_key, &encryption_key)?; + + let mut out = Vec::with_capacity(SPEC_LINE.len() + 1 + encrypted_secret_key.len()); + + out.extend_from_slice(SPEC_LINE.as_bytes()); + out.extend_from_slice(b"\n"); + out.extend_from_slice(&encrypted_secret_key); + + Ok(out) +} + +fn recovery_file_encryption_key_from_passphrase(passphrase: &str) -> Result<[u8; 32]> { + let argon2id = Argon2::default(); + + let mut out = [0; 32]; + + argon2id.hash_password_into(passphrase.as_bytes(), SPEC_NAME.as_bytes(), &mut out)?; + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_recovery_file() { + let passphrase = "very secure password"; + let keypair = Keypair::random(); + + let recovery_file = PubkyClient::create_recovery_file(&keypair, passphrase).unwrap(); + let recovered = PubkyClient::decrypt_recovery_file(&recovery_file, passphrase).unwrap(); + + assert_eq!(recovered.public_key(), keypair.public_key()); + } +}