44 * The broker encrypts with the server's public key. Only the server's
55 * private key can decrypt. The broker CANNOT decrypt sealed boxes.
66 *
7- * Uses tweetnacl's box.keyPair + secretbox under the hood to implement
8- * the libsodium crypto_box_seal pattern:
9- * 1. Generate an ephemeral X25519 keypair
10- * 2. Compute shared secret: ECDH(ephemeral_sk, recipient_pk)
11- * 3. Derive nonce from ephemeral_pk + recipient_pk
12- * 4. Encrypt payload with crypto_box using the shared secret
13- * 5. Output: ephemeral_pk || ciphertext
7+ * Uses libsodium's native crypto_box_seal / crypto_box_seal_open for
8+ * interoperability with standard libsodium implementations on the server.
9+ * The nonce is derived using BLAKE2B(ephemeral_pk || recipient_pk) as
10+ * per the libsodium spec.
1411 */
1512
16- import nacl from "tweetnacl " ;
17- import { decodeBase64 , encodeBase64 } from "../util/encoding.js" ;
13+ import _sodium from "libsodium-wrappers-sumo " ;
14+ import { encodeBase64 , decodeBase64 } from "../util/encoding.js" ;
1815
1916/** Length of an X25519 public key in bytes. */
2017const PUBLIC_KEY_BYTES = 32 ;
2118
22- /**
23- * Derive a nonce from the ephemeral public key and recipient public key.
24- * Uses the first 24 bytes of SHA-512(ephemeral_pk || recipient_pk).
25- */
26- async function deriveNonce (
27- ephemeralPk : Uint8Array ,
28- recipientPk : Uint8Array ,
29- ) : Promise < Uint8Array > {
30- const input = new Uint8Array ( PUBLIC_KEY_BYTES * 2 ) ;
31- input . set ( ephemeralPk , 0 ) ;
32- input . set ( recipientPk , PUBLIC_KEY_BYTES ) ;
33- const hash = await crypto . subtle . digest ( "SHA-512" , input ) ;
34- return new Uint8Array ( hash ) . slice ( 0 , nacl . box . nonceLength ) ;
19+ /** Ensure libsodium is initialized before use. */
20+ async function sodium ( ) : Promise < typeof _sodium > {
21+ await _sodium . ready ;
22+ return _sodium ;
3523}
3624
3725/**
38- * Encrypt a message using a sealed box (crypto_box_seal equivalent ).
26+ * Encrypt a message using a sealed box (crypto_box_seal).
3927 *
40- * Returns base64-encoded ciphertext: ephemeral_pk (32 bytes) || box output.
28+ * Returns base64-encoded ciphertext ( ephemeral_pk || box output) .
4129 * Only the holder of `recipientPublicKey`'s corresponding private key can decrypt.
30+ *
31+ * Uses libsodium's native implementation with BLAKE2B nonce derivation
32+ * for interoperability with standard libsodium on the server side.
4233 */
4334export async function sealedBoxEncrypt (
4435 plaintext : Uint8Array ,
4536 recipientPublicKey : Uint8Array ,
4637) : Promise < string > {
47- const ephemeral = nacl . box . keyPair ( ) ;
48- const nonce = await deriveNonce ( ephemeral . publicKey , recipientPublicKey ) ;
49- const ciphertext = nacl . box ( plaintext , nonce , recipientPublicKey , ephemeral . secretKey ) ;
50-
51- if ( ! ciphertext ) {
52- throw new Error ( "sealedBoxEncrypt: encryption failed" ) ;
53- }
54-
55- // Output: ephemeral_pk || ciphertext
56- const sealed = new Uint8Array ( PUBLIC_KEY_BYTES + ciphertext . length ) ;
57- sealed . set ( ephemeral . publicKey , 0 ) ;
58- sealed . set ( ciphertext , PUBLIC_KEY_BYTES ) ;
38+ const s = await sodium ( ) ;
39+ const sealed = s . crypto_box_seal ( plaintext , recipientPublicKey ) ;
5940 return encodeBase64 ( sealed ) ;
6041}
6142
6243/**
63- * Decrypt a sealed box (crypto_box_seal_open equivalent ).
44+ * Decrypt a sealed box (crypto_box_seal_open).
6445 *
6546 * Used on the SERVER side (not in the broker for inbound messages).
6647 * Included here for testing and for potential future use.
@@ -70,20 +51,16 @@ export async function sealedBoxDecrypt(
7051 recipientPublicKey : Uint8Array ,
7152 recipientSecretKey : Uint8Array ,
7253) : Promise < Uint8Array > {
54+ const s = await sodium ( ) ;
7355 const sealed = decodeBase64 ( sealedBase64 ) ;
7456
75- if ( sealed . length < PUBLIC_KEY_BYTES + nacl . box . overheadLength ) {
57+ if ( sealed . length < PUBLIC_KEY_BYTES + s . crypto_box_MACBYTES ) {
7658 throw new Error ( "sealedBoxDecrypt: ciphertext too short" ) ;
7759 }
7860
79- const ephemeralPk = sealed . slice ( 0 , PUBLIC_KEY_BYTES ) ;
80- const ciphertext = sealed . slice ( PUBLIC_KEY_BYTES ) ;
81- const nonce = await deriveNonce ( ephemeralPk , recipientPublicKey ) ;
82- const plaintext = nacl . box . open ( ciphertext , nonce , ephemeralPk , recipientSecretKey ) ;
83-
84- if ( ! plaintext ) {
61+ try {
62+ return s . crypto_box_seal_open ( sealed , recipientPublicKey , recipientSecretKey ) ;
63+ } catch {
8564 throw new Error ( "sealedBoxDecrypt: decryption failed — invalid key or corrupted data" ) ;
8665 }
87-
88- return plaintext ;
8966}
0 commit comments