Skip to content
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
3 changes: 3 additions & 0 deletions .github/workflows/on_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
with:
version: 10.2.0

- name: TS Unit tests
run: pnpm install && pnpm build && pnpm unit-test

- name: Integration tests
shell: bash
run: ./tests/integration-test.sh
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cardinal-cryptography/ecies-encryption-lib",
"author": "CardinalCryptography",
"version": "0.1.6",
"version": "0.2.0",
"description": "ECIES encryption library (proxy to ts/lib)",
"main": "ts/lib/dist/index.js",
"types": "ts/lib/dist/index.d.ts",
Expand All @@ -22,7 +22,8 @@
},
"scripts": {
"build": "pnpm --filter ./ts/lib... build",
"prepare": "pnpm run build"
"prepare": "pnpm run build",
"unit-test": "tsx tests/ts-lib-unit-test.test.ts"
},
"devDependencies": {
"@types/node": "^24.0.4",
Expand Down
33 changes: 32 additions & 1 deletion rust/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ecies_encryption_lib::{
PrivKey, PubKey, decrypt, decrypt_padded_unchecked, encrypt, encrypt_padded, generate_keypair,
PrivKey, PubKey, decrypt, decrypt_padded, decrypt_padded_unchecked, encrypt, encrypt_padded,
generate_keypair,
utils::{from_hex, to_hex},
};

Expand Down Expand Up @@ -89,6 +90,21 @@ enum Commands {
/// Ciphertext in hex (or file path if --file is passed)
#[arg(short, long)]
ciphertext: String,

/// Padded length of the message.
#[arg(long)]
padded_length: usize,
},

/// Decrypt a padded ciphertext with a private key without checking padding
DecryptPaddedUnchecked {
/// Private key (hex)
#[arg(short, long)]
privkey: String,

/// Ciphertext in hex (or file path if --file is passed)
#[arg(short, long)]
ciphertext: String,
},
Example,
}
Expand Down Expand Up @@ -143,6 +159,21 @@ fn main() -> Result<()> {
Commands::DecryptPadded {
privkey,
ciphertext,
padded_length,
} => {
let privkey_bytes = from_hex(&privkey).context("Invalid private key hex")?;
let privkey =
PrivKey::from_bytes(&privkey_bytes).context("Failed to parse private key")?;

let ciphertext_bytes = from_hex(&ciphertext).context("Invalid ciphertext hex")?;

let decrypted = decrypt_padded(&ciphertext_bytes, &privkey, padded_length)
.context("Decryption failed")?;
println!("{}", String::from_utf8(decrypted)?);
}
Commands::DecryptPaddedUnchecked {
privkey,
ciphertext,
} => {
let privkey_bytes = from_hex(&privkey).context("Invalid private key hex")?;
let privkey =
Expand Down
2 changes: 1 addition & 1 deletion rust/lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ecies-encryption-lib"
version = "0.1.6"
version = "0.2.0"
edition = "2024"

[dependencies]
Expand Down
61 changes: 38 additions & 23 deletions rust/lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ impl PrivKey {
pub fn to_bytes(&self) -> Vec<u8> {
self.key.to_bytes().to_vec()
}

pub fn public_key(&self) -> PubKey {
let pk = self.key.public_key();
PubKey { key: pk }
}
}

/// Generates a new secp256k1 keypair (private and public key).
pub fn generate_keypair() -> (PrivKey, PubKey) {
let sk = SecretKey::random(&mut OsRng);
let pk = sk.public_key();
Expand All @@ -67,7 +73,8 @@ fn hkdf_expand(shared_secret: &[u8]) -> Result<[u8; 32]> {
Ok(okm)
}

pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result<Vec<u8>> {
/// Encrypts a message using the recipient's public key.
pub fn encrypt(message_bytes: &[u8], recipient_pub_key: &PubKey) -> Result<Vec<u8>> {
let recipient_pk = &recipient_pub_key.key;
let eph_sk = SecretKey::random(&mut OsRng);
let eph_pk = eph_sk.public_key();
Expand All @@ -80,7 +87,7 @@ pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result<Vec<u8>> {
OsRng.fill_bytes(&mut iv);
let nonce = Nonce::from_slice(&iv);

let ciphertext = cipher.encrypt(nonce, message)?;
let ciphertext = cipher.encrypt(nonce, message_bytes)?;

let mut output = vec![];
output.extend(eph_pk.to_encoded_point(true).as_bytes());
Expand All @@ -89,6 +96,7 @@ pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result<Vec<u8>> {
Ok(output)
}

/// Decrypts a ciphertext using the recipient's private key.
pub fn decrypt(ciphertext_bytes: &[u8], recipient_priv_key: &PrivKey) -> Result<Vec<u8>> {
if ciphertext_bytes.len() < 45 {
return Err(Error::CryptoInvalidLength(format!(
Expand All @@ -110,65 +118,72 @@ pub fn decrypt(ciphertext_bytes: &[u8], recipient_priv_key: &PrivKey) -> Result<
Ok(decrypted_bytes)
}

/// Encrypts a message with padding to a specified length.
/// The first 4 bytes of the encrypted message will contain the original message length in little-endian format.
pub fn encrypt_padded(
message: &[u8],
message_bytes: &[u8],
recipient_pub_key: &PubKey,
padded_length: usize,
) -> Result<Vec<u8>> {
if padded_length < message.len() + 4 {
if padded_length < message_bytes.len() + 4 {
return Err(Error::InvalidPaddedLength {
found: padded_length,
expected: message.len() + 4,
expected: message_bytes.len() + 4,
});
}
// prepend with the message length info in little endian (4 bytes)
let mut padded_message = (message.len() as u32).to_le_bytes().to_vec();
padded_message.extend(message);
padded_message.resize(padded_length, 0u8);
encrypt(&padded_message, recipient_pub_key)
let mut padded_message_bytes = (message_bytes.len() as u32).to_le_bytes().to_vec();
padded_message_bytes.extend(message_bytes);
padded_message_bytes.resize(padded_length, 0u8);
encrypt(&padded_message_bytes, recipient_pub_key)
}

/// Decrypts a padded ciphertext with a private key.
/// The first 4 bytes of the encrypted message should contain the original message length in little-endian format.
/// This function does not check if the decrypted message length matches the expected padded length.
pub fn decrypt_padded_unchecked(
ciphertext_bytes: &[u8],
recipient_priv_key: &PrivKey,
) -> Result<Vec<u8>> {
let padded_message = decrypt(ciphertext_bytes, recipient_priv_key)?;
_decode_padded(&padded_message)
let padded_message_bytes = decrypt(ciphertext_bytes, recipient_priv_key)?;
_decode_padded(&padded_message_bytes)
}

/// Decrypts a padded ciphertext with a private key.
/// The first 4 bytes of the encrypted message should contain the original message length in little-endian format.
pub fn decrypt_padded(
ciphertext_bytes: &[u8],
recipient_priv_key: &PrivKey,
padded_length: usize,
) -> Result<Vec<u8>> {
let padded_message = decrypt(ciphertext_bytes, recipient_priv_key)?;
if padded_message.len() != padded_length {
let padded_message_bytes = decrypt(ciphertext_bytes, recipient_priv_key)?;
if padded_message_bytes.len() != padded_length {
return Err(Error::InvalidPaddedLength {
found: padded_message.len(),
found: padded_message_bytes.len(),
expected: padded_length,
});
}
_decode_padded(&padded_message)
_decode_padded(&padded_message_bytes)
}

fn _decode_padded(padded_message: &[u8]) -> Result<Vec<u8>> {
fn _decode_padded(padded_message_bytes: &[u8]) -> Result<Vec<u8>> {
// decode the original message length
let message_length = u32::from_le_bytes(
padded_message
let message_bytes_length = u32::from_le_bytes(
padded_message_bytes
.get(..4)
.ok_or(Error::InvalidPaddedLength {
found: padded_message.len(),
found: padded_message_bytes.len(),
expected: 4,
})?
.try_into()
.map_err(|_| Error::Decoding("Message length".to_string()))?,
) as usize;
// extract the original message
padded_message
.get(4..(message_length + 4))
padded_message_bytes
.get(4..(message_bytes_length + 4))
.ok_or(Error::InvalidMessageLength {
found: message_length,
expected: padded_message.len() - 4,
found: message_bytes_length,
expected: padded_message_bytes.len() - 4,
})
.map(|m| m.to_vec())
}
61 changes: 57 additions & 4 deletions tests/integration-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ eval "$($JS generate-keypair | tee /dev/stderr | awk '

# Encrypt in JS
JS_CIPHERTEXT=$($JS encrypt --pubkey "$JS_PK" --message "$MSG")
echo "JS ciphertext: $JS_CIPHERTEXT"

# Decrypt in Rust
RUST_OUTPUT=$($RUST decrypt --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT")
Expand All @@ -48,11 +49,11 @@ eval "$($RUST generate-keypair | tee /dev/stderr | awk '

# Encrypt in Rust
RUST_CIPHERTEXT=$($RUST encrypt --pubkey "$RUST_PK" --message "$MSG" | tail -n1)

echo "Rust ciphertext: $RUST_CIPHERTEXT"

# Decrypt in JS
JS_OUTPUT=$($JS decrypt --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT")
echo "JS output: $JS_OUTPUT"

if [[ "$JS_OUTPUT" == "$MSG" ]]; then
echo "✅ Rust → JS decryption success"
Expand All @@ -74,9 +75,10 @@ eval "$($JS generate-keypair | tee /dev/stderr | awk '

# Encrypt in JS
JS_CIPHERTEXT=$($JS encrypt-padded --pubkey "$JS_PK" --message "$MSG" --padded-length $PADDED_LENGTH)
echo "JS ciphertext: $JS_CIPHERTEXT"

# Decrypt in Rust
RUST_OUTPUT=$($RUST decrypt-padded --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT")
RUST_OUTPUT=$($RUST decrypt-padded --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT" --padded-length $PADDED_LENGTH)
echo "Rust output: $RUST_OUTPUT"

if [[ "$RUST_OUTPUT" == "$MSG" ]]; then
Expand All @@ -99,11 +101,11 @@ eval "$($RUST generate-keypair | tee /dev/stderr | awk '

# Encrypt in Rust
RUST_CIPHERTEXT=$($RUST encrypt-padded --pubkey "$RUST_PK" --message "$MSG" --padded-length $PADDED_LENGTH | tail -n1)

echo "Rust ciphertext: $RUST_CIPHERTEXT"

# Decrypt in JS
JS_OUTPUT=$($JS decrypt-padded --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT")
JS_OUTPUT=$($JS decrypt-padded --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT" --padded-length $PADDED_LENGTH)
echo "JS output: $JS_OUTPUT"

if [[ "$JS_OUTPUT" == "$MSG" ]]; then
echo "✅ Rust → JS padded decryption success"
Expand All @@ -114,4 +116,55 @@ else
exit 1
fi

echo "=== Scenario 5: JS encrypt padded -> Rust decrypt padded unchecked ==="

# Generate keypair in JS
eval "$($JS generate-keypair | tee /dev/stderr | awk '
/Private key:/ { print "JS_SK=" $3 }
/Public key:/ { print "JS_PK=" $3 }
')"

# Encrypt in JS
JS_CIPHERTEXT=$($JS encrypt-padded --pubkey "$JS_PK" --message "$MSG" --padded-length $PADDED_LENGTH)
echo "JS ciphertext: $JS_CIPHERTEXT"

# Decrypt in Rust
RUST_OUTPUT=$($RUST decrypt-padded-unchecked --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT")
echo "Rust output: $RUST_OUTPUT"

if [[ "$RUST_OUTPUT" == "$MSG" ]]; then
echo "✅ JS → Rust padded unchecked decryption success"
else
echo "❌ JS → Rust padded unchecked decryption failed"
echo "Expected: $MSG"
echo "Got: $RUST_OUTPUT"
exit 1
fi


echo "=== Scenario 6: Rust encrypt padded -> JS decrypt padded unchecked ==="

# Generate keypair in Rust
eval "$($RUST generate-keypair | tee /dev/stderr | awk '
/Private key:/ { print "RUST_SK=" $3 }
/Public key:/ { print "RUST_PK=" $3 }
')"

# Encrypt in Rust
RUST_CIPHERTEXT=$($RUST encrypt-padded --pubkey "$RUST_PK" --message "$MSG" --padded-length $PADDED_LENGTH | tail -n1)
echo "Rust ciphertext: $RUST_CIPHERTEXT"

# Decrypt in JS
JS_OUTPUT=$($JS decrypt-padded-unchecked --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT")
echo "JS output: $JS_OUTPUT"

if [[ "$JS_OUTPUT" == "$MSG" ]]; then
echo "✅ Rust → JS padded unchecked decryption success"
else
echo "❌ Rust → JS padded unchecked decryption failed"
echo "Expected: $MSG"
echo "Got: $JS_OUTPUT"
exit 1
fi

echo "🎉 All integration tests passed!"
Loading