Skip to content

Commit

Permalink
Replace libsodium.js with noble
Browse files Browse the repository at this point in the history
  • Loading branch information
FiloSottile committed Dec 20, 2023
1 parent d074454 commit 968567f
Show file tree
Hide file tree
Showing 17 changed files with 215 additions and 297 deletions.
56 changes: 21 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
[`age-encryption`](https://www.npmjs.com/package/age-encryption) is a TypeScript implementation of the
[age](https://age-encryption.org) file encryption format.

All low-level cryptographic operations are implemented with [libsodium.js](https://github.com/jedisct1/libsodium.js).

## Installation

```sh
Expand All @@ -21,26 +19,21 @@ npm install age-encryption

`age-encryption` is a modern ES Module, compatible with Node.js and Bun, with built-in types.

There is a single exported function, `age()`, which returns a Promise that resolves to an object that provides the package API. This is necessary to ensure that applications always call `sodium.ready()` from libsodium.js.

#### Encrypt and decrypt a file with a new recipient / identity pair

```ts
import age from "age-encryption"
import * as age from "age-encryption"

// Initialize the age library (calls sodium.ready).
const { Encrypter, Decrypter, generateIdentity, identityToRecipient } = await age()

const identity = generateIdentity()
const recipient = identityToRecipient(identity)
const identity = age.generateIdentity()
const recipient = age.identityToRecipient(identity)
console.log(identity)
console.log(recipient)

const e = new Encrypter()
const e = new age.Encrypter()
e.addRecipient(recipient)
const ciphertext = e.encrypt("Hello, age!")

const d = new Decrypter()
const d = new age.Decrypter()
d.addIdentity(identity)
const out = d.decrypt(ciphertext, "text")
console.log(out)
Expand All @@ -49,10 +42,7 @@ console.log(out)
#### Encrypt and decrypt a file with a passphrase

```ts
import age from "age-encryption"

// Initialize the age library (calls sodium.ready).
const { Encrypter, Decrypter } = await age()
import { Encrypter, Decrypter } from "age-encryption"

const e = new Encrypter()
e.setPassphrase("burst-swarm-slender-curve-ability-various-crystal-moon-affair-three")
Expand All @@ -68,11 +58,11 @@ console.log(out)

`age-encryption` is compatible with modern bundlers such as [esbuild](https://esbuild.github.io/).

To produce a classic library file that sets `age()` as a global variable, you can run
To produce a classic library file that sets `age` as a global variable, you can run

```sh
cd "$(mktemp -d)" && npm init -y && npm install esbuild age-encryption
echo 'import age from "age-encryption"; globalThis.age = age' | \
echo 'import * as age from "age-encryption"; globalThis.age = age' | \
npx esbuild --target=es6 --bundle --minify --outfile=age.js
```

Expand All @@ -81,22 +71,18 @@ Then, you can use it like this
```html
<script src="age.js"></script>
<script>
age().then((age) => {
const identity = age.generateIdentity()
const recipient = age.identityToRecipient(identity)
console.log(identity)
console.log(recipient)
const e = new age.Encrypter()
e.addRecipient(recipient)
const ciphertext = e.encrypt("Hello, age!")
const d = new age.Decrypter()
d.addIdentity(identity)
const out = d.decrypt(ciphertext, "text")
console.log(out)
})
const identity = age.generateIdentity()
const recipient = age.identityToRecipient(identity)
console.log(identity)
console.log(recipient)
const e = new age.Encrypter()
e.addRecipient(recipient)
const ciphertext = e.encrypt("Hello, age!")
const d = new age.Decrypter()
d.addIdentity(identity)
const out = d.decrypt(ciphertext, "text")
console.log(out)
</script>
```

(Or, in a `script` with `type="module"`, you can use the top-level `await` syntax like in the examples above.)
25 changes: 18 additions & 7 deletions lib/format.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import sodium from "libsodium-wrappers-sumo"
const { base64_variants, from_base64, from_string, to_base64, to_string } = sodium
import { base64 } from "@scure/base"

export function decodeBase64(s: string): Uint8Array {
const paddedLen = Math.ceil(s.length / 4) * 4
const padded = s + "=".repeat(paddedLen - s.length)
const out = base64.decode(padded)
if (encodeBase64(out) !== s) {
throw Error("invalid base64 string")
}
return out
}

export const decodeBase64 = (s: string) => from_base64(s, base64_variants.ORIGINAL_NO_PADDING)
export const encodeBase64 = (s: Uint8Array) => to_base64(s, base64_variants.ORIGINAL_NO_PADDING)
export function encodeBase64(arr: Uint8Array): string {
const s = base64.encode(arr)
return s.replace(/=+$/, "")
}

export class Stanza {
readonly args: string[]
Expand All @@ -26,7 +37,7 @@ class ByteReader {
throw Error("invalid non-ASCII byte in header")
}
})
return to_string(bytes)
return new TextDecoder().decode(bytes)
}

readString(n: number): string {
Expand Down Expand Up @@ -151,12 +162,12 @@ export function encodeHeaderNoMAC(recipients: Stanza[]): Uint8Array {
}

lines.push("---")
return from_string(lines.join(""))
return new TextEncoder().encode(lines.join(""))
}

export function encodeHeader(recipients: Stanza[], MAC: Uint8Array): Uint8Array {
return flattenArray([
encodeHeaderNoMAC(recipients),
from_string(" " + encodeBase64(MAC) + "\n")
new TextEncoder().encode(" " + encodeBase64(MAC) + "\n")
])
}
23 changes: 0 additions & 23 deletions lib/hkdf.ts

This file was deleted.

101 changes: 46 additions & 55 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
import sodium from "libsodium-wrappers-sumo"
import { decode as decodeBech32, encode as encodeBech32 } from "bech32-buffer"
import { bech32 } from "@scure/base"
import { hmac } from "@noble/hashes/hmac"
import { hkdf } from "@noble/hashes/hkdf"
import { sha256 } from "@noble/hashes/sha256"
import { x25519 } from "@noble/curves/ed25519"
import { randomBytes } from "@noble/hashes/utils"
import { scryptUnwrap, scryptWrap, x25519Identity, x25519Unwrap, x25519Wrap } from "./recipients.js"
import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js"
import { decryptSTREAM, encryptSTREAM } from "./stream.js"
import { HKDF } from "./hkdf.js"

interface age {
Encrypter: new () => Encrypter;
Decrypter: new () => Decrypter;
generateIdentity: () => string;
identityToRecipient: (identity: string) => string;
export function generateIdentity(): string {
const scalar = randomBytes(32)
return bech32.encode("AGE-SECRET-KEY-", bech32.toWords(scalar)).toUpperCase()
}

let initDone = false

export default async function init(): Promise<age> {
if (!initDone) {
await sodium.ready
initDone = true
}
return {
Encrypter: Encrypter,
Decrypter: Decrypter,
generateIdentity: generateIdentity,
identityToRecipient: identityToRecipient,
}
}

function generateIdentity(): string {
const scalar = sodium.randombytes_buf(sodium.crypto_scalarmult_curve25519_SCALARBYTES)
return encodeBech32("AGE-SECRET-KEY-", scalar)
}

function identityToRecipient(identity: string): string {
const res = decodeBech32(identity)
export function identityToRecipient(identity: string): string {
const res = bech32.decodeToBytes(identity)
if (!identity.startsWith("AGE-SECRET-KEY-1") ||
res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" || res.encoding !== "bech32" ||
res.data.length !== sodium.crypto_scalarmult_curve25519_SCALARBYTES)
res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" ||
res.bytes.length !== 32)
throw Error("invalid identity")

const recipient = sodium.crypto_scalarmult_base(res.data)
return encodeBech32("age", recipient)
const recipient = x25519.scalarMultBase(res.bytes)
return bech32.encode("age", bech32.toWords(recipient))
}

class Encrypter {
export class Encrypter {
private passphrase: string | null = null
private scryptWorkFactor = 18
private recipients: Uint8Array[] = []
Expand All @@ -63,20 +44,20 @@ class Encrypter {
addRecipient(s: string): void {
if (this.passphrase !== null)
throw new Error("can't encrypt to both recipients and passphrases")
const res = decodeBech32(s)
const res = bech32.decodeToBytes(s)
if (!s.startsWith("age1") ||
res.prefix.toLowerCase() !== "age" || res.encoding !== "bech32" ||
res.data.length !== sodium.crypto_scalarmult_curve25519_BYTES)
res.prefix.toLowerCase() !== "age" ||
res.bytes.length !== 32)
throw Error("invalid recipient")
this.recipients.push(res.data)
this.recipients.push(res.bytes)
}

encrypt(file: Uint8Array | string): Uint8Array {
if (typeof file === "string") {
file = sodium.from_string(file)
file = new TextEncoder().encode(file)
}

const fileKey = sodium.randombytes_buf(16)
const fileKey = randomBytes(16)
const stanzas: Stanza[] = []

for (const recipient of this.recipients) {
Expand All @@ -86,12 +67,12 @@ class Encrypter {
stanzas.push(scryptWrap(fileKey, this.passphrase, this.scryptWorkFactor))
}

const hmacKey = HKDF(fileKey, null, "header")
const mac = sodium.crypto_auth_hmacsha256(encodeHeaderNoMAC(stanzas), hmacKey)
const hmacKey = hkdf(sha256, fileKey, undefined, "header", 32)
const mac = hmac(sha256, hmacKey, encodeHeaderNoMAC(stanzas))
const header = encodeHeader(stanzas, mac)

const nonce = sodium.randombytes_buf(16)
const streamKey = HKDF(fileKey, nonce, "payload")
const nonce = randomBytes(16)
const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32)
const payload = encryptSTREAM(streamKey, file)

const out = new Uint8Array(header.length + nonce.length + payload.length)
Expand All @@ -102,7 +83,7 @@ class Encrypter {
}
}

class Decrypter {
export class Decrypter {
private passphrases: string[] = []
private identities: x25519Identity[] = []

Expand All @@ -111,14 +92,14 @@ class Decrypter {
}

addIdentity(s: string): void {
const res = decodeBech32(s)
const res = bech32.decodeToBytes(s)
if (!s.startsWith("AGE-SECRET-KEY-1") ||
res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" || res.encoding !== "bech32" ||
res.data.length !== sodium.crypto_scalarmult_curve25519_SCALARBYTES)
res.prefix.toUpperCase() !== "AGE-SECRET-KEY-" ||
res.bytes.length !== 32)
throw Error("invalid identity")
this.identities.push({
identity: res.data,
recipient: sodium.crypto_scalarmult_base(res.data),
identity: res.bytes,
recipient: x25519.scalarMultBase(res.bytes),
})
}

Expand All @@ -131,17 +112,18 @@ class Decrypter {
throw Error("no identity matched any of the file's recipients")
}

const hmacKey = HKDF(fileKey, null, "header")
if (!sodium.crypto_auth_hmacsha256_verify(h.MAC, h.headerNoMAC, hmacKey)) {
const hmacKey = hkdf(sha256, fileKey, undefined, "header", 32)
const mac = hmac(sha256, hmacKey, h.headerNoMAC)
if (!compareBytes(h.MAC, mac)) {
throw Error("invalid header HMAC")
}

const nonce = h.rest.subarray(0, 16)
const streamKey = HKDF(fileKey, nonce, "payload")
const streamKey = hkdf(sha256, fileKey, nonce, "payload", 32)
const payload = h.rest.subarray(16)

const out = decryptSTREAM(streamKey, payload)
if (outputFormat === "text") return sodium.to_string(out)
if (outputFormat === "text") return new TextDecoder().decode(out)
return out
}

Expand All @@ -167,3 +149,12 @@ class Decrypter {
return null
}
}

function compareBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) { return false }
let acc = 0
for (let i = 0; i < a.length; i++) {
acc |= a[i] ^ b[i]
}
return acc === 0
}
Loading

0 comments on commit 968567f

Please sign in to comment.