diff --git a/.github/workflows/on_pull_request.yml b/.github/workflows/on_pull_request.yml index 5b7b26e..9e56c16 100644 --- a/.github/workflows/on_pull_request.yml +++ b/.github/workflows/on_pull_request.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 35e368c..6754813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ dependencies = [ [[package]] name = "ecies-encryption-lib" -version = "0.1.6" +version = "0.2.0" dependencies = [ "aes-gcm", "hex", diff --git a/package.json b/package.json index 9d397ec..e6f5ebe 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/rust/cli/src/main.rs b/rust/cli/src/main.rs index 393f9eb..f644a9e 100644 --- a/rust/cli/src/main.rs +++ b/rust/cli/src/main.rs @@ -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}, }; @@ -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, } @@ -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 = diff --git a/rust/lib/Cargo.toml b/rust/lib/Cargo.toml index 52a2f8e..40a4f80 100644 --- a/rust/lib/Cargo.toml +++ b/rust/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ecies-encryption-lib" -version = "0.1.6" +version = "0.2.0" edition = "2024" [dependencies] diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs index 4be01e8..2719f38 100644 --- a/rust/lib/src/lib.rs +++ b/rust/lib/src/lib.rs @@ -42,8 +42,14 @@ impl PrivKey { pub fn to_bytes(&self) -> Vec { 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(); @@ -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> { +/// Encrypts a message using the recipient's public key. +pub fn encrypt(message_bytes: &[u8], recipient_pub_key: &PubKey) -> Result> { let recipient_pk = &recipient_pub_key.key; let eph_sk = SecretKey::random(&mut OsRng); let eph_pk = eph_sk.public_key(); @@ -80,7 +87,7 @@ pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result> { 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()); @@ -89,6 +96,7 @@ pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result> { Ok(output) } +/// Decrypts a ciphertext using the recipient's private key. pub fn decrypt(ciphertext_bytes: &[u8], recipient_priv_key: &PrivKey) -> Result> { if ciphertext_bytes.len() < 45 { return Err(Error::CryptoInvalidLength(format!( @@ -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> { - 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> { - 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> { - 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> { +fn _decode_padded(padded_message_bytes: &[u8]) -> Result> { // 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()) } diff --git a/tests/integration-test.sh b/tests/integration-test.sh index ae82019..ceab895 100755 --- a/tests/integration-test.sh +++ b/tests/integration-test.sh @@ -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") @@ -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" @@ -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 @@ -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" @@ -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!" diff --git a/tests/ts-lib-unit-test.test.ts b/tests/ts-lib-unit-test.test.ts new file mode 100644 index 0000000..47e97f6 --- /dev/null +++ b/tests/ts-lib-unit-test.test.ts @@ -0,0 +1,264 @@ +import { strict as assert } from 'assert'; +import { describe, it } from 'node:test'; +import { + generateKeypair, + encrypt, + decrypt, + encryptPadded, + decryptPadded, + toHexString, + fromHexString, + getCrypto +} from '../ts/lib/src/index.js'; + +describe('Encryption and Decryption Tests', () => { + const testMessage = 'Hello, World! This is a test message for encryption.'; + const testMessageBytes = new TextEncoder().encode(testMessage); + const testMessageHex = toHexString(testMessageBytes); + const paddedLength = 256; // Test with 256 bytes padding + + // Generate keypair for all tests + const keypair = generateKeypair(); + + describe('encrypt and decrypt', () => { + it('should encrypt and decrypt with Uint8Array messageBytes', async () => { + const crypto = await getCrypto(); + + // Test with Uint8Array input + const encrypted = await encrypt(testMessageBytes, keypair.pk, crypto); + const decrypted = await decrypt(encrypted, keypair.sk, crypto); + + assert.ok(encrypted instanceof Uint8Array, 'Encrypted data should be Uint8Array'); + assert.ok(encrypted.length > 0, 'Encrypted data should not be empty'); + assert.notDeepEqual(encrypted, testMessageBytes, 'Encrypted data should be different from original'); + + assert.ok(decrypted instanceof Uint8Array, 'Decrypted data should be Uint8Array'); + assert.deepEqual(decrypted, testMessageBytes, 'Decrypted data should match original'); + + const decryptedText = new TextDecoder().decode(decrypted); + assert.equal(decryptedText, testMessage, 'Decrypted text should match original message'); + }); + + it('should encrypt and decrypt with hex string messageBytes', async () => { + const crypto = await getCrypto(); + + // Test with hex string input + const encrypted = await encrypt(testMessageHex, keypair.pk, crypto); + const decrypted = await decrypt(encrypted, keypair.sk, crypto); + + assert.ok(encrypted instanceof Uint8Array, 'Encrypted data should be Uint8Array'); + assert.ok(encrypted.length > 0, 'Encrypted data should not be empty'); + + assert.ok(decrypted instanceof Uint8Array, 'Decrypted data should be Uint8Array'); + assert.deepEqual(decrypted, testMessageBytes, 'Decrypted data should match original bytes'); + + const decryptedText = new TextDecoder().decode(decrypted); + assert.equal(decryptedText, testMessage, 'Decrypted text should match original message'); + }); + + it('should encrypt and decrypt with hex string public/private keys', async () => { + const crypto = await getCrypto(); + const pkHex = toHexString(keypair.pk); + const skHex = toHexString(keypair.sk); + + // Test with Uint8Array message and hex keys + const encrypted = await encrypt(testMessageBytes, pkHex, crypto); + const decrypted = await decrypt(encrypted, skHex, crypto); + + assert.deepEqual(decrypted, testMessageBytes, 'Decrypted data should match original'); + + // Test with hex message and hex keys + const encrypted2 = await encrypt(testMessageHex, pkHex, crypto); + const decrypted2 = await decrypt(encrypted2, skHex, crypto); + + assert.deepEqual(decrypted2, testMessageBytes, 'Decrypted data should match original'); + }); + + it('should produce different ciphertexts for the same message (randomness)', async () => { + const crypto = await getCrypto(); + const encrypted1 = await encrypt(testMessageBytes, keypair.pk, crypto); + const encrypted2 = await encrypt(testMessageBytes, keypair.pk, crypto); + + assert.notDeepEqual(encrypted1, encrypted2, 'Encryptions should be different due to randomness'); + + const decrypted1 = await decrypt(encrypted1, keypair.sk, crypto); + const decrypted2 = await decrypt(encrypted2, keypair.sk, crypto); + + assert.deepEqual(decrypted1, testMessageBytes, 'First decryption should match original'); + assert.deepEqual(decrypted2, testMessageBytes, 'Second decryption should match original'); + }); + + it('should handle empty message', async () => { + const crypto = await getCrypto(); + const emptyMessage = new Uint8Array(0); + const emptyHex = ''; + + // Test with empty Uint8Array + const encrypted1 = await encrypt(emptyMessage, keypair.pk, crypto); + const decrypted1 = await decrypt(encrypted1, keypair.sk, crypto); + assert.deepEqual(decrypted1, emptyMessage, 'Empty Uint8Array should encrypt/decrypt correctly'); + + // Test with empty hex string + const encrypted2 = await encrypt(emptyHex, keypair.pk, crypto); + const decrypted2 = await decrypt(encrypted2, keypair.sk, crypto); + assert.deepEqual(decrypted2, emptyMessage, 'Empty hex string should encrypt/decrypt correctly'); + }); + + it('should fail to decrypt with wrong private key', async () => { + const crypto = await getCrypto(); + const wrongKeypair = generateKeypair(); + const encrypted = await encrypt(testMessageBytes, keypair.pk, crypto); + + await assert.rejects( + async () => await decrypt(encrypted, wrongKeypair.sk, crypto), + /OperationError|InvalidAccessError|Error/, + 'Decryption with wrong key should fail' + ); + }); + }); + + describe('encryptPadded and decryptPadded', () => { + it('should encrypt and decrypt padded message with Uint8Array messageBytes', async () => { + const crypto = await getCrypto(); + const encrypted = await encryptPadded(testMessageBytes, keypair.pk, paddedLength, crypto); + const decrypted = await decryptPadded(encrypted, keypair.sk, paddedLength, crypto); + + assert.ok(encrypted instanceof Uint8Array, 'Encrypted data should be Uint8Array'); + assert.ok(encrypted.length > 0, 'Encrypted data should not be empty'); + + assert.ok(decrypted instanceof Uint8Array, 'Decrypted data should be Uint8Array'); + assert.deepEqual(decrypted, testMessageBytes, 'Decrypted data should match original'); + + const decryptedText = new TextDecoder().decode(decrypted); + assert.equal(decryptedText, testMessage, 'Decrypted text should match original message'); + }); + + it('should encrypt and decrypt padded message with hex string messageBytes', async () => { + const crypto = await getCrypto(); + const encrypted = await encryptPadded(testMessageHex, keypair.pk, paddedLength, crypto); + const decrypted = await decryptPadded(encrypted, keypair.sk, paddedLength, crypto); + + assert.ok(encrypted instanceof Uint8Array, 'Encrypted data should be Uint8Array'); + assert.ok(decrypted instanceof Uint8Array, 'Decrypted data should be Uint8Array'); + assert.deepEqual(decrypted, testMessageBytes, 'Decrypted data should match original bytes'); + + const decryptedText = new TextDecoder().decode(decrypted); + assert.equal(decryptedText, testMessage, 'Decrypted text should match original message'); + }); + + it('should work with different padded lengths', async () => { + const crypto = await getCrypto(); + const lengths = [64, 128, 512, 1024]; + + for (const length of lengths) { + const encrypted = await encryptPadded(testMessageBytes, keypair.pk, length, crypto); + const decrypted = await decryptPadded(encrypted, keypair.sk, length, crypto); + + assert.deepEqual(decrypted, testMessageBytes, `Padding length ${length} should work correctly`); + } + }); + + it('should handle minimum padded length (message length + 4)', async () => { + const crypto = await getCrypto(); + const minLength = testMessageBytes.length + 4; + + const encrypted = await encryptPadded(testMessageBytes, keypair.pk, minLength, crypto); + const decrypted = await decryptPadded(encrypted, keypair.sk, minLength, crypto); + + assert.deepEqual(decrypted, testMessageBytes, 'Minimum padding should work correctly'); + }); + + it('should throw error for insufficient padded length', async () => { + const crypto = await getCrypto(); + const insufficientLength = testMessageBytes.length + 3; // Less than required minimum + + await assert.rejects( + async () => await encryptPadded(testMessageBytes, keypair.pk, insufficientLength, crypto), + /Invalid padded length/, + 'Should throw error for insufficient padding length' + ); + }); + + it('should throw error when decrypting with wrong padded length', async () => { + const crypto = await getCrypto(); + const encrypted = await encryptPadded(testMessageBytes, keypair.pk, paddedLength, crypto); + const wrongLength = paddedLength + 50; + + await assert.rejects( + async () => await decryptPadded(encrypted, keypair.sk, wrongLength, crypto), + /Invalid padded length/, + 'Should throw error when padded length doesn\'t match' + ); + }); + + it('should produce different ciphertexts for the same padded message (randomness)', async () => { + const crypto = await getCrypto(); + const encrypted1 = await encryptPadded(testMessageBytes, keypair.pk, paddedLength, crypto); + const encrypted2 = await encryptPadded(testMessageBytes, keypair.pk, paddedLength, crypto); + + assert.notDeepEqual(encrypted1, encrypted2, 'Padded encryptions should be different due to randomness'); + + const decrypted1 = await decryptPadded(encrypted1, keypair.sk, paddedLength, crypto); + const decrypted2 = await decryptPadded(encrypted2, keypair.sk, paddedLength, crypto); + + assert.deepEqual(decrypted1, testMessageBytes, 'First padded decryption should match original'); + assert.deepEqual(decrypted2, testMessageBytes, 'Second padded decryption should match original'); + }); + + it('should handle empty padded message', async () => { + const crypto = await getCrypto(); + const emptyMessage = new Uint8Array(0); + const emptyHex = ''; + const minPaddedLength = 4; // Minimum for empty message + + // Test with empty Uint8Array + const encrypted1 = await encryptPadded(emptyMessage, keypair.pk, minPaddedLength, crypto); + const decrypted1 = await decryptPadded(encrypted1, keypair.sk, minPaddedLength, crypto); + assert.deepEqual(decrypted1, emptyMessage, 'Empty Uint8Array should encrypt/decrypt with padding correctly'); + + // Test with empty hex string + const encrypted2 = await encryptPadded(emptyHex, keypair.pk, minPaddedLength, crypto); + const decrypted2 = await decryptPadded(encrypted2, keypair.sk, minPaddedLength, crypto); + assert.deepEqual(decrypted2, emptyMessage, 'Empty hex string should encrypt/decrypt with padding correctly'); + }); + + it('should fail to decrypt padded message with wrong private key', async () => { + const crypto = await getCrypto(); + const wrongKeypair = generateKeypair(); + const encrypted = await encryptPadded(testMessageBytes, keypair.pk, paddedLength, crypto); + + await assert.rejects( + async () => await decryptPadded(encrypted, wrongKeypair.sk, paddedLength, crypto), + /OperationError|InvalidAccessError|Error/, + 'Padded decryption with wrong key should fail' + ); + }); + }); + + describe('Cross-compatibility tests', () => { + it('should work with different input type combinations', async () => { + const crypto = await getCrypto(); + const pkHex = toHexString(keypair.pk); + const skHex = toHexString(keypair.sk); + + // All combinations for regular encrypt/decrypt + const combinations = [ + { msg: testMessageBytes, pk: keypair.pk, sk: keypair.sk, desc: 'Uint8Array message, Uint8Array keys' }, + { msg: testMessageBytes, pk: pkHex, sk: skHex, desc: 'Uint8Array message, hex keys' }, + { msg: testMessageHex, pk: keypair.pk, sk: keypair.sk, desc: 'hex message, Uint8Array keys' }, + { msg: testMessageHex, pk: pkHex, sk: skHex, desc: 'hex message, hex keys' } + ]; + + for (const { msg, pk, sk, desc } of combinations) { + const encrypted = await encrypt(msg, pk, crypto); + const decrypted = await decrypt(encrypted, sk, crypto); + assert.deepEqual(decrypted, testMessageBytes, `Regular encryption should work with: ${desc}`); + + // Also test padded versions + const encryptedPadded = await encryptPadded(msg, pk, paddedLength, crypto); + const decryptedPadded = await decryptPadded(encryptedPadded, sk, paddedLength, crypto); + assert.deepEqual(decryptedPadded, testMessageBytes, `Padded encryption should work with: ${desc}`); + } + }); + }); +}); diff --git a/ts/cli/index.ts b/ts/cli/index.ts index 2afaf75..0945872 100644 --- a/ts/cli/index.ts +++ b/ts/cli/index.ts @@ -3,12 +3,13 @@ import { Command } from "commander"; import { generateKeypair, - toHex, + toHexString, getCrypto, encrypt, decrypt, encryptPadded, - decryptPaddedUnchecked + decryptPaddedUnchecked, + decryptPadded } from "@cardinal-cryptography/ecies-encryption-lib"; const program = new Command(); @@ -19,8 +20,8 @@ program .description("Generate a new secp256k1 keypair") .action(() => { const { sk, pk } = generateKeypair(); - console.log("Private key:", toHex(sk)); - console.log("Public key: ", toHex(pk)); + console.log("Private key:", toHexString(sk)); + console.log("Public key: ", toHexString(pk)); }); program @@ -30,8 +31,10 @@ program .requiredOption("-m, --message ", "Plaintext message to encrypt") .action(async (opts: { message: string; pubkey: string }) => { const cryptoAPI = await getCrypto(); - const hex = await encrypt(opts.message, opts.pubkey, cryptoAPI); - console.log(hex); + const messageBytes = new TextEncoder().encode(opts.message); + const encrypted = await encrypt(messageBytes, opts.pubkey, cryptoAPI); + const encryptedHex = toHexString(encrypted); + console.log(encryptedHex); }); program @@ -42,7 +45,8 @@ program .action(async (opts: { privkey: string; ciphertext: string }) => { const cryptoAPI = await getCrypto(); const result = await decrypt(opts.ciphertext, opts.privkey, cryptoAPI); - console.log(result); + const decryptedMessage = new TextDecoder().decode(result); + console.log(decryptedMessage); }); program @@ -53,8 +57,10 @@ program .requiredOption("--padded-length ", "Padded length of the message") .action(async (opts: { message: string; pubkey: string; paddedLength: number }) => { const cryptoAPI = await getCrypto(); - const hex = await encryptPadded(opts.message, opts.pubkey, cryptoAPI, opts.paddedLength); - console.log(hex); + const messageBytes = new TextEncoder().encode(opts.message); + const encrypted = await encryptPadded(messageBytes, opts.pubkey, opts.paddedLength, cryptoAPI); + const encryptedHex = toHexString(encrypted); + console.log(encryptedHex); }); program @@ -62,10 +68,24 @@ program .description("Decrypt a ciphertext with a private key") .requiredOption("-k, --privkey ", "Private key (hex)") .requiredOption("-c, --ciphertext ", "Ciphertext (hex)") + .requiredOption("--padded-length ", "Padded length of the message") + .action(async (opts: { privkey: string; ciphertext: string; paddedLength: number }) => { + const cryptoAPI = await getCrypto(); + const result = await decryptPadded(opts.ciphertext, opts.privkey, opts.paddedLength, cryptoAPI); + const decryptedMessage = new TextDecoder().decode(result); + console.log(decryptedMessage); + }); + +program + .command("decrypt-padded-unchecked") + .description("Decrypt a ciphertext with a private key") + .requiredOption("-k, --privkey ", "Private key (hex)") + .requiredOption("-c, --ciphertext ", "Ciphertext (hex)") .action(async (opts: { privkey: string; ciphertext: string }) => { const cryptoAPI = await getCrypto(); const result = await decryptPaddedUnchecked(opts.ciphertext, opts.privkey, cryptoAPI); - console.log(result); + const decryptedMessage = new TextDecoder().decode(result); + console.log(decryptedMessage); }); program @@ -74,18 +94,21 @@ program .action(async () => { const cryptoAPI = await getCrypto(); const { sk, pk } = generateKeypair(); - const skHex = toHex(sk); - const pkHex = toHex(pk); + const skHex = toHexString(sk); + const pkHex = toHexString(pk); console.log("Private key:", skHex); console.log("Public key: ", pkHex); const message = "hello from TypeScript"; - const ciphertext = await encrypt(message, pkHex, cryptoAPI); - console.log("Ciphertext:", ciphertext); + const messageBytes = new TextEncoder().encode(message); + const ciphertext = await encrypt(messageBytes, pkHex, cryptoAPI); + const ciphertextHex = toHexString(ciphertext); + console.log("Ciphertext:", ciphertextHex); - const decrypted = await decrypt(ciphertext, skHex, cryptoAPI); - console.log("Decrypted:", decrypted); + const decrypted = await decrypt(ciphertextHex, skHex, cryptoAPI); + const decryptedMessage = new TextDecoder().decode(decrypted); + console.log("Decrypted:", decryptedMessage); }); program.parseAsync(); diff --git a/ts/lib/package.json b/ts/lib/package.json index c416f4d..c7008ad 100644 --- a/ts/lib/package.json +++ b/ts/lib/package.json @@ -1,7 +1,7 @@ { "name": "@cardinal-cryptography/ecies-encryption-lib", "author": "CardinalCryptography", - "version": "0.1.6", + "version": "0.2.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/ts/lib/src/index.ts b/ts/lib/src/index.ts index 33655ec..f0eb5f7 100644 --- a/ts/lib/src/index.ts +++ b/ts/lib/src/index.ts @@ -2,84 +2,161 @@ import * as secp from "@noble/secp256k1"; export type Keypair = { sk: Uint8Array; pk: Uint8Array }; +/** * Generates a new keypair using secp256k1. + * The private key (sk) is a random 32-byte Uint8Array, + * and the public key (pk) is derived from the private key. + * @returns A Keypair object containing the private key (sk) and public key (pk). + */ export function generateKeypair(): Keypair { const sk = secp.utils.randomPrivateKey(); const pk = secp.getPublicKey(sk, true); return { sk, pk }; } +/** * Converts a private key (sk) to a public key (pk). + * The private key can be provided as a Uint8Array or a hex string. + * @param sk The private key to convert. It can be a Uint8Array or a hex string. + * @returns The public key as a Uint8Array. + * @throws Error if the private key length is not 32 bytes. + */ +export function publicKeyFromPrivateKey(sk: Uint8Array | string): Uint8Array { + const privateKey = convertToBytes(sk); + if (privateKey.length !== 32) { + throw new Error("Invalid private key length, expected 32 bytes"); + } + return secp.getPublicKey(privateKey, true); +} + +/** + * Gets the appropriate Crypto API to use. + * @returns The global Crypto API or the Node.js crypto module. + * If the global Crypto API is not available, it will import the Node.js crypto module. + */ export async function getCrypto(): Promise { return typeof globalThis.crypto !== "undefined" - ? globalThis.crypto - : ((await import("node:crypto")).webcrypto as Crypto); + ? globalThis.crypto + : ((await import("node:crypto")).webcrypto as Crypto); } -export function toHex(uint8: Uint8Array, withPrefix: boolean = false): string { - const hex = Array.from(uint8) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); +/** + * Converts a Uint8Array to a hex string. + * @param bytes The Uint8Array to convert. + * @param withPrefix If true, the hex string will be prefixed with "0x". + * @returns The hex string representation of the Uint8Array. + */ +export function toHexString( + bytes: Uint8Array, + withPrefix: boolean = false +): string { + const hex = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); return withPrefix ? "0x" + hex : hex; } -export function fromHex(hex: Uint8Array | string): Uint8Array { - return isBytes(hex) - ? Uint8Array.from(hex as any) - : fromStringHex(hex as string); -} - - -function isBytes(bytes: Uint8Array | string): boolean { - return ( - bytes instanceof Uint8Array || - (ArrayBuffer.isView(bytes) && bytes.constructor.name === "Uint8Array") - ); -} - -function fromStringHex(hex: string): Uint8Array { +/** + * Converts a hex string to a Uint8Array. + * @param {string} hex The hex string to convert. It can optionally start with "0x" or "0X". + * @returns {Uint8Array} The Uint8Array representation of the hex string. + * @throws Error if the hex string has an odd length. + */ +export function fromHexString(hex: string): Uint8Array { hex = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; if (hex.length % 2 !== 0) { throw new Error("Hex string must have an even length"); } const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + const byte = parseInt(hex.slice(i, i + 2), 16); + if (isNaN(byte)) { + throw new Error("Failed to parse hex string"); + } + bytes[i / 2] = byte; } return bytes; } +/** + * Converts the input to a Uint8Array. + * If the input is a hex string, it converts it to a Uint8Array. + * If the input is already a Uint8Array, it returns it as is. + * @param {Uint8Array | string} hex The input to convert to a Uint8Array. It can be a hex string or a Uint8Array. + * @returns {Uint8Array} A Uint8Array representation of the input. + */ +export function convertToBytes(hex: Uint8Array | string): Uint8Array { + return isBytes(hex) + ? Uint8Array.from(hex as Uint8Array) + : fromHexString(hex as string); +} + +function isBytes(bytes: Uint8Array | string): boolean { + return ( + bytes instanceof Uint8Array || + (ArrayBuffer.isView(bytes) && bytes.constructor.name === "Uint8Array") + ); +} +/** + * Encrypts a message using the recipient's public key. + * @param {Uint8Array | string} messageBytes The message to encrypt. It can be a hex string or a Uint8Array. + * @param {Uint8Array | string} recipientPubKeyBytes The recipient's public key. It can be a hex string or a Uint8Array. + * @param {Crypto} [cryptoAPI] The crypto API to use. See getCrypto() for details. + * If not provided, the global crypto API will be used. + * @returns {Promise} The encrypted message. + */ export async function encrypt( - message: string, - recipientPubHex: string, - cryptoAPI: Crypto -): Promise { - const encoded = new TextEncoder().encode(message); - const out = await _encrypt(encoded, fromHex(recipientPubHex), cryptoAPI); - return toHex(out); + messageBytes: Uint8Array | string, + recipientPubKeyBytes: Uint8Array | string, + cryptoAPI?: Crypto +): Promise { + const out = await _encrypt( + convertToBytes(messageBytes), + convertToBytes(recipientPubKeyBytes), + cryptoAPI + ); + return out; } +/** * Decrypts a message using the recipient's private key. + * @param {Uint8Array | string} ciphertextBytes The encrypted message to decrypt. It can be a hex string or a Uint8Array. + * @param {Uint8Array | string} recipientSkBytes The recipient's private key. It can be a hex string or a Uint8Array. + * @param {Crypto} [cryptoAPI] The crypto API to use. See getCrypto() for details. + * If not provided, the global crypto API will be used. + * @returns {Promise} The decrypted message. + */ export async function decrypt( - ciphertextHex: string, - recipientSkHex: string, - cryptoAPI: Crypto -): Promise { + ciphertextBytes: Uint8Array | string, + recipientSkBytes: Uint8Array | string, + cryptoAPI?: Crypto +): Promise { const decrypted = await _decrypt( - fromHex(ciphertextHex), - fromHex(recipientSkHex), + convertToBytes(ciphertextBytes), + convertToBytes(recipientSkBytes), cryptoAPI ); - return new TextDecoder().decode(decrypted); + return decrypted; } +/** + * 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. + * @param {Uint8Array | string} messageBytes The message to encrypt. It can be a hex string or a Uint8Array. + * @param {Uint8Array | string} recipientPubKeyBytes The recipient's public key. It can be a hex string or a Uint8Array. + * @param {Crypto} [cryptoAPI] The crypto API to use. See getCrypto() for details. + * If not provided, the global crypto API will be used. + * @param {number} paddedLength The total length of the padded message in bytes. + * @returns {Promise} The encrypted padded message. + */ export async function encryptPadded( - message: string, - recipientPubHex: string, - cryptoAPI: Crypto, - paddedLength: number -): Promise { - if (paddedLength < message.length + 4) { + messageBytes: Uint8Array | string, + recipientPubKeyBytes: Uint8Array | string, + paddedLength: number, + cryptoAPI?: Crypto +): Promise { + const encodedMessage = convertToBytes(messageBytes); + if (paddedLength < encodedMessage.length + 4) { throw new Error( `Invalid padded length ${paddedLength} bytes, expected at least ${ - message.length + 4 + encodedMessage.length + 4 } bytes)` ); } @@ -88,29 +165,36 @@ export async function encryptPadded( // prepend with the message length info in little endian (4 bytes) const buffer = new ArrayBuffer(4); const view = new DataView(buffer); - view.setUint32(0, message.length, true); + view.setUint32(0, encodedMessage.length, true); encoded.set(new Uint8Array(buffer), 0); - const encodedMessage = new TextEncoder().encode(message); - encoded.set(encodedMessage, 4); const encrypted = await _encrypt( encoded, - fromHex(recipientPubHex), + convertToBytes(recipientPubKeyBytes), cryptoAPI ); - return toHex(encrypted); + return encrypted; } +/** + * Decrypts a padded message using the recipient's private key. + * The first 4 bytes of the encrypted message should contain the original message length in little-endian format. + * @param {Uint8Array | string} ciphertextBytes The encrypted padded message to decrypt. It can be a hex string or a Uint8Array. + * @param {Uint8Array | string} recipientSkBytes The recipient's private key. It can be a hex string or a Uint8Array. + * @param {Crypto} cryptoAPI The crypto API to use. See getCrypto() for details. + * @param {number} paddedLength The expected total length of the padded message in bytes. + * @returns {Promise} The decrypted padded message. + */ export async function decryptPadded( - ciphertextHex: string, - recipientSkHex: string, - cryptoAPI: Crypto, - paddedLength: number -): Promise { + ciphertextBytes: Uint8Array | string, + recipientSkBytes: Uint8Array | string, + paddedLength: number, + cryptoAPI?: Crypto +): Promise { const decrypted = await _decrypt( - fromHex(ciphertextHex), - fromHex(recipientSkHex), + convertToBytes(ciphertextBytes), + convertToBytes(recipientSkBytes), cryptoAPI ); if (decrypted.length != paddedLength) { @@ -121,14 +205,24 @@ export async function decryptPadded( return decodePadded(decrypted); } +/** + * Decrypts a padded message without checking the padded length. + * 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. + * Use with caution. + * @param {Uint8Array | string} ciphertextBytes The encrypted padded message to decrypt. It can be a hex string or a Uint8Array. + * @param {Uint8Array | string} recipientSkBytes The recipient's private key. It can be a hex string or a Uint8Array. + * @param {Crypto} cryptoAPI The crypto API to use. See getCrypto() for details. + * @returns {Promise} The decrypted padded message. + */ export async function decryptPaddedUnchecked( - ciphertextHex: string, - recipientSkHex: string, - cryptoAPI: Crypto -): Promise { + ciphertextBytes: Uint8Array | string, + recipientSkBytes: Uint8Array | string, + cryptoAPI?: Crypto +): Promise { const decrypted = await _decrypt( - fromHex(ciphertextHex), - fromHex(recipientSkHex), + convertToBytes(ciphertextBytes), + convertToBytes(recipientSkBytes), cryptoAPI ); return await decodePadded(decrypted); @@ -162,13 +256,16 @@ async function hkdf( async function _encrypt( message: Uint8Array, recipientPk: Uint8Array, - cryptoAPI: Crypto + cryptoAPI?: Crypto ): Promise { + if (!cryptoAPI) { + cryptoAPI = await getCrypto(); + } const recipientPub = secp.Point.fromHex(recipientPk); const ephSk = secp.utils.randomPrivateKey(); const ephPk = secp.getPublicKey(ephSk, true); - const ephSkBigInt = BigInt(toHex(ephSk, true)); + const ephSkBigInt = BigInt(toHexString(ephSk, true)); const shared = recipientPub.multiply(ephSkBigInt).toRawBytes(true); const aesKey = await hkdf(shared, cryptoAPI); @@ -192,8 +289,11 @@ async function _encrypt( async function _decrypt( ciphertextBytes: Uint8Array, recipientSkBytes: Uint8Array, - cryptoAPI: Crypto + cryptoAPI?: Crypto ): Promise { + if (!cryptoAPI) { + cryptoAPI = await getCrypto(); + } if (ciphertextBytes.length < 45) { throw new Error( `Invalid ciphertext length ${ciphertextBytes.length} bytes, expected at least 45 bytes` @@ -203,7 +303,7 @@ async function _decrypt( const iv = ciphertextBytes.slice(33, 45); const ciphertext = ciphertextBytes.slice(45); - const skBigInt = BigInt(toHex(recipientSkBytes, true)); + const skBigInt = BigInt(toHexString(recipientSkBytes, true)); const shared_point = ephPk.multiply(skBigInt); let shared = shared_point.toRawBytes(true); const aesKey = await hkdf(shared, cryptoAPI); @@ -216,7 +316,7 @@ async function _decrypt( return new Uint8Array(plaintextBuffer); } -async function decodePadded(paddedMessage: Uint8Array): Promise { +async function decodePadded(paddedMessage: Uint8Array): Promise { if (paddedMessage.length < 4) { throw new Error( `Invalid padded length ${ @@ -235,5 +335,5 @@ async function decodePadded(paddedMessage: Uint8Array): Promise { ); } - return new TextDecoder().decode(paddedMessage.subarray(4, messageLength + 4)); + return paddedMessage.subarray(4, messageLength + 4); }