This library implements ECIES (Elliptic Curve Integrated Encryption Scheme) with an additional RSA layer that wraps the shared secret before decryption.
function eciesDecryptWithRsa(
encryptedSharedSecret: Uint8Array, // RSA-encrypted EC point (256 bytes for 2048-bit RSA)
rsaPrivateKey: CryptoKey, // RSA private key
ciphertext: Uint8Array, // AES-GCM encrypted data (includes 16-byte auth tag)
nonce: Uint8Array, // 12 bytes (96-bit)
context: Uint8Array // Arbitrary bytes, used as HKDF info
): Promise<Uint8Array> // Decrypted plaintext| Property | Value |
|---|---|
| Algorithm | RSA-OAEP |
| Hash | SHA-256 |
| Input | encryptedSharedSecret (256 bytes for 2048-bit key) |
| Output | 33 bytes (compressed SEC1 EC point) |
const pointBytes = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
rsaPrivateKey,
encryptedSharedSecret
);
// Result: 33 bytes - compressed secp256k1 point| Property | Value |
|---|---|
| Curve | secp256k1 (k256) |
| Point Format | Compressed SEC1 |
| Point Size | 33 bytes |
| Format | 0x02 or 0x03 prefix + 32 bytes X-coordinate |
// pointBytes format:
// - Byte 0: 0x02 (even Y) or 0x03 (odd Y)
// - Bytes 1-32: X-coordinate (32 bytes, big-endian)
const xCoordinate = pointBytes.slice(1, 33); // 32 bytesNote: You only need the X-coordinate for key derivation. No need to decompress the full point.
| Property | Value |
|---|---|
| Algorithm | HKDF |
| Hash | SHA-256 |
| Salt | Empty/None (new Uint8Array(0)) |
| IKM (Input Key Material) | X-coordinate (32 bytes) |
| Info | context parameter |
| Output Length | 32 bytes (256 bits) |
// Import the X-coordinate as raw key material
const ikm = await crypto.subtle.importKey(
"raw",
xCoordinate,
"HKDF",
false,
["deriveKey"]
);
// Derive the AES-256 key
const aesKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0), // No salt
info: context // The context bytes
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key Size | 256 bits (32 bytes) |
| Nonce/IV Size | 96 bits (12 bytes) |
| Tag Size | 128 bits (16 bytes, appended to ciphertext) |
const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
tagLength: 128 // 16 bytes
},
aesKey,
ciphertext // Includes auth tag at the end
);async function eciesDecryptWithRsa(
encryptedSharedSecret,
rsaPrivateKey,
ciphertext,
nonce,
context
) {
// Step 1: RSA-OAEP decrypt to recover the EC point
const pointBytes = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
rsaPrivateKey,
encryptedSharedSecret
)
);
// Step 2: Extract X-coordinate (bytes 1-32 of compressed point)
const xCoordinate = pointBytes.slice(1, 33);
// Step 3: HKDF to derive AES key
const ikm = await crypto.subtle.importKey(
"raw",
xCoordinate,
"HKDF",
false,
["deriveKey"]
);
const aesKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info: context
},
ikm,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
// Step 4: AES-GCM decrypt
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce, tagLength: 128 },
aesKey,
ciphertext
);
return new Uint8Array(plaintext);
}When importing or generating the RSA private key:
// For importing a PKCS#8 private key:
const rsaPrivateKey = await crypto.subtle.importKey(
"pkcs8",
privateKeyDer,
{
name: "RSA-OAEP",
hash: "SHA-256" // Must match!
},
false,
["decrypt"]
);To verify compatibility, have the Rust side output these intermediate values:
| Value | Description |
|---|---|
encrypted_shared_secret |
Hex-encoded RSA ciphertext |
point_bytes (after RSA decrypt) |
33 bytes, hex-encoded |
x_coordinate |
32 bytes, hex-encoded |
derived_key (after HKDF) |
32 bytes, hex-encoded |
nonce |
12 bytes |
context |
The info string |
ciphertext |
AES-GCM output |
plaintext |
Expected result |
| Component | Algorithm | Parameters |
|---|---|---|
| RSA Decryption | RSA-OAEP | SHA-256, 2048+ bit key |
| EC Point | secp256k1 | Compressed SEC1 (33 bytes) |
| Key Derivation | HKDF-SHA256 | salt=empty, info=context, output=32 bytes |
| Symmetric Encryption | AES-256-GCM | nonce=12 bytes, tag=16 bytes |