|
1 | | -import libsodium from "libsodium-wrappers" |
| 1 | +import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa" |
| 2 | +import { u32 } from "@noble/ciphers/utils" |
| 3 | +import { x25519 } from "@noble/curves/ed25519.js" |
| 4 | +import { blake2b } from "@noble/hashes/blake2.js" |
| 5 | +import { randomBytes } from "@noble/hashes/utils.js" |
2 | 6 |
|
| 7 | +// Salsa20 sigma constant: "expand 32-byte k" |
| 8 | +const SIGMA = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]) |
| 9 | + |
| 10 | +/** |
| 11 | + * Implements libsodium's crypto_box_beforenm using HSalsa20 key derivation. |
| 12 | + * Derives encryption key from X25519 shared secret. |
| 13 | + * @param sharedSecret - 32-byte X25519 shared secret |
| 14 | + * @returns 32-byte derived encryption key |
| 15 | + */ |
| 16 | +function cryptoBoxBeforenm(sharedSecret: Uint8Array): Uint8Array { |
| 17 | + const zeroNonce = new Uint32Array(4) // 16 bytes of zeros |
| 18 | + const key32 = u32(sharedSecret) // Convert 32-byte key to Uint32Array |
| 19 | + const output32 = new Uint32Array(8) // Output: 32 bytes |
| 20 | + |
| 21 | + // HSalsa20(zero_nonce, shared_secret, sigma) -> derived_key |
| 22 | + hsalsa(SIGMA, key32, zeroNonce, output32) |
| 23 | + |
| 24 | + return new Uint8Array(output32.buffer, output32.byteOffset, 32) |
| 25 | +} |
| 26 | + |
| 27 | +/** |
| 28 | + * Encrypts a secret using X25519-XSalsa20-Poly1305 sealed box construction. |
| 29 | + * Compatible with libsodium's crypto_box_seal. |
| 30 | + * @param key - Base64-encoded recipient public key (32 bytes) |
| 31 | + * @param value - Secret value to encrypt |
| 32 | + * @returns Base64-encoded sealed box (ephemeral_pk || ciphertext) |
| 33 | + */ |
3 | 34 | export async function encodeSecret(key: string, value: string): Promise<string> { |
4 | | - // Ensure libsodium is ready (initializes WASM) |
5 | | - await libsodium.ready |
| 35 | + // Decode the base64 recipient public key |
| 36 | + const recipientPublicKey = Buffer.from(key, "base64") |
| 37 | + |
| 38 | + if (recipientPublicKey.length !== 32) { |
| 39 | + throw new Error("Invalid public key length") |
| 40 | + } |
| 41 | + |
| 42 | + // Generate ephemeral keypair |
| 43 | + const ephemeralSecretKey = randomBytes(32) |
| 44 | + const ephemeralPublicKey = x25519.getPublicKey(ephemeralSecretKey) |
| 45 | + |
| 46 | + // Compute X25519 shared secret |
| 47 | + const x25519SharedSecret = x25519.getSharedSecret(ephemeralSecretKey, recipientPublicKey) |
6 | 48 |
|
7 | | - // Access the crypto functions from the now-initialized libsodium module |
8 | | - const sodium = libsodium as any |
| 49 | + // Derive encryption key using HSalsa20 (crypto_box_beforenm) |
| 50 | + const encryptionKey = cryptoBoxBeforenm(x25519SharedSecret) |
9 | 51 |
|
10 | | - // Convert the secret and key to a Uint8Array. |
11 | | - const binkey = sodium.from_base64(key, sodium.base64_variants.ORIGINAL) |
12 | | - const binsec = sodium.from_string(value) |
| 52 | + // Derive nonce from Blake2b(ephemeral_pk || recipient_pk) |
| 53 | + // This matches libsodium's crypto_box_seal nonce derivation |
| 54 | + const nonceInput = new Uint8Array(64) |
| 55 | + nonceInput.set(ephemeralPublicKey, 0) |
| 56 | + nonceInput.set(recipientPublicKey, 32) |
| 57 | + const nonce = blake2b(nonceInput, { dkLen: 24 }) |
13 | 58 |
|
14 | | - // Encrypt the secret using libsodium's sealed box encryption |
15 | | - const encBytes = sodium.crypto_box_seal(binsec, binkey) |
| 59 | + // Encrypt the message using XSalsa20-Poly1305 |
| 60 | + const messageBytes = new TextEncoder().encode(value) |
| 61 | + const cipher = xsalsa20poly1305(encryptionKey, nonce) |
| 62 | + const ciphertext = cipher.encrypt(messageBytes) |
16 | 63 |
|
17 | | - // Convert the encrypted Uint8Array to Base64 |
18 | | - const output = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL) |
| 64 | + // Sealed box format: ephemeral_pk || ciphertext |
| 65 | + const sealedBox = new Uint8Array(32 + ciphertext.length) |
| 66 | + sealedBox.set(ephemeralPublicKey, 0) |
| 67 | + sealedBox.set(ciphertext, 32) |
19 | 68 |
|
20 | | - return output |
| 69 | + // Convert to base64 |
| 70 | + return Buffer.from(sealedBox).toString("base64") |
21 | 71 | } |
0 commit comments