Skip to content

Commit c61f203

Browse files
committed
Replace libsodium with noble
Libsodium has constant import errors and was always a problem
1 parent 9343083 commit c61f203

4 files changed

Lines changed: 112 additions & 41 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dependencies": {
4545
"@actions/languageserver": "^0.3.44",
4646
"@actions/workflow-parser": "^0.3.17",
47+
"@noble/ciphers": "^1.1.0",
4748
"@noble/curves": "^2.0.1",
4849
"@noble/hashes": "^2.0.1",
4950
"@octokit/core": "^7.0.6",
@@ -59,7 +60,6 @@
5960
"dayjs": "^1.11.13",
6061
"elliptic": "6.6.1",
6162
"fast-deep-equal": "^3.1.3",
62-
"libsodium-wrappers": "^0.8.2",
6363
"path-browserify": "^1.0.1",
6464
"ssh-config": "^3.0.1",
6565
"stream-browserify": "^3.0.0",

pnpm-lock.yaml

Lines changed: 9 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/secrets/index.test.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,56 @@
11
import assert from "node:assert/strict"
22
import { describe, it } from "node:test"
33

4-
import sodium_module from "libsodium-wrappers"
4+
import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa"
5+
import { u32 } from "@noble/ciphers/utils"
6+
import { x25519 } from "@noble/curves/ed25519.js"
7+
import { blake2b } from "@noble/hashes/blake2.js"
58

69
import { encodeSecret } from "~/secrets/index"
710

11+
// Salsa20 sigma constant: "expand 32-byte k"
12+
const SIGMA = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574])
13+
14+
function cryptoBoxBeforenm(sharedSecret: Uint8Array): Uint8Array {
15+
const zeroNonce = new Uint32Array(4)
16+
const key32 = u32(sharedSecret)
17+
const output32 = new Uint32Array(8)
18+
hsalsa(SIGMA, key32, zeroNonce, output32)
19+
return new Uint8Array(output32.buffer, output32.byteOffset, 32)
20+
}
21+
822
describe("secret encryption", () => {
923
it("encrypts secret correctly", async () => {
10-
// Ensure libsodium is ready (initializes WASM)
11-
await sodium_module.ready
12-
13-
const sodium = sodium_module as any
1424
const publicKey = "M2Kq4k1y9DiqlqLfm2YYm75x5M3SuwuNYbLyiHEMUAM="
1525
const privateKey = "RI2kKSjSOBmcjme5x8iv42Ozdu1rDo9QkaU2l+IFcrE="
1626

1727
const encrypted = await encodeSecret(publicKey, "secret-value")
1828

19-
// Decrypt to verify
20-
const encBytes = sodium.from_base64(encrypted, sodium.base64_variants.ORIGINAL)
21-
const publicKeyBytes = sodium.from_base64(publicKey, sodium.base64_variants.ORIGINAL)
22-
const privateKeyBytes = sodium.from_base64(privateKey, sodium.base64_variants.ORIGINAL)
29+
// Decrypt to verify (using libsodium-compatible crypto_box_seal_open)
30+
const sealedBox = Buffer.from(encrypted, "base64")
31+
const recipientPublicKey = Buffer.from(publicKey, "base64")
32+
const recipientPrivateKey = Buffer.from(privateKey, "base64")
33+
34+
// Extract ephemeral public key and ciphertext
35+
const ephemeralPublicKey = sealedBox.subarray(0, 32)
36+
const ciphertext = sealedBox.subarray(32)
37+
38+
// Compute X25519 shared secret
39+
const x25519SharedSecret = x25519.getSharedSecret(recipientPrivateKey, ephemeralPublicKey)
40+
41+
// Derive encryption key using HSalsa20 (crypto_box_beforenm)
42+
const encryptionKey = cryptoBoxBeforenm(x25519SharedSecret)
43+
44+
// Derive nonce (same as encryption)
45+
const nonceInput = new Uint8Array(64)
46+
nonceInput.set(ephemeralPublicKey, 0)
47+
nonceInput.set(recipientPublicKey, 32)
48+
const nonce = blake2b(nonceInput, { dkLen: 24 })
2349

24-
// Decrypt the secret using libsodium
25-
const decrypted = sodium.crypto_box_seal_open(encBytes, publicKeyBytes, privateKeyBytes)
50+
// Decrypt using XSalsa20-Poly1305
51+
const cipher = xsalsa20poly1305(encryptionKey, nonce)
52+
const decrypted = cipher.decrypt(ciphertext)
2653

27-
assert.strictEqual(sodium.to_string(decrypted), "secret-value")
54+
assert.strictEqual(new TextDecoder().decode(decrypted), "secret-value")
2855
})
2956
})

src/secrets/index.ts

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,71 @@
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"
26

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+
*/
334
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)
648

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)
951

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 })
1358

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)
1663

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)
1968

20-
return output
69+
// Convert to base64
70+
return Buffer.from(sealedBox).toString("base64")
2171
}

0 commit comments

Comments
 (0)