Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement KeyObfuscator for Deterministic Encryption of storage keys. #32

Merged
merged 2 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ build = "build.rs"

[features]
default = ["lnurl-auth"]
lnurl-auth = ["dep:bitcoin", "dep:url", "dep:base64", "dep:serde", "dep:serde_json", "reqwest/json"]
lnurl-auth = ["dep:bitcoin", "dep:url", "dep:serde", "dep:serde_json", "reqwest/json"]

[dependencies]
prost = "0.11.6"
Expand All @@ -23,14 +23,17 @@ rand = "0.8.5"
async-trait = "0.1.77"
bitcoin = { version = "0.32.2", default-features = false, features = ["std", "rand-std"], optional = true }
url = { version = "2.5.0", default-features = false, optional = true }
base64 = { version = "0.21.7", default-features = false, optional = true }
base64 = { version = "0.21.7", default-features = false}
serde = { version = "1.0.196", default-features = false, features = ["serde_derive"], optional = true }
serde_json = { version = "1.0.113", default-features = false, optional = true }

bitcoin_hashes = "0.14.0"
G8XSU marked this conversation as resolved.
Show resolved Hide resolved

[target.'cfg(genproto)'.build-dependencies]
prost-build = { version = "0.11.3" }
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls", "blocking"] }

[dev-dependencies]
mockito = "0.31.1"
proptest = "1.1.0"
tokio = { version = "1.22.0", features = ["macros"]}
8 changes: 6 additions & 2 deletions src/crypto/chacha20poly1305.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ mod real_chachapoly {
self.finish_and_get_tag(out_tag);
}

pub fn decrypt_inplace(&mut self, input_output: &mut [u8], tag: &[u8]) -> bool {
pub fn decrypt_inplace(&mut self, input_output: &mut [u8], tag: &[u8]) -> Result<(), ()> {
assert!(self.finished == false);
self.decrypt_in_place(input_output);
self.finish_and_check_tag(tag)
if self.finish_and_check_tag(tag) {
Ok(())
} else {
Err(())
}
}

// Encrypt `input_output` in-place. To finish and calculate the tag, use `finish_and_get_tag`
Expand Down
184 changes: 184 additions & 0 deletions src/util/key_obfuscator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
use std::io::{Error, ErrorKind};
G8XSU marked this conversation as resolved.
Show resolved Hide resolved

use base64::prelude::BASE64_STANDARD_NO_PAD;
use base64::Engine;
use bitcoin_hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine};

use crate::crypto::chacha20poly1305::ChaCha20Poly1305;

/// [`KeyObfuscator`] is a utility to obfuscate and deobfuscate storage
/// keys to be used for VSS operations.
///
/// It provides client-side deterministic encryption of given keys using ChaCha20-Poly1305.
pub struct KeyObfuscator {
obfuscation_key: [u8; 32],
hashing_key: [u8; 32],
}

impl KeyObfuscator {
/// Constructs a new instance.
pub fn new(obfuscation_master_key: [u8; 32]) -> KeyObfuscator {
let (obfuscation_key, hashing_key) =
Self::derive_obfuscation_and_hashing_keys(&obfuscation_master_key);
Self { obfuscation_key, hashing_key }
}
}

const TAG_LENGTH: usize = 16;
const NONCE_LENGTH: usize = 12;

impl KeyObfuscator {
/// Obfuscates the given key.
pub fn obfuscate(&self, key: &str) -> String {
let key_bytes = key.as_bytes();
let mut ciphertext =
Vec::with_capacity(key_bytes.len() + TAG_LENGTH + NONCE_LENGTH + TAG_LENGTH);
ciphertext.extend_from_slice(&key_bytes);

// Encrypt key in-place using a synthetic nonce.
let (mut nonce, tag) = self.encrypt(&mut ciphertext, key.as_bytes());

// Wrap the synthetic nonce to store along-side key.
let (_, nonce_tag) = self.encrypt(&mut nonce, &ciphertext);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just to recap (simplified) what happened here so far:

  1. We generate a nonce N from the plaintext storage key P, where basically N = hash(P).
  2. We encrypt the plaintext P using the nonce N, getting the ciphertext C.
  3. We encrypt that nonce N using the ciphertext C as the nonce.

Is this approach cryptographically sound? Are there any risks associated with it?

Copy link
Collaborator Author

@G8XSU G8XSU Aug 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes,
with minor correction, we use N2 = hash(ciphertext) to encrypt nonce N.

* We generate a nonce N from the plaintext storage key P, where basically N = hash(P).
* We encrypt the plaintext P using the nonce N, getting the ciphertext C.
* We encrypt that nonce N using the nonce N2 = hash (ciphertext-C) to obtain encrypted-nonce 'E'.
* We append ciphertext C and encrypted-nonce E , to store them together by sending in request to vss-server.

With using ChaCha20Poly1305 here, we need to take care of two things mainly:

  1. Don't re-use nonces for different plaintext. (Addressing Concern 1: We don't re-use it for different plaintexts, we do re-use it for same plaintext.)
  2. Since it is possible to guess some of the plaintexts like channel-manager, server shouldn't have access to enough information to be able to reverse the deterministic encryption.

For e.g. if we store unencrypted nonce N with cipher-text, and known plain text it might be possible to reverse it. Hence we store the encrypted nonce.

Addressing Concern 2: For nonce encryption we use hash(ciphertext) as nonce which isn't stored alongside data in server hence it should be safe.

debug_assert_eq!(tag.len(), TAG_LENGTH);
ciphertext.extend_from_slice(&tag);
G8XSU marked this conversation as resolved.
Show resolved Hide resolved
debug_assert_eq!(nonce.len(), NONCE_LENGTH);
ciphertext.extend_from_slice(&nonce);
debug_assert_eq!(nonce_tag.len(), TAG_LENGTH);
ciphertext.extend_from_slice(&nonce_tag);
BASE64_STANDARD_NO_PAD.encode(ciphertext)
}

/// Deobfuscates the given obfuscated_key.
pub fn deobfuscate(&self, obfuscated_key: &str) -> Result<String, Error> {
let obfuscated_key_bytes = BASE64_STANDARD_NO_PAD.decode(obfuscated_key).map_err(|e| {
let msg = format!(
"Failed to decode base64 while deobfuscating key: {}, Error: {}",
obfuscated_key, e
);
Error::new(ErrorKind::InvalidData, msg)
})?;

if obfuscated_key_bytes.len() < TAG_LENGTH + NONCE_LENGTH + TAG_LENGTH {
let msg = format!(
"Failed to deobfuscate, obfuscated_key was of invalid length. \
Obfuscated key should at least have {} bytes, found: {}. Key: {}.",
(TAG_LENGTH + NONCE_LENGTH + TAG_LENGTH),
obfuscated_key_bytes.len(),
obfuscated_key
);
return Err(Error::new(ErrorKind::InvalidData, msg));
}

// Split obfuscated_key into ciphertext, tag(for ciphertext), wrapped_nonce, tag(for wrapped_nonce).
let (ciphertext, remaining) = obfuscated_key_bytes
.split_at(obfuscated_key_bytes.len() - TAG_LENGTH - NONCE_LENGTH - TAG_LENGTH);
let (tag, remaining) = remaining.split_at(TAG_LENGTH);
let (wrapped_nonce_bytes, wrapped_nonce_tag) = remaining.split_at(NONCE_LENGTH);
debug_assert_eq!(wrapped_nonce_tag.len(), TAG_LENGTH);

G8XSU marked this conversation as resolved.
Show resolved Hide resolved
// Unwrap wrapped_nonce to get nonce.
let mut wrapped_nonce = [0u8; NONCE_LENGTH];
wrapped_nonce.clone_from_slice(&wrapped_nonce_bytes);
self.decrypt(&mut wrapped_nonce, ciphertext, wrapped_nonce_tag).map_err(|_| {
let msg = format!(
"Failed to decrypt wrapped nonce, for key: {}, Invalid Tag.",
obfuscated_key
);
Error::new(ErrorKind::InvalidData, msg)
})?;

// Decrypt ciphertext using nonce.
let mut cipher = ChaCha20Poly1305::new(&self.obfuscation_key, &wrapped_nonce, &[]);
let mut ciphertext = ciphertext.to_vec();
cipher.decrypt_inplace(&mut ciphertext, tag).map_err(|_| {
let msg = format!("Failed to decrypt key: {}, Invalid Tag.", obfuscated_key);
Error::new(ErrorKind::InvalidData, msg)
})?;

let original_key = String::from_utf8(ciphertext).map_err(|e| {
let msg = format!(
"Input was not valid utf8 while deobfuscating key: {}, Error: {}",
obfuscated_key, e
);
Error::new(ErrorKind::InvalidData, msg)
})?;
Ok(original_key)
}

/// Encrypts the given plaintext in-place using a HMAC generated nonce.
fn encrypt(
&self, mut plaintext: &mut [u8], initial_nonce_material: &[u8],
) -> ([u8; 12], [u8; 16]) {
let nonce = self.generate_synthetic_nonce(initial_nonce_material);
let mut cipher = ChaCha20Poly1305::new(&self.obfuscation_key, &nonce, &[]);
let mut tag = [0u8; TAG_LENGTH];
cipher.encrypt_inplace(&mut plaintext, &mut tag);
(nonce, tag)
}

/// Decrypts the given ciphertext in-place using a HMAC generated nonce.
fn decrypt(
&self, mut ciphertext: &mut [u8], initial_nonce_material: &[u8], tag: &[u8],
) -> Result<(), ()> {
let nonce = self.generate_synthetic_nonce(initial_nonce_material);
let mut cipher = ChaCha20Poly1305::new(&self.obfuscation_key, &nonce, &[]);
cipher.decrypt_inplace(&mut ciphertext, tag)
}

/// Generate a HMAC based nonce using provided `initial_nonce_material`.
fn generate_synthetic_nonce(&self, initial_nonce_material: &[u8]) -> [u8; 12] {
let hmac = Self::hkdf(&self.hashing_key, initial_nonce_material);
let mut nonce = [0u8; NONCE_LENGTH];
nonce[4..].copy_from_slice(&hmac[..8]);
nonce
}

/// Derives the obfuscation and hashing keys from the master key.
fn derive_obfuscation_and_hashing_keys(
obfuscation_master_key: &[u8; 32],
) -> ([u8; 32], [u8; 32]) {
let prk = Self::hkdf(obfuscation_master_key, "pseudo_random_key".as_bytes());
let k1 = Self::hkdf(&prk, "obfuscation_key".as_bytes());
let k2 = Self::hkdf(&prk, &[&k1[..], "hashing_key".as_bytes()].concat());
(k1, k2)
G8XSU marked this conversation as resolved.
Show resolved Hide resolved
}
fn hkdf(initial_key_material: &[u8], salt: &[u8]) -> [u8; 32] {
let mut engine = HmacEngine::<sha256::Hash>::new(salt);
engine.input(initial_key_material);
Hmac::from_engine(engine).to_byte_array()
}
}

#[cfg(test)]
mod tests {
use crate::util::key_obfuscator::KeyObfuscator;

#[test]
fn obfuscate_deobfuscate_deterministic() {
let obfuscation_master_key = [42u8; 32];
let key_obfuscator = KeyObfuscator::new(obfuscation_master_key);
let expected_key = "a_semi_secret_key";
let obfuscated_key = key_obfuscator.obfuscate(expected_key);

let actual_key = key_obfuscator.deobfuscate(obfuscated_key.as_str()).unwrap();
assert_eq!(actual_key, expected_key);
assert_eq!(
obfuscated_key,
"cMoet5WTvl0nYds+VW7JPCtXUq24DtMG2dR9apAi/T5jy8eNIEyDrUAJBS4geeUuX+XGXPqlizIByOip2g"
);
}

use proptest::prelude::*;

proptest! {
#[test]
fn obfuscate_deobfuscate_proptest(expected_key in "[a-zA-Z0-9_!@#,;:%\\s\\*\\$\\^&\\(\\)\\[\\]\\{\\}\\.]*", obfuscation_master_key in any::<[u8; 32]>()) {
let key_obfuscator = KeyObfuscator::new(obfuscation_master_key);
let obfuscated_key = key_obfuscator.obfuscate(&expected_key);
let actual_key = key_obfuscator.deobfuscate(obfuscated_key.as_str()).unwrap();
assert_eq!(actual_key, expected_key);
}
}
}
5 changes: 5 additions & 0 deletions src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ pub mod storable_builder;

/// Contains retry utilities.
pub mod retry;

/// Contains [`KeyObfuscator`] utility.
G8XSU marked this conversation as resolved.
Show resolved Hide resolved
///
/// [`KeyObfuscator`]: key_obfuscator::KeyObfuscator
pub mod key_obfuscator;
14 changes: 7 additions & 7 deletions src/util/storable_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ impl<T: EntropySource> StorableBuilder<T> {
let mut cipher =
ChaCha20Poly1305::new(&self.data_encryption_key, &encryption_metadata.nonce, &[]);

if cipher.decrypt_inplace(&mut storable.data, encryption_metadata.tag.borrow()) {
let data_blob = PlaintextBlob::decode(&storable.data[..])
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
Ok((data_blob.value, data_blob.version))
} else {
Err(Error::new(ErrorKind::InvalidData, "Invalid Tag"))
}
cipher
.decrypt_inplace(&mut storable.data, encryption_metadata.tag.borrow())
.map_err(|_| Error::new(ErrorKind::InvalidData, "Invalid Tag"))?;

let data_blob = PlaintextBlob::decode(&storable.data[..])
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
Ok((data_blob.value, data_blob.version))
}
}

Expand Down
Loading