mirror of
https://github.com/aljazceru/pubky-core.git
synced 2025-12-31 12:54:35 +01:00
feat(pubky): add recovery file generation and decryption
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod pkarr;
|
||||
pub mod public;
|
||||
pub mod recovery_file;
|
||||
|
||||
81
pubky/src/shared/recovery_file.rs
Normal file
81
pubky/src/shared/recovery_file.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user