feat(pubky): add recovery file generation and decryption

This commit is contained in:
nazeh
2024-08-03 16:33:38 +03:00
parent 5ee464a5b4
commit 11334bf81c
7 changed files with 129 additions and 13 deletions

View File

@@ -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"

View File

@@ -30,10 +30,7 @@ pub fn random_bytes<const N: usize>() -> [u8; N] {
arr
}
pub fn encrypt(
plain_text: &[u8],
encryption_key: &[u8; 32],
) -> Result<Vec<u8>, crypto_secretbox::Error> {
pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result<Vec<u8>, 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<Vec<u8>, crypto_secretbox::Error> {
pub fn decrypt(bytes: &[u8], encryption_key: &[u8; 32]) -> Result<Vec<u8>, 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)]

View File

@@ -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"] }

View File

@@ -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")]

View File

@@ -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<T: TryInto<Url>>(&self, url: T) -> Result<()> {
self.inner_delete(url).await
}
// === Helpers ===
pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result<Vec<u8>> {
create_recovery_file(keypair, passphrase)
}
pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result<Keypair> {
decrypt_recovery_file(recovery_file, passphrase)
}
}
// === Internals ===

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod pkarr;
pub mod public;
pub mod recovery_file;

View File

@@ -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<Keypair> {
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<Vec<u8>> {
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());
}
}