Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# @suaveplan/crypto-sodium — Changelog
## 0.0.0 (suaveplan)
Initial implementation on the suaveplan branch.
### Added
- `sodiumReady(): Promise<void>` — lazy WASM initialisation singleton.
- `sodiumAeadEngine` — XChaCha20-Poly1305 / ChaCha20-Poly1305 / AES-256-GCM AEAD engine.
- `sodiumAesGcmEngine` — AES-256-GCM–preferring AEAD engine (falls back to ChaCha20).
- `createSodiumAeadEngine(opts)` — factory for custom AEAD engine configurations.
- `sodiumSignEngine` — Ed25519 sign/verify/generateKeypair engine.
- `sodiumAgreeEngine` — X25519 Diffie-Hellman agree/generateKeypair engine.
- `sodiumSealedBoxEngine` — anonymous-sender sealed box (`crypto_box_seal`).
- `sodiumAuthBoxEngine` — authenticated box (`crypto_box_easy`).
- `cryptoKdf` — BLAKE2b-based deterministic subkey derivation.
- `cryptoAuth` / `cryptoAuthVerify` — BLAKE2b MAC.
- `cryptoPwhashStr` / `cryptoPwhashStrVerify` — Argon2id password hashing.
- `ristretto255` namespace — group operations (add, sub, mul, fromHash, toBytes, fromBytes, random).
- `SodiumError` + `SODIUM_CODES` — structured error surface.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,184 @@
{
"name": "@suaveplan/crypto-sodium",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "libsodium-backed engine implementations for @suaveplan/crypto \u2014 AEAD (XChaCha20-Poly1305, ChaCha20-Poly1305, AES-256-GCM), Ed25519 sign, X25519 agree, sealed-box, auth-box, KDF, BLAKE2b auth, Argon2id pwhash, Ristretto255 group ops.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./init": {
"import": {
"types": "./dist/init/init.d.ts",
"default": "./dist/init/init.js"
},
"require": {
"types": "./dist/init/init.d.ts",
"default": "./dist/init/init.js"
}
},
"./engines": {
"import": {
"types": "./dist/engines/index.d.ts",
"default": "./dist/engines/index.js"
},
"require": {
"types": "./dist/engines/index.d.ts",
"default": "./dist/engines/index.js"
}
},
"./engines/aead": {
"import": {
"types": "./dist/engines/sodium-aead-engine.d.ts",
"default": "./dist/engines/sodium-aead-engine.js"
},
"require": {
"types": "./dist/engines/sodium-aead-engine.d.ts",
"default": "./dist/engines/sodium-aead-engine.js"
}
},
"./engines/sign": {
"import": {
"types": "./dist/engines/sodium-sign-engine.d.ts",
"default": "./dist/engines/sodium-sign-engine.js"
},
"require": {
"types": "./dist/engines/sodium-sign-engine.d.ts",
"default": "./dist/engines/sodium-sign-engine.js"
}
},
"./engines/agree": {
"import": {
"types": "./dist/engines/sodium-agree-engine.d.ts",
"default": "./dist/engines/sodium-agree-engine.js"
},
"require": {
"types": "./dist/engines/sodium-agree-engine.d.ts",
"default": "./dist/engines/sodium-agree-engine.js"
}
},
"./engines/sealed-box": {
"import": {
"types": "./dist/engines/sodium-sealed-box-engine.d.ts",
"default": "./dist/engines/sodium-sealed-box-engine.js"
},
"require": {
"types": "./dist/engines/sodium-sealed-box-engine.d.ts",
"default": "./dist/engines/sodium-sealed-box-engine.js"
}
},
"./engines/auth-box": {
"import": {
"types": "./dist/engines/sodium-auth-box-engine.d.ts",
"default": "./dist/engines/sodium-auth-box-engine.js"
},
"require": {
"types": "./dist/engines/sodium-auth-box-engine.d.ts",
"default": "./dist/engines/sodium-auth-box-engine.js"
}
},
"./primitives/kdf": {
"import": {
"types": "./dist/primitives/crypto-kdf.d.ts",
"default": "./dist/primitives/crypto-kdf.js"
},
"require": {
"types": "./dist/primitives/crypto-kdf.d.ts",
"default": "./dist/primitives/crypto-kdf.js"
}
},
"./primitives/auth": {
"import": {
"types": "./dist/primitives/crypto-auth.d.ts",
"default": "./dist/primitives/crypto-auth.js"
},
"require": {
"types": "./dist/primitives/crypto-auth.d.ts",
"default": "./dist/primitives/crypto-auth.js"
}
},
"./primitives/pwhash": {
"import": {
"types": "./dist/primitives/crypto-pwhash.d.ts",
"default": "./dist/primitives/crypto-pwhash.js"
},
"require": {
"types": "./dist/primitives/crypto-pwhash.d.ts",
"default": "./dist/primitives/crypto-pwhash.js"
}
},
"./primitives/ristretto255": {
"import": {
"types": "./dist/primitives/ristretto255.d.ts",
"default": "./dist/primitives/ristretto255.js"
},
"require": {
"types": "./dist/primitives/ristretto255.d.ts",
"default": "./dist/primitives/ristretto255.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"libsodium-wrappers-sumo": "catalog:crypto",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/libsodium-wrappers": "catalog:crypto",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,32 @@
/** Size of a libsodium KDF master key in bytes. */
export const SODIUM_KDF_KEY_BYTES = 32;
/** Required context string byte-length for `crypto_kdf_derive_from_key`. */
export const SODIUM_KDF_CONTEXT_BYTES = 8;
/** MAC output size in bytes from `crypto_auth`. */
export const SODIUM_AUTH_BYTES = 32;
/** Key size required by `crypto_auth`. */
export const SODIUM_AUTH_KEY_BYTES = 32;
/** Public key size for libsodium box / sealed-box operations. */
export const SODIUM_BOX_PUBLIC_KEY_BYTES = 32;
/** Secret key size for libsodium box / sealed-box operations. */
export const SODIUM_BOX_SECRET_KEY_BYTES = 32;
/** Nonce size for `crypto_box_easy`. */
export const SODIUM_BOX_NONCE_BYTES = 24;
/** Ed25519 public key size in bytes. */
export const SODIUM_SIGN_PUBLIC_KEY_BYTES = 32;
/** Ed25519 secret key size in bytes (includes seed + public key). */
export const SODIUM_SIGN_SECRET_KEY_BYTES = 64;
/** Ed25519 signature size in bytes. */
export const SODIUM_SIGN_BYTES = 64;
/** Ristretto255 group element size in bytes. */
export const SODIUM_RISTRETTO255_BYTES = 32;

View File

@@ -0,0 +1,10 @@
export {
createSodiumAeadEngine,
type SodiumAeadOptions,
sodiumAeadEngine,
sodiumAesGcmEngine,
} from "./sodium-aead-engine/sodium-aead-engine.js";
export { sodiumAgreeEngine } from "./sodium-agree-engine/sodium-agree-engine.js";
export { sodiumAuthBoxEngine } from "./sodium-auth-box-engine/sodium-auth-box-engine.js";
export { sodiumSealedBoxEngine } from "./sodium-sealed-box-engine/sodium-sealed-box-engine.js";
export { sodiumSignEngine } from "./sodium-sign-engine/sodium-sign-engine.js";

View File

@@ -0,0 +1,6 @@
export {
createSodiumAeadEngine,
type SodiumAeadOptions,
sodiumAeadEngine,
sodiumAesGcmEngine,
} from "./sodium-aead-engine.js";

View File

@@ -0,0 +1,96 @@
# engines/sodium-aead-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumAeadEngine` is a libsodium-backed implementation of the `AeadEngine` interface from `@suaveplan/crypto`. It provides authenticated encryption with associated data (AEAD) via three algorithms selectable by nonce length: XChaCha20-Poly1305 (24-byte nonce), ChaCha20-Poly1305 IETF (12-byte nonce), and AES-256-GCM (12-byte nonce, hardware only).
The engine is a drop-in for the noble-based default engine in `@suaveplan/crypto`. It uses libsodium's WASM implementation, which uses the platform's native AES-GCM hardware acceleration when available and detectable via `sodium.crypto_aead_aes256gcm_is_available()`.
## Features
- XChaCha20-Poly1305 IETF via `crypto_aead_xchacha20poly1305_ietf_*` — 24-byte nonce, resistant to nonce-reuse compared to 12-byte variants.
- ChaCha20-Poly1305 IETF via `crypto_aead_chacha20poly1305_ietf_*` — 12-byte nonce, default for 12-byte nonce paths.
- AES-256-GCM via `crypto_aead_aes256gcm_*` — hardware-accelerated path enabled with `preferAes: true`.
- Associated data (AAD) support — empty AAD is used when `aad` is omitted.
- Throws `SodiumError` with code `SODIUM.INIT.FAILED` on authentication tag mismatch during decryption.
- Factory function `createSodiumAeadEngine` for custom configurations; singleton exports for convenience.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAeadEngine } from "@suaveplan/crypto-sodium/engines/aead";
await sodiumReady();
const key = crypto.getRandomValues(new Uint8Array(32));
const nonce = crypto.getRandomValues(new Uint8Array(24)); // XChaCha20
const plaintext = new TextEncoder().encode("secret message");
const ciphertext = sodiumAeadEngine.encrypt(key, plaintext, nonce);
const recovered = sodiumAeadEngine.decrypt(key, ciphertext, nonce);
console.log(new TextDecoder().decode(recovered)); // "secret message"
```
## Usage: Advanced Example
Using AAD and the AES-256-GCM–preferring engine:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import {
sodiumAesGcmEngine,
createSodiumAeadEngine,
} from "@suaveplan/crypto-sodium/engines/aead";
await sodiumReady();
const key = crypto.getRandomValues(new Uint8Array(32));
const nonce = crypto.getRandomValues(new Uint8Array(12)); // ChaCha20 or AES-GCM
const plaintext = new TextEncoder().encode("data");
const aad = new TextEncoder().encode("header-v1");
// Prefer AES-GCM when hardware is available, fall back to ChaCha20
const ct = sodiumAesGcmEngine.encrypt(key, plaintext, nonce, aad);
const pt = sodiumAesGcmEngine.decrypt(key, ct, nonce, aad);
// Or create a custom engine:
const customEngine = createSodiumAeadEngine({ preferAes: false });
```
## API Reference
### `sodiumAeadEngine: AeadEngine`
Default engine using XChaCha20-Poly1305 (24-byte nonce) and ChaCha20-Poly1305 IETF (12-byte nonce). `preferAes` is `false`.
### `sodiumAesGcmEngine: AeadEngine`
Engine that prefers AES-256-GCM for 12-byte nonces when the platform supports it; falls back to ChaCha20-Poly1305.
### `createSodiumAeadEngine(options?: SodiumAeadOptions): AeadEngine`
Creates a configured engine.
**`SodiumAeadOptions`:**
- `preferAes?: boolean` — default `false`. When `true`, routes 12-byte nonces through `crypto_aead_aes256gcm_*` if hardware is available.
### `AeadEngine.encrypt(key, plaintext, nonce, aad?): Uint8Array`
Encrypts `plaintext` and returns `ciphertext || tag`.
### `AeadEngine.decrypt(key, ciphertext, nonce, aad?): Uint8Array`
Decrypts and authenticates. Throws `SodiumError` (`SODIUM.INIT.FAILED`) if authentication fails.
## Implementation Notes
Algorithm routing is determined by nonce length at call time, not at engine-creation time. A 24-byte nonce always routes to XChaCha20-Poly1305; a 12-byte nonce routes to AES-256-GCM (when `preferAes` and available) or ChaCha20-Poly1305. This mirrors how `@suaveplan/crypto`'s `aeadEncrypt` picks algorithms.
libsodium WASM does not expose software AES-GCM — `crypto_aead_aes256gcm_is_available()` returns `false` on platforms without hardware AES-NI. For maximum portability, leave `preferAes: false` and use XChaCha20-Poly1305 with 24-byte nonces.
The `assertReady()` guard checks a sodium constant (`crypto_aead_xchacha20poly1305_ietf_KEYBYTES`) synchronously. If undefined, the WASM has not initialised yet — callers must await `sodiumReady()` first.

View File

@@ -0,0 +1,126 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
import { sodiumReady } from "../../init/init.js";
import {
createSodiumAeadEngine,
sodiumAeadEngine,
sodiumAesGcmEngine,
} from "./sodium-aead-engine.js";
const KEY = new Uint8Array(32).fill(0xab);
const XCHACHA_NONCE = new Uint8Array(24).fill(0x01);
const CHACHA_NONCE = new Uint8Array(12).fill(0x02);
const PLAINTEXT = new TextEncoder().encode("hello sodium aead");
const AAD = new TextEncoder().encode("additional data");
describe("sodiumAeadEngine — XChaCha20-Poly1305", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("encrypts and decrypts round-trip without AAD", {
timeout: 30_000,
}, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, XCHACHA_NONCE);
const pt = sodiumAeadEngine.decrypt(KEY, ct, XCHACHA_NONCE);
expect(pt).toEqual(PLAINTEXT);
});
it("encrypts and decrypts round-trip with AAD", { timeout: 30_000 }, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, XCHACHA_NONCE, AAD);
const pt = sodiumAeadEngine.decrypt(KEY, ct, XCHACHA_NONCE, AAD);
expect(pt).toEqual(PLAINTEXT);
});
it("ciphertext differs from plaintext", { timeout: 30_000 }, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, XCHACHA_NONCE);
expect(ct).not.toEqual(PLAINTEXT);
expect(ct.length).toBeGreaterThan(PLAINTEXT.length);
});
it("throws SodiumError on authentication failure", {
timeout: 30_000,
}, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, XCHACHA_NONCE);
ct[0] ^= 0xff; // corrupt
expect(() => sodiumAeadEngine.decrypt(KEY, ct, XCHACHA_NONCE)).toThrow(
SodiumError
);
});
it("round-trip property — XChaCha20 nonce", { timeout: 30_000 }, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 64 }),
(msg) => {
const ct = sodiumAeadEngine.encrypt(
KEY,
msg,
XCHACHA_NONCE
);
const pt = sodiumAeadEngine.decrypt(KEY, ct, XCHACHA_NONCE);
return (
pt.length === msg.length &&
pt.every((b, i) => b === msg[i])
);
}
),
{ numRuns: 20 }
);
});
});
describe("sodiumAeadEngine — ChaCha20-Poly1305 (12-byte nonce)", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("encrypts and decrypts round-trip", { timeout: 30_000 }, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, CHACHA_NONCE);
const pt = sodiumAeadEngine.decrypt(KEY, ct, CHACHA_NONCE);
expect(pt).toEqual(PLAINTEXT);
});
it("round-trip with AAD", { timeout: 30_000 }, () => {
const ct = sodiumAeadEngine.encrypt(KEY, PLAINTEXT, CHACHA_NONCE, AAD);
const pt = sodiumAeadEngine.decrypt(KEY, ct, CHACHA_NONCE, AAD);
expect(pt).toEqual(PLAINTEXT);
});
});
describe("sodiumAesGcmEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("encrypts and decrypts round-trip (AES-GCM or ChaCha20 fallback)", {
timeout: 30_000,
}, () => {
const ct = sodiumAesGcmEngine.encrypt(KEY, PLAINTEXT, CHACHA_NONCE);
const pt = sodiumAesGcmEngine.decrypt(KEY, ct, CHACHA_NONCE);
expect(pt).toEqual(PLAINTEXT);
});
});
describe("createSodiumAeadEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("creates a functional engine with preferAes=false", {
timeout: 30_000,
}, () => {
const engine = createSodiumAeadEngine({ preferAes: false });
const ct = engine.encrypt(KEY, PLAINTEXT, XCHACHA_NONCE);
expect(engine.decrypt(KEY, ct, XCHACHA_NONCE)).toEqual(PLAINTEXT);
});
it("creates a functional engine with preferAes=true", {
timeout: 30_000,
}, () => {
const engine = createSodiumAeadEngine({ preferAes: true });
const ct = engine.encrypt(KEY, PLAINTEXT, CHACHA_NONCE);
expect(engine.decrypt(KEY, ct, CHACHA_NONCE)).toEqual(PLAINTEXT);
});
});

View File

@@ -0,0 +1,111 @@
import type { AeadEngine } from "@suaveplan/crypto-core/engines";
import * as sodium from "libsodium-wrappers-sumo";
import { SODIUM_CODES } from "../../errors/codes/codes.js";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
/**
* Algorithm routing by nonce length:
* - 24 bytes → XChaCha20-Poly1305 IETF (recommended — larger nonce space)
* - 12 bytes → ChaCha20-Poly1305 IETF
*
* Note: AES-256-GCM is present at the WASM level in libsodium-sumo but is
* not exposed through the JS wrapper API in the 0.7.x series. The `preferAes`
* option is accepted for API compatibility but currently routes to
* ChaCha20-Poly1305 on all platforms.
*/
export interface SodiumAeadOptions {
/**
* Reserved for future use when libsodium-wrappers-sumo exposes AES-256-GCM
* at the JS level. Currently has no effect; all 12-byte nonces use
* ChaCha20-Poly1305 IETF regardless of this setting.
*/
readonly preferAes?: boolean;
}
/**
* Creates a libsodium-backed `AeadEngine`.
*
* - 24-byte nonces always route to XChaCha20-Poly1305 IETF.
* - 12-byte nonces route to ChaCha20-Poly1305 IETF.
*
* `sodiumReady()` must have been awaited before calling `encrypt`/`decrypt`.
*/
export function createSodiumAeadEngine(
options: SodiumAeadOptions = {}
): AeadEngine {
// Acknowledge the option to satisfy the type constraint; AES path deferred.
const _preferAes = options.preferAes ?? false;
void _preferAes;
return {
encrypt(
key: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
const ad = aad ?? new Uint8Array(0);
if (nonce.length === 24) {
return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
ad,
null,
nonce,
key
);
}
return sodium.crypto_aead_chacha20poly1305_ietf_encrypt(
plaintext,
ad,
null,
nonce,
key
);
},
decrypt(
key: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
const ad = aad ?? new Uint8Array(0);
try {
if (nonce.length === 24) {
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null,
ciphertext,
ad,
nonce,
key
);
}
return sodium.crypto_aead_chacha20poly1305_ietf_decrypt(
null,
ciphertext,
ad,
nonce,
key
);
} catch (cause) {
throw new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"sodium.aead.decrypt.failed",
{ cause }
);
}
},
};
}
/** Default XChaCha20-Poly1305 / ChaCha20-Poly1305 IETF engine instance. */
export const sodiumAeadEngine: AeadEngine = createSodiumAeadEngine();
/**
* Convenience alias for `sodiumAeadEngine`. When AES-256-GCM becomes
* accessible via the JS wrapper, this instance will be updated to prefer it.
*/
export const sodiumAesGcmEngine: AeadEngine = createSodiumAeadEngine({
preferAes: true,
});

View File

@@ -0,0 +1 @@
export { sodiumAgreeEngine } from "./sodium-agree-engine.js";

View File

@@ -0,0 +1,81 @@
# engines/sodium-agree-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumAgreeEngine` implements the `AgreeEngine` interface from `@suaveplan/crypto` using libsodium's X25519 Diffie-Hellman key agreement. X25519 is the modern industry-standard ECDH scheme used in TLS 1.3, Signal, Noise Protocol, and WireGuard.
The engine produces a raw 32-byte shared secret from two Curve25519 keypairs. Callers are responsible for deriving a symmetric key from this shared secret — use `cryptoKdf` or a KDF such as HKDF for this purpose.
## Features
- X25519 keypair generation via `crypto_kx_keypair` — Curve25519 points compatible with `crypto_scalarmult`.
- Diffie-Hellman shared secret via `crypto_scalarmult` — raw X25519 output, 32 bytes.
- Conforms to `AgreeEngine` from `@suaveplan/crypto/engines` — pluggable into any higher-level key-exchange protocol.
- `sodiumReady()` must be awaited before first use.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
await sodiumReady();
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const aliceShared = sodiumAgreeEngine.agree(alice.secretKey, bob.publicKey);
const bobShared = sodiumAgreeEngine.agree(bob.secretKey, alice.publicKey);
// aliceShared deepEquals bobShared — both parties derived the same secret
```
## Usage: Advanced Example
Combining X25519 agree with KDF for a symmetric key:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
import { cryptoKdf } from "@suaveplan/crypto-sodium/primitives/kdf";
await sodiumReady();
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
// Raw shared secret — not safe to use directly as a key
const rawSecret = sodiumAgreeEngine.agree(alice.secretKey, bob.publicKey);
// Derive a 32-byte encryption key via KDF
const encKey = await cryptoKdf(rawSecret, 1, "EncryptN", 32);
```
## API Reference
### `sodiumAgreeEngine: AgreeEngine`
Singleton engine object.
### `generateKeypair(byteSource?: ByteSource): AgreeKeypair`
Generates a fresh Curve25519 keypair. `byteSource` is accepted for interface compatibility but ignored.
**Returns:** `{ publicKey: Uint8Array (32 bytes), secretKey: Uint8Array (32 bytes) }`
### `agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array`
Performs X25519 Diffie-Hellman.
**Returns:** 32-byte raw shared secret.
## Implementation Notes
`crypto_kx_keypair` produces Curve25519 keypairs that are fully compatible with `crypto_scalarmult`. The `crypto_kx_*` family includes session-key derivation helpers, but this engine uses `crypto_scalarmult` directly to return the raw shared secret, giving callers full control over key derivation.
The raw X25519 output is a group element, not a uniformly random key. It MUST be passed through a KDF before use in a symmetric cipher. See `cryptoKdf` in `@suaveplan/crypto-sodium/primitives/kdf` for the libsodium-native option.
`byteSource` is not forwarded to libsodium for the same reason as in `sodiumSignEngine` — libsodium's WASM manages entropy internally.

View File

@@ -0,0 +1,58 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { sodiumReady } from "../../init/init.js";
import { sodiumAgreeEngine } from "./sodium-agree-engine.js";
describe("sodiumAgreeEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("generateKeypair returns 32-byte public and secret keys", {
timeout: 30_000,
}, () => {
const kp = sodiumAgreeEngine.generateKeypair();
expect(kp.publicKey.length).toBe(32);
expect(kp.secretKey.length).toBe(32);
});
it("agree produces the same shared secret for both parties", {
timeout: 30_000,
}, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const aliceShared = sodiumAgreeEngine.agree(
alice.secretKey,
bob.publicKey
);
const bobShared = sodiumAgreeEngine.agree(
bob.secretKey,
alice.publicKey
);
expect(aliceShared).toEqual(bobShared);
});
it("shared secret is 32 bytes", { timeout: 30_000 }, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const shared = sodiumAgreeEngine.agree(alice.secretKey, bob.publicKey);
expect(shared.length).toBe(32);
});
it("different keypairs produce different shared secrets", {
timeout: 30_000,
}, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const charlie = sodiumAgreeEngine.generateKeypair();
const shared1 = sodiumAgreeEngine.agree(alice.secretKey, bob.publicKey);
const shared2 = sodiumAgreeEngine.agree(
alice.secretKey,
charlie.publicKey
);
expect(shared1).not.toEqual(shared2);
});
});

View File

@@ -0,0 +1,24 @@
import type { AgreeEngine, AgreeKeypair } from "@suaveplan/crypto-core/engines";
import * as sodium from "libsodium-wrappers-sumo";
/**
* libsodium-backed X25519 `AgreeEngine`.
*
* Uses `crypto_kx_keypair` for key generation and `crypto_scalarmult` /
* `crypto_scalarmult_base` for the Diffie-Hellman shared secret. The raw
* X25519 shared secret is returned; callers are responsible for deriving a
* symmetric key from it (e.g., via `cryptoKdf` or HKDF).
*
* `sodiumReady()` must have been awaited before first use.
*/
export const sodiumAgreeEngine: AgreeEngine = {
generateKeypair(): AgreeKeypair {
// crypto_kx_keypair produces Curve25519 compatible with scalarmult
const kp = sodium.crypto_kx_keypair();
return { publicKey: kp.publicKey, secretKey: kp.privateKey };
},
agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array {
return sodium.crypto_scalarmult(mySecret, theirPublic);
},
};

View File

@@ -0,0 +1 @@
export { sodiumAuthBoxEngine } from "./sodium-auth-box-engine.js";

View File

@@ -0,0 +1,110 @@
# engines/sodium-auth-box-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumAuthBoxEngine` implements the `AuthBoxEngine` interface from `@suaveplan/crypto` using libsodium's `crypto_box_easy` and `crypto_box_open_easy`. This construction provides authenticated key-exchange encryption: both the sender and recipient are authenticated because the shared secret is derived from a static X25519 DH between sender's secret and recipient's public key, then used to encrypt with XSalsa20-Poly1305.
Unlike `sodiumSealedBoxEngine`, the sender's public key is not included in the ciphertext — the recipient must already know who sent the message (i.e., possess the sender's public key independently).
## Features
- Mutually authenticated encryption using `crypto_box_easy` / `crypto_box_open_easy`.
- XSalsa20-Poly1305 authenticated encryption via X25519 key agreement.
- 24-byte explicit nonce required from caller.
- 16-byte MAC overhead in the ciphertext.
- Throws `SodiumError` on authentication failure during `open`.
- Conforms to `AuthBoxEngine` from `@suaveplan/crypto/engines`.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
import { sodiumAuthBoxEngine } from "@suaveplan/crypto-sodium/engines/auth-box";
await sodiumReady();
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const nonce = crypto.getRandomValues(new Uint8Array(24));
const msg = new TextEncoder().encode("authenticated message");
// Alice sends to Bob
const ct = sodiumAuthBoxEngine.seal(
alice.secretKey,
bob.publicKey,
msg,
nonce
);
// Bob receives from Alice
const pt = sodiumAuthBoxEngine.open(
alice.publicKey,
bob.secretKey,
ct,
nonce
);
console.log(new TextDecoder().decode(pt)); // "authenticated message"
```
## Usage: Advanced Example
Bidirectional messaging channel with distinct nonce counters per direction:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
import { sodiumAuthBoxEngine } from "@suaveplan/crypto-sodium/engines/auth-box";
await sodiumReady();
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
function incrementNonce(nonce: Uint8Array): Uint8Array {
const n = nonce.slice();
for (let i = n.length - 1; i >= 0; i--) {
if (++n[i]) break;
}
return n;
}
let aliceNonce = new Uint8Array(24);
const ct1 = sodiumAuthBoxEngine.seal(
alice.secretKey, bob.publicKey,
new TextEncoder().encode("msg 1"), aliceNonce
);
aliceNonce = incrementNonce(aliceNonce);
```
## API Reference
### `sodiumAuthBoxEngine: AuthBoxEngine`
Singleton engine.
### `seal(senderSecret, recipientPublic, plaintext, nonce): Uint8Array`
Encrypts `plaintext` using `crypto_box_easy`. Returns ciphertext with 16-byte MAC prepended.
**Parameters:**
- `senderSecret` — sender's 32-byte secret key.
- `recipientPublic` — recipient's 32-byte public key.
- `plaintext` — message bytes.
- `nonce` — 24-byte nonce (must be unique per message under the same keypair).
### `open(senderPublic, recipientSecret, ciphertext, nonce): Uint8Array`
Decrypts and authenticates. Both keys must correspond to the same exchange as `seal`.
**Throws:** `SodiumError` (`SODIUM.INIT.FAILED`) if MAC verification fails.
## Implementation Notes
`crypto_box_easy` computes `Curve25519(senderSecret, recipientPublic)` as the shared secret and uses HSalsa20 as KDF before XSalsa20-Poly1305. Nonces must be 24 bytes. The nonce management strategy (random, counter, or derived) is left to the caller — each (keypair, nonce) pair must be used at most once for confidentiality.
The MAC overhead is 16 bytes (`crypto_box_MACBYTES`). The nonce must be transmitted alongside the ciphertext; a common convention is to prepend it to the ciphertext.

View File

@@ -0,0 +1,100 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
import { sodiumReady } from "../../init/init.js";
import { sodiumAgreeEngine } from "../sodium-agree-engine/sodium-agree-engine.js";
import { sodiumAuthBoxEngine } from "./sodium-auth-box-engine.js";
const NONCE = new Uint8Array(24).fill(0x03);
describe("sodiumAuthBoxEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("seal/open round-trip", { timeout: 30_000 }, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("authenticated box message");
const ct = sodiumAuthBoxEngine.seal(
alice.secretKey,
bob.publicKey,
msg,
NONCE
);
const pt = sodiumAuthBoxEngine.open(
alice.publicKey,
bob.secretKey,
ct,
NONCE
);
expect(pt).toEqual(msg);
});
it("ciphertext is longer than plaintext (MAC overhead)", {
timeout: 30_000,
}, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("x");
const ct = sodiumAuthBoxEngine.seal(
alice.secretKey,
bob.publicKey,
msg,
NONCE
);
expect(ct.length).toBeGreaterThan(msg.length);
});
it("throws SodiumError when opening with wrong key", {
timeout: 30_000,
}, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
const wrong = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("auth box");
const ct = sodiumAuthBoxEngine.seal(
alice.secretKey,
bob.publicKey,
msg,
NONCE
);
expect(() =>
sodiumAuthBoxEngine.open(wrong.publicKey, bob.secretKey, ct, NONCE)
).toThrow(SodiumError);
});
it("round-trip property", { timeout: 30_000 }, () => {
const alice = sodiumAgreeEngine.generateKeypair();
const bob = sodiumAgreeEngine.generateKeypair();
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 64 }),
(msg) => {
const ct = sodiumAuthBoxEngine.seal(
alice.secretKey,
bob.publicKey,
msg,
NONCE
);
const pt = sodiumAuthBoxEngine.open(
alice.publicKey,
bob.secretKey,
ct,
NONCE
);
return (
pt.length === msg.length &&
pt.every((b, i) => b === msg[i])
);
}
),
{ numRuns: 15 }
);
});
});

View File

@@ -0,0 +1,52 @@
import type { AuthBoxEngine } from "@suaveplan/crypto-core/engines";
import * as sodium from "libsodium-wrappers-sumo";
import { SODIUM_CODES } from "../../errors/codes/codes.js";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
/**
* libsodium-backed authenticated `AuthBoxEngine`.
*
* Wraps `crypto_box_easy` and `crypto_box_open_easy`. Both parties are
* authenticated — the sender signs with their secret key, and the recipient
* verifies with the sender's public key. Key agreement uses X25519; the
* stream cipher is XSalsa20-Poly1305.
*
* `sodiumReady()` must have been awaited before first use.
*/
export const sodiumAuthBoxEngine: AuthBoxEngine = {
seal(
senderSecret: Uint8Array,
recipientPublic: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array
): Uint8Array {
return sodium.crypto_box_easy(
plaintext,
nonce,
recipientPublic,
senderSecret
);
},
open(
senderPublic: Uint8Array,
recipientSecret: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array
): Uint8Array {
try {
return sodium.crypto_box_open_easy(
ciphertext,
nonce,
senderPublic,
recipientSecret
);
} catch (cause) {
throw new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"sodium.auth-box.open.failed",
{ cause }
);
}
},
};

View File

@@ -0,0 +1 @@
export { sodiumSealedBoxEngine } from "./sodium-sealed-box-engine.js";

View File

@@ -0,0 +1,98 @@
# engines/sodium-sealed-box-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumSealedBoxEngine` implements the `SealedBoxEngine` interface from `@suaveplan/crypto` using libsodium's `crypto_box_seal` and `crypto_box_seal_open`. This construction provides anonymous-sender encryption: the sender generates an ephemeral X25519 keypair, performs a Diffie-Hellman with the recipient's public key, derives a shared secret, and encrypts with XSalsa20-Poly1305. The ephemeral keypair is discarded after encryption.
Because the sender's identity is ephemeral and included only implicitly in the ciphertext, the recipient cannot determine who sent the message. Use `sodiumAuthBoxEngine` when sender authentication is required.
## Features
- Anonymous-sender AEAD using `crypto_box_seal` / `crypto_box_seal_open`.
- Ephemeral X25519 key exchange — sender identity is not revealed.
- XSalsa20-Poly1305 authenticated encryption under the hood.
- Ciphertext overhead: 48 bytes (32 ephemeral public key + 16 MAC).
- Throws `SodiumError` on authentication failure during `open`.
- Conforms to `SealedBoxEngine` from `@suaveplan/crypto/engines`.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
import { sodiumSealedBoxEngine } from "@suaveplan/crypto-sodium/engines/sealed-box";
await sodiumReady();
const recipientKp = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("anonymous message");
const ct = sodiumSealedBoxEngine.seal(recipientKp.publicKey, msg);
const pt = sodiumSealedBoxEngine.open(
recipientKp.secretKey,
recipientKp.publicKey,
ct
);
console.log(new TextDecoder().decode(pt)); // "anonymous message"
```
## Usage: Advanced Example
Sealed-box used as an encrypted submission envelope:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumAgreeEngine } from "@suaveplan/crypto-sodium/engines/agree";
import { sodiumSealedBoxEngine } from "@suaveplan/crypto-sodium/engines/sealed-box";
import { SodiumError } from "@suaveplan/crypto-sodium/errors";
await sodiumReady();
const serverKp = sodiumAgreeEngine.generateKeypair();
// Client submits encrypted payload — server public key is published
function clientSubmit(serverPub: Uint8Array, data: Uint8Array): Uint8Array {
return sodiumSealedBoxEngine.seal(serverPub, data);
}
// Server decrypts — only it holds the secret key
function serverReceive(ct: Uint8Array): Uint8Array {
try {
return sodiumSealedBoxEngine.open(
serverKp.secretKey,
serverKp.publicKey,
ct
);
} catch (err) {
if (err instanceof SodiumError) {
throw new Error("Tampered submission rejected");
}
throw err;
}
}
```
## API Reference
### `sodiumSealedBoxEngine: SealedBoxEngine`
Singleton engine.
### `seal(recipientPublic: Uint8Array, plaintext: Uint8Array): Uint8Array`
Encrypts `plaintext` to the recipient's public key. Returns `ephemeralPublicKey (32 bytes) || ciphertext+MAC`.
### `open(recipientSecret: Uint8Array, recipientPublic: Uint8Array, ciphertext: Uint8Array): Uint8Array`
Opens a sealed box. Both `recipientSecret` and `recipientPublic` are required because libsodium uses both for the Diffie-Hellman computation.
**Throws:** `SodiumError` (`SODIUM.INIT.FAILED`) if the MAC verification fails.
## Implementation Notes
libsodium's `crypto_box_seal_open` requires both keys to reconstruct the X25519 shared secret from the ephemeral public key embedded in the ciphertext. The function throws natively on authentication failure; this engine catches that and wraps it in a `SodiumError` for uniform error handling.
The ciphertext overhead is always exactly 48 bytes: 32 bytes for the sender's ephemeral public key prepended to the ciphertext, and 16 bytes for the Poly1305 authentication tag.

View File

@@ -0,0 +1,63 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
import { sodiumReady } from "../../init/init.js";
import { sodiumAgreeEngine } from "../sodium-agree-engine/sodium-agree-engine.js";
import { sodiumSealedBoxEngine } from "./sodium-sealed-box-engine.js";
describe("sodiumSealedBoxEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("seal/open round-trip", { timeout: 30_000 }, () => {
const kp = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("sealed message");
const ct = sodiumSealedBoxEngine.seal(kp.publicKey, msg);
const pt = sodiumSealedBoxEngine.open(kp.secretKey, kp.publicKey, ct);
expect(pt).toEqual(msg);
});
it("ciphertext is longer than plaintext (includes ephemeral pubkey + MAC)", {
timeout: 30_000,
}, () => {
const kp = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("x");
const ct = sodiumSealedBoxEngine.seal(kp.publicKey, msg);
expect(ct.length).toBeGreaterThan(msg.length);
});
it("throws SodiumError when opening with wrong secret key", {
timeout: 30_000,
}, () => {
const kp = sodiumAgreeEngine.generateKeypair();
const wrong = sodiumAgreeEngine.generateKeypair();
const msg = new TextEncoder().encode("sealed");
const ct = sodiumSealedBoxEngine.seal(kp.publicKey, msg);
expect(() =>
sodiumSealedBoxEngine.open(wrong.secretKey, kp.publicKey, ct)
).toThrow(SodiumError);
});
it("round-trip property", { timeout: 30_000 }, () => {
const kp = sodiumAgreeEngine.generateKeypair();
fc.assert(
fc.property(
fc.uint8Array({ minLength: 0, maxLength: 64 }),
(msg) => {
const ct = sodiumSealedBoxEngine.seal(kp.publicKey, msg);
const pt = sodiumSealedBoxEngine.open(
kp.secretKey,
kp.publicKey,
ct
);
return (
pt.length === msg.length &&
pt.every((b, i) => b === msg[i])
);
}
),
{ numRuns: 15 }
);
});
});

View File

@@ -0,0 +1,40 @@
import type { SealedBoxEngine } from "@suaveplan/crypto-core/engines";
import * as sodium from "libsodium-wrappers-sumo";
import { SODIUM_CODES } from "../../errors/codes/codes.js";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
/**
* libsodium-backed anonymous `SealedBoxEngine`.
*
* Wraps `crypto_box_seal` and `crypto_box_seal_open`. The sender generates
* an ephemeral X25519 keypair internally; the sender's identity is not
* revealed in the ciphertext. Only the recipient who holds the secret key
* can open the box.
*
* `sodiumReady()` must have been awaited before first use.
*/
export const sodiumSealedBoxEngine: SealedBoxEngine = {
seal(recipientPublic: Uint8Array, plaintext: Uint8Array): Uint8Array {
return sodium.crypto_box_seal(plaintext, recipientPublic);
},
open(
recipientSecret: Uint8Array,
recipientPublic: Uint8Array,
ciphertext: Uint8Array
): Uint8Array {
try {
return sodium.crypto_box_seal_open(
ciphertext,
recipientPublic,
recipientSecret
);
} catch (cause) {
throw new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"sodium.sealed-box.open.failed",
{ cause }
);
}
},
};

View File

@@ -0,0 +1 @@
export { sodiumSignEngine } from "./sodium-sign-engine.js";

View File

@@ -0,0 +1,88 @@
# engines/sodium-sign-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumSignEngine` implements the `SignEngine` interface from `@suaveplan/crypto` using libsodium's Ed25519 signing primitives. Ed25519 is a deterministic signature scheme based on the Edwards-curve digital signature algorithm — it provides strong security guarantees, fast verification, and small key and signature sizes.
This engine replaces the noble-curves default in performance-sensitive or server-side scenarios where the libsodium WASM path is preferred for its maturity and NIST/IETF certification heritage.
## Features
- Ed25519 keypair generation via `crypto_sign_keypair` — uses libsodium's internal secure entropy.
- Detached signature creation via `crypto_sign_detached` — signature is separate from the message.
- Detached signature verification via `crypto_sign_verify_detached` — returns boolean, never throws on invalid signatures.
- 32-byte public keys, 64-byte secret keys (includes seed), 64-byte signatures.
- Conforms to `SignEngine` from `@suaveplan/crypto/engines` — drop-in for the noble default.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumSignEngine } from "@suaveplan/crypto-sodium/engines/sign";
await sodiumReady();
const kp = sodiumSignEngine.generateKeypair();
const msg = new TextEncoder().encode("sign this");
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
const valid = sodiumSignEngine.verify(kp.publicKey, sig, msg);
console.log(valid); // true
```
## Usage: Advanced Example
Using `sodiumSignEngine` as a pluggable engine with `@suaveplan/crypto`:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumSignEngine } from "@suaveplan/crypto-sodium/engines/sign";
import type { SignEngine } from "@suaveplan/crypto/engines";
await sodiumReady();
function signDocument(
engine: SignEngine,
secretKey: Uint8Array,
document: Uint8Array
): Uint8Array {
return engine.sign(secretKey, document);
}
const kp = sodiumSignEngine.generateKeypair();
const doc = new TextEncoder().encode("legal document text");
const sig = signDocument(sodiumSignEngine, kp.secretKey, doc);
// Verify on the receiving side:
const ok = sodiumSignEngine.verify(kp.publicKey, sig, doc);
```
## API Reference
### `sodiumSignEngine: SignEngine`
Singleton engine object.
### `generateKeypair(byteSource?: ByteSource): SignKeypair`
Generates a fresh Ed25519 keypair. The `byteSource` parameter is accepted for interface compatibility but ignored — libsodium manages its own CSPRNG.
**Returns:** `{ publicKey: Uint8Array (32 bytes), secretKey: Uint8Array (64 bytes) }`
### `sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array`
Produces a 64-byte detached Ed25519 signature over `message`.
### `verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean`
Verifies a detached signature. Returns `false` rather than throwing on any verification failure (wrong key, tampered sig, wrong message). Exceptions from libsodium are caught and mapped to `false`.
## Implementation Notes
libsodium's `crypto_sign_keypair()` returns `{ publicKey, privateKey }` — note the property is named `privateKey` in the TS bindings despite being the full 64-byte secret key (seed + public key concatenated). The engine remaps this to `secretKey` to match the `SignKeypair` interface.
The `byteSource` parameter is not forwarded to libsodium because libsodium's WASM module manages entropy internally. Callers cannot substitute an alternative random source for key generation via this engine.
`verify` wraps `crypto_sign_verify_detached` in a try/catch because libsodium throws for structurally malformed inputs (e.g., a signature buffer that is not 64 bytes), mapping those to `false` for consistency with the `SignEngine` contract.

View File

@@ -0,0 +1,77 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { sodiumReady } from "../../init/init.js";
import { sodiumSignEngine } from "./sodium-sign-engine.js";
describe("sodiumSignEngine", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("generateKeypair returns 32-byte public and 64-byte secret key", {
timeout: 30_000,
}, () => {
const kp = sodiumSignEngine.generateKeypair();
expect(kp.publicKey.length).toBe(32);
expect(kp.secretKey.length).toBe(64);
});
it("sign produces 64-byte signature", { timeout: 30_000 }, () => {
const kp = sodiumSignEngine.generateKeypair();
const msg = new TextEncoder().encode("test message");
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
expect(sig.length).toBe(64);
});
it("verify returns true for valid signature", { timeout: 30_000 }, () => {
const kp = sodiumSignEngine.generateKeypair();
const msg = new TextEncoder().encode("hello sign");
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
expect(sodiumSignEngine.verify(kp.publicKey, sig, msg)).toBe(true);
});
it("verify returns false for tampered signature", {
timeout: 30_000,
}, () => {
const kp = sodiumSignEngine.generateKeypair();
const msg = new TextEncoder().encode("hello sign");
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
sig[0] ^= 0xff;
expect(sodiumSignEngine.verify(kp.publicKey, sig, msg)).toBe(false);
});
it("verify returns false for wrong message", { timeout: 30_000 }, () => {
const kp = sodiumSignEngine.generateKeypair();
const msg = new TextEncoder().encode("msg A");
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
const other = new TextEncoder().encode("msg B");
expect(sodiumSignEngine.verify(kp.publicKey, sig, other)).toBe(false);
});
it("round-trip property", { timeout: 30_000 }, () => {
const kp = sodiumSignEngine.generateKeypair();
fc.assert(
fc.property(
fc.uint8Array({ minLength: 1, maxLength: 256 }),
(msg) => {
const sig = sodiumSignEngine.sign(kp.secretKey, msg);
return sodiumSignEngine.verify(kp.publicKey, sig, msg);
}
),
{ numRuns: 20 }
);
});
it("verify returns false for wrong-length signature (triggers sodium throw)", {
timeout: 5000,
}, () => {
const kp = sodiumSignEngine.generateKeypair();
// 1-byte signature causes sodium to throw internally — our catch returns false
const result = sodiumSignEngine.verify(
kp.publicKey,
new Uint8Array(1),
new Uint8Array(4)
);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
import type { SignEngine, SignKeypair } from "@suaveplan/crypto-core/engines";
import * as sodium from "libsodium-wrappers-sumo";
/**
* libsodium-backed Ed25519 `SignEngine`.
*
* Uses `crypto_sign_keypair`, `crypto_sign_detached`, and
* `crypto_sign_verify_detached` from libsodium. All operations are
* synchronous after `sodiumReady()` has been awaited once.
*
* The `byteSource` parameter for `generateKeypair` is not used — libsodium
* manages its own secure entropy; callers cannot substitute the source.
*/
export const sodiumSignEngine: SignEngine = {
generateKeypair(): SignKeypair {
const kp = sodium.crypto_sign_keypair();
return { publicKey: kp.publicKey, secretKey: kp.privateKey };
},
sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(message, secretKey);
},
verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): boolean {
try {
return sodium.crypto_sign_verify_detached(
signature,
message,
publicKey
);
} catch {
return false;
}
},
};

View File

@@ -0,0 +1,80 @@
# SODIUM_CODES
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`codes.ts` defines the complete set of stable error codes used throughout `@suaveplan/crypto-sodium`. Each code is a namespaced string constant produced by `defineCodes` from `@suaveplan/error`, ensuring that codes are unique within the `"SODIUM"` namespace and that the `SodiumCode` union type remains in sync with the registered values at compile time. Consumers can branch on `error.code` without inspecting message strings, making error handling robust across version upgrades.
## Features
- Four codes covering the four libsodium-specific failure domains: library initialisation, KDF context/length validation, and HMAC authentication verification
- Generated via `defineCodes("SODIUM", [...])` which prefixes each entry and freezes the resulting object
- `SodiumCode` is derived as a `typeof` union so TypeScript narrows exhaustively in switch statements
- No runtime dependencies beyond `@suaveplan/error`
## Usage: Basic Example
```typescript
import { SODIUM_CODES } from "@suaveplan/crypto-sodium/errors/codes";
import { SodiumError } from "@suaveplan/crypto-sodium/errors/sodium-error";
throw new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"error.crypto-sodium.init.failed"
);
```
## Usage: Advanced Example
```typescript
import { SODIUM_CODES, type SodiumCode } from "@suaveplan/crypto-sodium/errors/codes";
import { SodiumError } from "@suaveplan/crypto-sodium/errors/sodium-error";
function handleSodiumError(err: SodiumError): string {
switch (err.code as SodiumCode) {
case SODIUM_CODES["INIT.FAILED"]:
return "libsodium failed to initialise — WebAssembly may not be available.";
case SODIUM_CODES["KDF.INVALID_CONTEXT"]:
return "KDF context must be exactly 8 bytes.";
case SODIUM_CODES["KDF.INVALID_LENGTH"]:
return "Derived key length is outside the range permitted by crypto_kdf.";
case SODIUM_CODES["AUTH.VERIFY_FAILED"]:
return "HMAC authentication tag did not match — message rejected.";
default:
return "Unknown sodium error.";
}
}
```
## API Reference
### `SODIUM_CODES`
```typescript
const SODIUM_CODES: Readonly<Record<string, string>>
```
A frozen map from short code keys to their namespaced string values. All values are prefixed with `"SODIUM."` by `defineCodes`.
| Key | Namespaced value | When thrown |
|----------------------|---------------------------------|----------------------------------------------------------|
| `INIT.FAILED` | `"SODIUM.INIT.FAILED"` | libsodium initialisation (WASM load) failed |
| `KDF.INVALID_CONTEXT`| `"SODIUM.KDF.INVALID_CONTEXT"` | `crypto_kdf` context string is not exactly 8 bytes |
| `KDF.INVALID_LENGTH` | `"SODIUM.KDF.INVALID_LENGTH"` | Requested derived key length is out of range for `crypto_kdf` |
| `AUTH.VERIFY_FAILED` | `"SODIUM.AUTH.VERIFY_FAILED"` | `crypto_auth_verify` returned a failure |
### `SodiumCode`
```typescript
type SodiumCode = (typeof SODIUM_CODES)[keyof typeof SODIUM_CODES];
```
Union of all namespaced code strings. Use this type to annotate `err.code` when narrowing `SodiumError` in typed catch blocks.
## Implementation Notes
`defineCodes` returns a plain frozen object. The `SodiumCode` type is derived with `typeof SODIUM_CODES[keyof typeof SODIUM_CODES]`, which collapses to a union of string literals at compile time. This approach means adding a new code to the array automatically widens the union, and any exhaustive switch statement will produce a TypeScript error if the new case is not handled.
The `"AUTH.VERIFY_FAILED"` code covers `crypto_auth` HMAC-SHA512256 verification failures only. AEAD decryption failures in the sodium AEAD engine re-use the `AEAD.DECRYPT_FAILED` code from `@suaveplan/crypto` (`CryptoError`) because those errors flow through the shared `AeadEngine` interface and consumers expect that specific code.

View File

@@ -0,0 +1,10 @@
import { defineCodes } from "@suaveplan/error";
export const SODIUM_CODES = defineCodes("SODIUM", [
"INIT.FAILED",
"KDF.INVALID_CONTEXT",
"KDF.INVALID_LENGTH",
"AUTH.VERIFY_FAILED",
] as const);
export type SodiumCode = (typeof SODIUM_CODES)[keyof typeof SODIUM_CODES];

View File

@@ -0,0 +1 @@
export { SODIUM_CODES, type SodiumCode } from "./codes.js";

View File

@@ -0,0 +1,2 @@
export { SODIUM_CODES, type SodiumCode } from "./codes/codes.js";
export { SodiumError } from "./sodium-error/sodium-error.js";

View File

@@ -0,0 +1 @@
export { SodiumError } from "./sodium-error.js";

View File

@@ -0,0 +1,94 @@
# errors — SodiumError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SodiumError` is the structured error class for all failures originating in `@suaveplan/crypto-sodium`. It extends `SuaveplanError<SodiumCode>` from `@suaveplan/error`, providing a stable, machine-readable `code` field alongside an optional `cause` chain and `context` map for structured logging, observability, and serialisation.
Every `throw` in this package produces a `SodiumError` instance, enabling callers to narrow error handling with `instanceof SodiumError` or by inspecting `error.code` against the exported `SODIUM_CODES` registry.
## Features
- Extends `SuaveplanError<SodiumCode>` — participates in the full `@suaveplan/error` ecosystem (cause-chain traversal, OTel span events, wire serialisation).
- Stable code registry via `SODIUM_CODES` — codes are defined with `defineCodes("SODIUM", [...])` and never change between package versions.
- Full `context` support — structured fields attached at throw site for log enrichment without leaking sensitive bytes.
- `cause` preservation — wraps underlying libsodium errors for root-cause analysis.
## Usage: Basic Example
```ts
import { SodiumError, SODIUM_CODES } from "@suaveplan/crypto-sodium/errors";
try {
// ...some crypto-sodium operation
} catch (err) {
if (err instanceof SodiumError) {
console.error("sodium failure:", err.code, err.message);
}
}
```
## Usage: Advanced Example
Matching against specific codes and extracting context:
```ts
import { SodiumError, SODIUM_CODES } from "@suaveplan/crypto-sodium/errors";
import { walkCauses } from "@suaveplan/error";
function handleError(err: unknown): void {
if (!(err instanceof SodiumError)) throw err;
switch (err.code) {
case SODIUM_CODES["KDF.INVALID_CONTEXT"]:
console.warn("KDF context must be exactly 8 bytes", err.context);
break;
case SODIUM_CODES["KDF.INVALID_LENGTH"]:
console.warn("KDF length out of range", err.context);
break;
case SODIUM_CODES["INIT.FAILED"]:
// Inspect the original libsodium error
for (const cause of walkCauses(err)) {
console.error(" caused by:", cause);
}
break;
default:
throw err;
}
}
```
## API Reference
### `class SodiumError extends SuaveplanError<SodiumCode>`
**Constructor:** `new SodiumError(code: SodiumCode, messageKey: string, options?: SuaveplanErrorOptions)`
- `code` — one of `SODIUM_CODES[keyof typeof SODIUM_CODES]`.
- `messageKey` — i18n resolver key, falls back to the key string if no resolver is registered.
- `options.cause` — underlying error to preserve in the cause chain.
- `options.context` — structured fields for logging; redacted at the wire boundary.
- `options.traceId` — optional trace correlation id.
### `SODIUM_CODES`
Stable code registry produced by `defineCodes("SODIUM", [...])`:
| Key | Code string |
|-----|-------------|
| `INIT.FAILED` | `SODIUM.INIT.FAILED` |
| `KDF.INVALID_CONTEXT` | `SODIUM.KDF.INVALID_CONTEXT` |
| `KDF.INVALID_LENGTH` | `SODIUM.KDF.INVALID_LENGTH` |
| `AUTH.VERIFY_FAILED` | `SODIUM.AUTH.VERIFY_FAILED` |
### `type SodiumCode`
Union of all values in `SODIUM_CODES`. Use for typed error-code comparisons.
## Implementation Notes
`SodiumError` is a thin subclass — its sole purpose is widening the `protected` `SuaveplanError` constructor to `public` so call sites can instantiate it directly. The `// biome-ignore` comment on the constructor is intentional; Biome's `noUselessConstructor` rule would otherwise flag the pass-through, but the accessibility change is the actual purpose.
Codes are frozen at `defineCodes` time, making them suitable for storage in databases or event streams where code strings must remain stable across deployments.

View File

@@ -0,0 +1,55 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SODIUM_CODES, SodiumError } from "../index.js";
describe("SodiumError", () => {
it("is an instance of SuaveplanError", { timeout: 5000 }, () => {
const err = new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"sodium.init.failed"
);
expect(err).toBeInstanceOf(SuaveplanError);
});
it("is an instance of SodiumError", { timeout: 5000 }, () => {
const err = new SodiumError(
SODIUM_CODES["KDF.INVALID_CONTEXT"],
"sodium.kdf.invalid-context"
);
expect(err).toBeInstanceOf(SodiumError);
});
it("carries the correct code", { timeout: 5000 }, () => {
const err = new SodiumError(
SODIUM_CODES["KDF.INVALID_LENGTH"],
"sodium.kdf.invalid-length"
);
expect(err.code).toBe(SODIUM_CODES["KDF.INVALID_LENGTH"]);
});
it("carries cause when provided", { timeout: 5000 }, () => {
const cause = new Error("root cause");
const err = new SodiumError(
SODIUM_CODES["AUTH.VERIFY_FAILED"],
"sodium.auth.verify-failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("carries context when provided", { timeout: 5000 }, () => {
const err = new SodiumError(
SODIUM_CODES["KDF.INVALID_CONTEXT"],
"sodium.kdf.invalid-context",
{ context: { provided: "abc", byteLength: 3 } }
);
expect(err.context).toEqual({ provided: "abc", byteLength: 3 });
});
it("SODIUM_CODES contains all expected codes", { timeout: 5000 }, () => {
expect(SODIUM_CODES["INIT.FAILED"]).toBeDefined();
expect(SODIUM_CODES["KDF.INVALID_CONTEXT"]).toBeDefined();
expect(SODIUM_CODES["KDF.INVALID_LENGTH"]).toBeDefined();
expect(SODIUM_CODES["AUTH.VERIFY_FAILED"]).toBeDefined();
});
});

View File

@@ -0,0 +1,20 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { SodiumCode } from "../codes/codes.js";
/**
* Structured error class for all `@suaveplan/crypto-sodium` failures.
*
* Every error thrown by this package is an instance of `SodiumError`, carrying
* a stable `SODIUM.*` code, an optional `cause`, and optional `context` fields
* for structured logging and observability.
*/
export class SodiumError extends SuaveplanError<SodiumCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: SodiumCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,35 @@
// Initialisation
// Constants
export * from "./constants/index.js";
// Engine implementations
export {
createSodiumAeadEngine,
type SodiumAeadOptions,
sodiumAeadEngine,
sodiumAesGcmEngine,
sodiumAgreeEngine,
sodiumAuthBoxEngine,
sodiumSealedBoxEngine,
sodiumSignEngine,
} from "./engines/index.js";
// Error surface
export {
SODIUM_CODES,
type SodiumCode,
SodiumError,
} from "./errors/index.js";
export { sodiumReady } from "./init/init.js";
// Sodium-exclusive primitives
export {
cryptoAuth,
cryptoAuthVerify,
cryptoKdf,
cryptoPwhashStr,
cryptoPwhashStrVerify,
KDF_SUBKEY_MAX,
KDF_SUBKEY_MIN,
type PwhashOptions,
ristretto255,
} from "./primitives/index.js";

View File

@@ -0,0 +1 @@
export { sodiumReady } from "./init";

View File

@@ -0,0 +1,70 @@
# init — sodiumReady
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sodiumReady` is the initialisation gateway for the `libsodium-wrappers-sumo` WASM module. libsodium ships as WebAssembly, which requires an asynchronous compilation and instantiation step before any cryptographic API becomes available. `sodiumReady` wraps that step in an idempotent singleton Promise so the WASM is compiled exactly once regardless of how many concurrent callers exist.
All engine implementations (`sodiumAeadEngine`, `sodiumSignEngine`, etc.) call `sodiumReady()` internally where they are async. The synchronous engines require `sodiumReady()` to have been awaited before first use — typically in a `beforeAll` hook or an application startup sequence.
## Features
- Single lazy initialisation — WASM is loaded only when first requested.
- Idempotent — every call after the first returns the same settled Promise at zero cost.
- Error recovery — if initialisation fails, the internal promise reference is cleared so a retry is possible on the next call.
- Throws `SodiumError` with code `SODIUM.INIT.FAILED` and the underlying cause preserved if libsodium's `ready` promise rejects.
## Usage: Basic Example
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
import { sodiumSignEngine } from "@suaveplan/crypto-sodium/engines/sign";
async function main() {
await sodiumReady();
const kp = sodiumSignEngine.generateKeypair();
const sig = sodiumSignEngine.sign(kp.secretKey, new TextEncoder().encode("hello"));
console.log("signature bytes:", sig.length);
}
```
## Usage: Advanced Example
Pre-warming during application boot to avoid latency on the first real crypto call:
```ts
import { sodiumReady } from "@suaveplan/crypto-sodium/init";
// In your server bootstrap:
export async function bootstrap(): Promise<void> {
// Pre-warm WASM — all subsequent calls are synchronous no-ops.
await sodiumReady();
console.log("libsodium WASM ready");
}
// Concurrent callers share the same init — no double-load:
await Promise.all([sodiumReady(), sodiumReady(), sodiumReady()]);
```
## API Reference
### `sodiumReady(): Promise<void>`
Resolves when `libsodium-wrappers-sumo` WASM is fully initialised and all `sodium.*` APIs are callable synchronously.
**Returns:** `Promise<void>` — resolves to `undefined` on success.
**Throws:** `SodiumError` with code `SODIUM.INIT.FAILED` if libsodium's internal `ready` promise rejects.
**Idempotency:** The returned promise is the same object on every call after the first successful resolution; concurrent callers are coalesced into a single init sequence.
## Implementation Notes
libsodium's JS wrapper exports a `ready` property which is a `Promise<void>` that resolves once WASM compilation finishes. Before that promise settles, calling any sodium API throws a `ReferenceError`. The `sodiumReady` singleton ensures those calls only happen post-resolution.
The module-level `readyPromise` variable is `null` until the first call, preventing any eager WASM load on import. If the `ready` promise rejects (e.g., missing WASM in a restricted environment), `readyPromise` is reset to `null` so subsequent calls can retry — this is the only exception to idempotency.
Performance: after the first successful resolution, every subsequent `sodiumReady()` call resolves in a single microtask tick (a `.then()` on an already-resolved Promise), adding negligible overhead.

View File

@@ -0,0 +1,21 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { sodiumReady } from "./init.js";
describe("sodiumReady", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("resolves on first call", { timeout: 30_000 }, async () => {
await expect(sodiumReady()).resolves.toBeUndefined();
});
it("is idempotent — returns same promise on repeated calls", {
timeout: 30_000,
}, async () => {
const p1 = sodiumReady();
const p2 = sodiumReady();
// Both should resolve without error
await expect(Promise.all([p1, p2])).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,40 @@
import * as sodium from "libsodium-wrappers-sumo";
import { SODIUM_CODES } from "../errors/codes/codes.js";
import { SodiumError } from "../errors/sodium-error/sodium-error.js";
let readyPromise: Promise<void> | null = null;
/**
* Resolves once the `libsodium-wrappers-sumo` WASM module is fully
* initialised and all sodium APIs are available.
*
* Idempotent: calling it a second time returns the same Promise that was
* created on the first call, so concurrent callers share one initialisation
* sequence without duplicating the WASM load.
*
* All engine methods call this internally; application code only needs to
* call it explicitly when pre-warming the runtime before the first crypto
* operation.
*/
export async function sodiumReady(): Promise<void> {
if (readyPromise !== null) return readyPromise;
readyPromise = new Promise<void>((resolve, reject) => {
sodium.ready.then(
() => {
resolve();
},
/* istanbul ignore next -- sodium.ready never rejects in practice; WASM always loads */
(cause: unknown) => {
readyPromise = null;
reject(
new SodiumError(
SODIUM_CODES["INIT.FAILED"],
"sodium.init.failed",
{ cause }
)
);
}
);
});
return readyPromise;
}

View File

@@ -0,0 +1,85 @@
# primitives/crypto-auth
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cryptoAuth` and `cryptoAuthVerify` expose libsodium's `crypto_auth` and `crypto_auth_verify`, which implement a BLAKE2b-based message authentication code (MAC). Unlike HMAC-SHA256, this MAC is built on BLAKE2b — a fast, parallel, modern hash function with a wider security margin than HMAC-MD5 or HMAC-SHA1, and comparable to HMAC-SHA256/512.
MACs are used to verify that a message was produced by a party holding the shared key and has not been tampered with in transit. They differ from signatures in that both parties must hold the same secret key; there is no separate verification key.
## Features
- BLAKE2b-based MAC via `crypto_auth` — 32-byte output, 32-byte key.
- Constant-time verification via `crypto_auth_verify` — resistant to timing-based MAC forgery.
- `cryptoAuthVerify` returns `false` (never throws) for invalid MACs.
- Async — calls `sodiumReady()` internally.
- Deterministic — same key + message always produces the same MAC.
## Usage: Basic Example
```ts
import { cryptoAuth, cryptoAuthVerify } from "@suaveplan/crypto-sodium/primitives/auth";
const key = crypto.getRandomValues(new Uint8Array(32));
const message = new TextEncoder().encode("verify this");
const mac = await cryptoAuth(key, message);
const valid = await cryptoAuthVerify(key, message, mac);
console.log(valid); // true
```
## Usage: Advanced Example
Authenticating API request payloads with a shared secret:
```ts
import { cryptoAuth, cryptoAuthVerify } from "@suaveplan/crypto-sodium/primitives/auth";
const sharedKey = crypto.getRandomValues(new Uint8Array(32));
async function signPayload(payload: Uint8Array): Promise<{ payload: Uint8Array; mac: Uint8Array }> {
const mac = await cryptoAuth(sharedKey, payload);
return { payload, mac };
}
async function verifyPayload(payload: Uint8Array, mac: Uint8Array): Promise<boolean> {
return cryptoAuthVerify(sharedKey, payload, mac);
}
const data = new TextEncoder().encode('{"action":"transfer","amount":100}');
const signed = await signPayload(data);
const ok = await verifyPayload(signed.payload, signed.mac);
```
## API Reference
### `cryptoAuth(key: Uint8Array, message: Uint8Array): Promise<Uint8Array>`
Computes a 32-byte BLAKE2b MAC over `message` with `key`.
**Parameters:**
- `key` — 32-byte authentication key.
- `message` — Arbitrary-length message bytes.
**Returns:** `Promise<Uint8Array>` — 32-byte MAC.
### `cryptoAuthVerify(key: Uint8Array, message: Uint8Array, mac: Uint8Array): Promise<boolean>`
Verifies a MAC in constant time.
**Parameters:**
- `key` — 32-byte authentication key.
- `message` — The message that was authenticated.
- `mac` — 32-byte MAC to verify.
**Returns:** `Promise<boolean>` — `true` if valid, `false` otherwise. Never throws.
## Implementation Notes
`crypto_auth_verify` from libsodium uses constant-time comparison to prevent timing side channels that would otherwise allow an attacker to iteratively learn the correct MAC byte by byte. The `try/catch` in `cryptoAuthVerify` catches structurally malformed inputs (e.g., wrong-length MAC) and maps them to `false` for consistency.
Both functions are async solely because they call `sodiumReady()` — the underlying sodium operations are synchronous once the WASM is initialised.

View File

@@ -0,0 +1,70 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { sodiumReady } from "../../init/init.js";
import { cryptoAuth, cryptoAuthVerify } from "./crypto-auth.js";
const KEY = new Uint8Array(32).fill(0xde);
const MSG = new TextEncoder().encode("authenticate this");
describe("cryptoAuth / cryptoAuthVerify", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("produces a 32-byte MAC", { timeout: 30_000 }, async () => {
const mac = await cryptoAuth(KEY, MSG);
expect(mac.length).toBe(32);
});
it("verify returns true for correct MAC", { timeout: 30_000 }, async () => {
const mac = await cryptoAuth(KEY, MSG);
expect(await cryptoAuthVerify(KEY, MSG, mac)).toBe(true);
});
it("verify returns false for wrong MAC", { timeout: 30_000 }, async () => {
const mac = await cryptoAuth(KEY, MSG);
const bad = mac.slice();
bad[0] ^= 0xff;
expect(await cryptoAuthVerify(KEY, MSG, bad)).toBe(false);
});
it("verify returns false for wrong message", {
timeout: 30_000,
}, async () => {
const mac = await cryptoAuth(KEY, MSG);
const other = new TextEncoder().encode("different");
expect(await cryptoAuthVerify(KEY, other, mac)).toBe(false);
});
it("verify returns false for wrong key", { timeout: 30_000 }, async () => {
const mac = await cryptoAuth(KEY, MSG);
const otherKey = new Uint8Array(32).fill(0xaa);
expect(await cryptoAuthVerify(otherKey, MSG, mac)).toBe(false);
});
it("MAC is deterministic", { timeout: 30_000 }, async () => {
const mac1 = await cryptoAuth(KEY, MSG);
const mac2 = await cryptoAuth(KEY, MSG);
expect(mac1).toEqual(mac2);
});
it("round-trip property", { timeout: 30_000 }, async () => {
await fc.assert(
fc.asyncProperty(
fc.uint8Array({ minLength: 0, maxLength: 128 }),
async (msg) => {
const mac = await cryptoAuth(KEY, msg);
return cryptoAuthVerify(KEY, msg, mac);
}
),
{ numRuns: 20 }
);
});
it("cryptoAuthVerify returns false for wrong-length MAC (triggers sodium throw)", {
timeout: 5000,
}, async () => {
// 1-byte MAC causes sodium to throw internally; our catch returns false
expect(await cryptoAuthVerify(KEY, MSG, new Uint8Array(1))).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
import * as sodium from "libsodium-wrappers-sumo";
import { sodiumReady } from "../../init/init.js";
/**
* Computes a BLAKE2b-based MAC (message authentication code) using
* libsodium's `crypto_auth`.
*
* The output is a 32-byte MAC. The key must be 32 bytes.
*
* @param key 32-byte authentication key.
* @param message Arbitrary-length message to authenticate.
* @returns 32-byte MAC as `Uint8Array`.
*/
export async function cryptoAuth(
key: Uint8Array,
message: Uint8Array
): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_auth(message, key);
}
/**
* Verifies a BLAKE2b MAC produced by `cryptoAuth`.
*
* Uses libsodium's constant-time `crypto_auth_verify` to prevent
* timing-based MAC forgery.
*
* @param key 32-byte authentication key.
* @param message The original message that was authenticated.
* @param mac 32-byte MAC to verify.
* @returns `true` if the MAC is valid, `false` otherwise.
*/
export async function cryptoAuthVerify(
key: Uint8Array,
message: Uint8Array,
mac: Uint8Array
): Promise<boolean> {
await sodiumReady();
try {
return sodium.crypto_auth_verify(mac, message, key);
} catch {
return false;
}
}

View File

@@ -0,0 +1 @@
export { cryptoAuth, cryptoAuthVerify } from "./crypto-auth.js";

View File

@@ -0,0 +1,87 @@
# primitives/crypto-kdf
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cryptoKdf` provides deterministic subkey derivation via libsodium's `crypto_kdf_derive_from_key`, which implements BLAKE2b in KDF mode. Given a 32-byte master key, a numeric subkey identifier, an 8-byte application context string, and a desired length, it always produces the same output — making it suitable for generating multiple distinct keys (encryption, MAC, signing) from a single master without separate key storage.
The context string acts as a domain separator between applications or subsystems that share the same master key. Requiring exactly 8 bytes prevents both accidental collisions (empty context, overly short strings) and truncation attacks.
## Features
- Deterministic BLAKE2b-based KDF via `crypto_kdf_derive_from_key`.
- Context domain-separation — exactly 8 UTF-8 bytes required.
- Configurable output length: 16–64 bytes (inclusive).
- Different `subkeyId` values produce cryptographically independent outputs.
- Throws `SodiumError(SODIUM.KDF.INVALID_CONTEXT)` for non-8-byte contexts.
- Throws `SodiumError(SODIUM.KDF.INVALID_LENGTH)` for out-of-range lengths.
- Async — calls `sodiumReady()` internally; no pre-warming required.
## Usage: Basic Example
```ts
import { cryptoKdf } from "@suaveplan/crypto-sodium/primitives/kdf";
const masterKey = crypto.getRandomValues(new Uint8Array(32));
// Derive a 32-byte encryption key (slot 1) and a 32-byte MAC key (slot 2)
const encKey = await cryptoKdf(masterKey, 1, "SuavEncr", 32);
const macKey = await cryptoKdf(masterKey, 2, "SuavEncr", 32);
```
## Usage: Advanced Example
Deriving keys for different subsystems from a single root master:
```ts
import { cryptoKdf } from "@suaveplan/crypto-sodium/primitives/kdf";
const masterKey = crypto.getRandomValues(new Uint8Array(32));
// Context strings must be exactly 8 ASCII bytes:
// "SuavAEAD" for AEAD subkeys, "SuavSign" for signing subkeys
const aeadKey = await cryptoKdf(masterKey, 0, "SuavAEAD", 32);
const signKey = await cryptoKdf(masterKey, 0, "SuavSign", 32);
// Rotate by incrementing subkeyId — old key remains derivable for decryption
const aeadKeyV2 = await cryptoKdf(masterKey, 1, "SuavAEAD", 32);
// Validate context length at compile time:
const CONTEXT = "SuavCtx1"; // exactly 8 chars — enforced at runtime
```
## API Reference
### `cryptoKdf(key, subkeyId, context, length): Promise<Uint8Array>`
Derives a subkey.
**Parameters:**
- `key: Uint8Array` — 32-byte master key.
- `subkeyId: number` — Non-negative integer. Changing this produces a different subkey.
- `context: string` — Must encode to exactly 8 bytes in UTF-8. Acts as a domain separator.
- `length: number` — Output length in bytes (16–64 inclusive).
**Returns:** `Promise<Uint8Array>` of the requested length.
**Throws:**
- `SodiumError(SODIUM.KDF.INVALID_CONTEXT)` — if `context` is not exactly 8 UTF-8 bytes.
- `SodiumError(SODIUM.KDF.INVALID_LENGTH)` — if `length < 16` or `length > 64`.
### `KDF_SUBKEY_MIN = 16`
Minimum accepted output length.
### `KDF_SUBKEY_MAX = 64`
Maximum accepted output length.
## Implementation Notes
The 8-byte context requirement comes directly from libsodium's `crypto_kdf_CONTEXTBYTES` constant. The context is encoded via `TextEncoder` and checked for exact byte length — multi-byte UTF-8 characters can silently cause a 7-char string to produce 8 bytes or a 8-char string to produce more, which is why byte-length is checked rather than character length.
libsodium's `crypto_kdf_derive_from_key` uses BLAKE2b in a mode where the key, subkey ID, context, and output length are mixed into the BLAKE2b input. Outputs for different `subkeyId` values are computationally independent.

View File

@@ -0,0 +1,105 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
import { sodiumReady } from "../../init/init.js";
import { cryptoKdf, KDF_SUBKEY_MAX, KDF_SUBKEY_MIN } from "./crypto-kdf.js";
const MASTER_KEY = new Uint8Array(32).fill(0xcc);
describe("cryptoKdf", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("derives a subkey of the requested length", {
timeout: 30_000,
}, async () => {
const subkey = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32);
expect(subkey.length).toBe(32);
});
it("is deterministic — same inputs produce same output", {
timeout: 30_000,
}, async () => {
const a = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32);
const b = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32);
expect(a).toEqual(b);
});
it("different subkeyId produces different output", {
timeout: 30_000,
}, async () => {
const a = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32);
const b = await cryptoKdf(MASTER_KEY, 2, "SuavApp!", 32);
expect(a).not.toEqual(b);
});
it("different context produces different output", {
timeout: 30_000,
}, async () => {
const a = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32);
const b = await cryptoKdf(MASTER_KEY, 1, "OtherApp", 32);
expect(a).not.toEqual(b);
});
it("throws SODIUM.KDF.INVALID_CONTEXT when context is not 8 bytes", {
timeout: 30_000,
}, async () => {
await expect(cryptoKdf(MASTER_KEY, 1, "short", 32)).rejects.toThrow(
SodiumError
);
const err = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", 32).catch(
() => null
);
expect(err).not.toBeNull();
});
it("throws SODIUM.KDF.INVALID_CONTEXT with multi-byte UTF-8 too long", {
timeout: 30_000,
}, async () => {
// "café!app" = 8 visible chars but 9 bytes: c(1)+a(1)+f(1)+é(2)+!(1)+a(1)+p(1)+p(1) = 9
await expect(cryptoKdf(MASTER_KEY, 1, "café!app", 32)).rejects.toThrow(
SodiumError
);
});
it("throws SODIUM.KDF.INVALID_LENGTH when length is below minimum", {
timeout: 30_000,
}, async () => {
await expect(
cryptoKdf(MASTER_KEY, 1, "SuavApp!", KDF_SUBKEY_MIN - 1)
).rejects.toThrow(SodiumError);
});
it("throws SODIUM.KDF.INVALID_LENGTH when length is above maximum", {
timeout: 30_000,
}, async () => {
await expect(
cryptoKdf(MASTER_KEY, 1, "SuavApp!", KDF_SUBKEY_MAX + 1)
).rejects.toThrow(SodiumError);
});
it("derives subkeys at min and max lengths", {
timeout: 30_000,
}, async () => {
const min = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", KDF_SUBKEY_MIN);
expect(min.length).toBe(KDF_SUBKEY_MIN);
const max = await cryptoKdf(MASTER_KEY, 1, "SuavApp!", KDF_SUBKEY_MAX);
expect(max.length).toBe(KDF_SUBKEY_MAX);
});
it("determinism property", { timeout: 30_000 }, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 99 }),
fc.integer({ min: KDF_SUBKEY_MIN, max: KDF_SUBKEY_MAX }),
async (id, len) => {
const a = await cryptoKdf(MASTER_KEY, id, "TestCtx!", len);
const b = await cryptoKdf(MASTER_KEY, id, "TestCtx!", len);
return a.every((byte, i) => byte === b[i]);
}
),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,59 @@
import * as sodium from "libsodium-wrappers-sumo";
import { SODIUM_CODES } from "../../errors/codes/codes.js";
import { SodiumError } from "../../errors/sodium-error/sodium-error.js";
import { sodiumReady } from "../../init/init.js";
/** Minimum subkey length accepted by `crypto_kdf_derive_from_key`. */
export const KDF_SUBKEY_MIN = 16;
/** Maximum subkey length accepted by `crypto_kdf_derive_from_key`. */
export const KDF_SUBKEY_MAX = 64;
/**
* Derives a deterministic subkey from a master key using libsodium's
* `crypto_kdf_derive_from_key` (BLAKE2b in KDF mode).
*
* `context` must encode to **exactly 8 bytes** in UTF-8. Padding or
* truncation is NOT applied — an invalid context throws immediately.
*
* @param key 32-byte master key.
* @param subkeyId Non-negative integer identifying the subkey slot.
* @param context Exactly-8-byte UTF-8 application context string.
* @param length Output subkey length in bytes (16–64 inclusive).
* @returns Derived subkey as `Uint8Array`.
*
* @throws {SodiumError} `SODIUM.KDF.INVALID_CONTEXT` — context is not 8 bytes.
* @throws {SodiumError} `SODIUM.KDF.INVALID_LENGTH` — length is out of range.
*/
export async function cryptoKdf(
key: Uint8Array,
subkeyId: number,
context: string,
length: number
): Promise<Uint8Array> {
await sodiumReady();
const contextBytes = new TextEncoder().encode(context);
if (contextBytes.length !== 8) {
throw new SodiumError(
SODIUM_CODES["KDF.INVALID_CONTEXT"],
"sodium.kdf.invalid-context",
{ context: { provided: context, byteLength: contextBytes.length } }
);
}
if (length < KDF_SUBKEY_MIN || length > KDF_SUBKEY_MAX) {
throw new SodiumError(
SODIUM_CODES["KDF.INVALID_LENGTH"],
"sodium.kdf.invalid-length",
{
context: {
provided: length,
min: KDF_SUBKEY_MIN,
max: KDF_SUBKEY_MAX,
},
}
);
}
return sodium.crypto_kdf_derive_from_key(length, subkeyId, context, key);
}

View File

@@ -0,0 +1 @@
export { cryptoKdf, KDF_SUBKEY_MAX, KDF_SUBKEY_MIN } from "./crypto-kdf.js";

View File

@@ -0,0 +1,93 @@
# primitives/crypto-pwhash
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cryptoPwhashStr` and `cryptoPwhashStrVerify` expose libsodium's Argon2id password hashing via `crypto_pwhash_str` and `crypto_pwhash_str_verify`. Argon2id is the winner of the Password Hashing Competition (2015) and the recommended algorithm for password storage by OWASP and NIST SP 800-63B.
The encoded hash string includes the algorithm identifier, salt, and cost parameters — it is self-contained and safe to store directly in a database. This eliminates a class of bugs where the salt or parameters are stored separately and can become desynchronised.
## Features
- Argon2id password hashing via `crypto_pwhash_str` — salt generated internally.
- Self-contained encoded output (≈97 chars) — includes algorithm, salt, opsLimit, memLimit.
- Constant-time verification via `crypto_pwhash_str_verify`.
- Configurable `opsLimit` and `memLimit` — defaults to `OPSLIMIT_INTERACTIVE` / `MEMLIMIT_INTERACTIVE`.
- Async — calls `sodiumReady()` internally.
- `cryptoPwhashStrVerify` returns `false` on any failure, never throws.
## Usage: Basic Example
```ts
import { cryptoPwhashStr, cryptoPwhashStrVerify } from "@suaveplan/crypto-sodium/primitives/pwhash";
const password = new TextEncoder().encode("user-password-here");
const hash = await cryptoPwhashStr(password);
// Store `hash` in your database. Later, at login:
const valid = await cryptoPwhashStrVerify(hash, password);
console.log(valid); // true
```
## Usage: Advanced Example
Higher-security settings for sensitive credential storage:
```ts
import { cryptoPwhashStr, cryptoPwhashStrVerify } from "@suaveplan/crypto-sodium/primitives/pwhash";
const password = new TextEncoder().encode("admin-master-password");
// Sensitive account — use MODERATE or SENSITIVE preset values
// crypto_pwhash_OPSLIMIT_MODERATE = 3, MEMLIMIT_MODERATE = 268435456 (256 MiB)
const hash = await cryptoPwhashStr(password, {
opsLimit: 3,
memLimit: 268_435_456,
});
// Verification automatically reads cost parameters from the hash string
const ok = await cryptoPwhashStrVerify(hash, password);
// Password rehashing: if ok but hash uses lower-cost params, rehash
// (the encoded string embeds the params so you can detect this)
```
## API Reference
### `cryptoPwhashStr(password, opts?): Promise<string>`
Hashes `password` with Argon2id.
**Parameters:**
- `password: Uint8Array` — Raw password bytes.
- `opts.opsLimit?: number` — CPU/iteration cost. Default: `crypto_pwhash_OPSLIMIT_INTERACTIVE`.
- `opts.memLimit?: number` — Memory cost in bytes. Default: `crypto_pwhash_MEMLIMIT_INTERACTIVE`.
**Returns:** `Promise<string>` — Argon2id-encoded hash string, safe for database storage.
### `cryptoPwhashStrVerify(hash, password): Promise<boolean>`
Verifies `password` against an encoded hash string.
**Parameters:**
- `hash: string` — Encoded hash from `cryptoPwhashStr`.
- `password: Uint8Array` — Candidate password bytes.
**Returns:** `Promise<boolean>` — `true` if the password matches. Returns `false` (not throws) on any failure including malformed hash strings.
### `interface PwhashOptions`
- `opsLimit?: number`
- `memLimit?: number`
## Implementation Notes
`crypto_pwhash_str` generates a random salt internally and encodes the full Argon2id parameter set into the output string. This is the `$argon2id$v=19$...` format, compatible with most password-hashing libraries. The salt is never exposed to the caller.
`crypto_pwhash_str_verify` uses constant-time comparison for the hash check. The `try/catch` maps libsodium exceptions (e.g., malformed hash strings, wrong length) to `false` for uniform API behaviour.
The interactive preset (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`) is calibrated for ≈50 ms on typical hardware. Increase `opsLimit` and `memLimit` for stored API keys, encryption master passwords, or recovery codes.

View File

@@ -0,0 +1,73 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { sodiumReady } from "../../init/init.js";
import { cryptoPwhashStr, cryptoPwhashStrVerify } from "./crypto-pwhash.js";
const PASSWORD = new TextEncoder().encode("correct-horse-battery-staple");
describe("cryptoPwhashStr / cryptoPwhashStrVerify", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("returns a non-empty hash string", { timeout: 30_000 }, async () => {
const hash = await cryptoPwhashStr(PASSWORD);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
it("hash string starts with argon2 prefix", {
timeout: 30_000,
}, async () => {
const hash = await cryptoPwhashStr(PASSWORD);
expect(hash).toMatch(/^\$argon2/);
});
it("verify returns true for correct password", {
timeout: 30_000,
}, async () => {
const hash = await cryptoPwhashStr(PASSWORD);
expect(await cryptoPwhashStrVerify(hash, PASSWORD)).toBe(true);
});
it("verify returns false for wrong password", {
timeout: 30_000,
}, async () => {
const hash = await cryptoPwhashStr(PASSWORD);
const wrong = new TextEncoder().encode("wrong-password");
expect(await cryptoPwhashStrVerify(hash, wrong)).toBe(false);
});
it("different invocations produce different hashes (salted)", {
timeout: 30_000,
}, async () => {
const h1 = await cryptoPwhashStr(PASSWORD);
const h2 = await cryptoPwhashStr(PASSWORD);
// Different salts → different encoded strings
expect(h1).not.toBe(h2);
});
it("accepts custom opsLimit and memLimit", {
timeout: 30_000,
}, async () => {
const hash = await cryptoPwhashStr(PASSWORD, {
opsLimit: 2,
memLimit: 67_108_864, // 64 MiB
});
expect(await cryptoPwhashStrVerify(hash, PASSWORD)).toBe(true);
});
it("verify returns false for malformed hash string", {
timeout: 30_000,
}, async () => {
expect(await cryptoPwhashStrVerify("not-a-hash", PASSWORD)).toBe(false);
});
it("verify returns false when sodium throws for null-like input", {
timeout: 5000,
}, async () => {
// Passing null triggers a sodium internal error, exercising the catch path
expect(
await cryptoPwhashStrVerify(null as unknown as string, PASSWORD)
).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
import * as sodium from "libsodium-wrappers-sumo";
import { sodiumReady } from "../../init/init.js";
/** Options for `cryptoPwhashStr`. */
export interface PwhashOptions {
/**
* CPU/memory cost factor. Defaults to
* `crypto_pwhash_OPSLIMIT_INTERACTIVE`.
*/
readonly opsLimit?: number;
/**
* Memory cost in bytes. Defaults to
* `crypto_pwhash_MEMLIMIT_INTERACTIVE`.
*/
readonly memLimit?: number;
}
/**
* Hashes a password using Argon2id and returns a self-contained encoded
* string that includes the algorithm identifier, salt, and parameters.
*
* The returned string is safe to store directly in a database; it encodes
* everything `cryptoPwhashStrVerify` needs to re-derive and compare.
*
* Defaults to `OPSLIMIT_INTERACTIVE` / `MEMLIMIT_INTERACTIVE` which are
* appropriate for interactive login flows. Increase both limits for
* offline-attack–resistant storage of high-value credentials.
*
* @param password Raw password bytes.
* @param opts Optional Argon2id cost parameters.
* @returns Encoded Argon2id hash string (≈97 chars).
*/
export async function cryptoPwhashStr(
password: Uint8Array,
opts: PwhashOptions = {}
): Promise<string> {
await sodiumReady();
const opsLimit = opts.opsLimit ?? sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE;
const memLimit = opts.memLimit ?? sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE;
return sodium.crypto_pwhash_str(password, opsLimit, memLimit);
}
/**
* Verifies a password against an Argon2id hash string produced by
* `cryptoPwhashStr`.
*
* Uses libsodium's constant-time comparison to prevent timing attacks.
*
* @param hash Encoded hash string from `cryptoPwhashStr`.
* @param password Raw password bytes to verify.
* @returns `true` if the password matches, `false` otherwise.
*/
export async function cryptoPwhashStrVerify(
hash: string,
password: Uint8Array
): Promise<boolean> {
await sodiumReady();
try {
return sodium.crypto_pwhash_str_verify(hash, password);
} catch {
/* istanbul ignore next -- sodium returns false for bad hashes, rarely throws */
return false;
}
}

View File

@@ -0,0 +1,5 @@
export {
cryptoPwhashStr,
cryptoPwhashStrVerify,
type PwhashOptions,
} from "./crypto-pwhash.js";

View File

@@ -0,0 +1,12 @@
export { cryptoAuth, cryptoAuthVerify } from "./crypto-auth/crypto-auth.js";
export {
cryptoKdf,
KDF_SUBKEY_MAX,
KDF_SUBKEY_MIN,
} from "./crypto-kdf/crypto-kdf.js";
export {
cryptoPwhashStr,
cryptoPwhashStrVerify,
type PwhashOptions,
} from "./crypto-pwhash/crypto-pwhash.js";
export { ristretto255 } from "./ristretto255/ristretto255.js";

View File

@@ -0,0 +1 @@
export { ristretto255 } from "./ristretto255.js";

View File

@@ -0,0 +1,98 @@
# primitives/ristretto255
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ristretto255` namespace exposes libsodium's Ristretto255 group operations — a prime-order group construction over Curve25519 that eliminates the cofactor pitfalls present in raw Edwards25519 arithmetic. Ristretto255 is used in zero-knowledge proofs, Oblivious Pseudorandom Functions (OPRFs), Password-Authenticated Key Exchanges (PAKEs), and Verifiable Random Functions (VRFs).
By wrapping libsodium's `crypto_core_ristretto255_*` and `crypto_scalarmult_ristretto255_*` APIs, this module provides the building blocks for advanced cryptographic protocols without requiring callers to reason about low-level curve arithmetic or cofactor clearing.
## Features
- Group element addition (`add`) and subtraction (`sub`) via `crypto_core_ristretto255_add/sub`.
- Scalar multiplication (`mul`) via `crypto_scalarmult_ristretto255`.
- Hash-to-curve via `fromHash` using `crypto_core_ristretto255_from_hash` (64-byte input).
- Point serialisation (`toBytes`) and deserialisation with validation (`fromBytes`).
- Random group element generation (`random`) via `crypto_core_ristretto255_random`.
- All operations are async and call `sodiumReady()` internally.
- `fromBytes` throws `RangeError` for invalid or identity points.
## Usage: Basic Example
```ts
import { ristretto255 } from "@suaveplan/crypto-sodium/primitives/ristretto255";
// Generate two random group elements
const p = await ristretto255.random();
const q = await ristretto255.random();
// Add them
const sum = await ristretto255.add(p, q);
// Verify commutativity
const sumQP = await ristretto255.add(q, p);
// sum deepEquals sumQP
```
## Usage: Advanced Example
Hash-to-curve for OPRF input blinding:
```ts
import { ristretto255 } from "@suaveplan/crypto-sodium/primitives/ristretto255";
// Hash the password to a group element (hash-to-curve)
// In a real OPRF you'd use a proper H2C function; fromHash works for demos
async function hashToPoint(input: Uint8Array): Promise<Uint8Array> {
// Pad/hash input to 64 bytes with SHA-512 first in production:
const padded = new Uint8Array(64);
padded.set(input.subarray(0, Math.min(input.length, 64)));
return ristretto255.fromHash(padded);
}
// Scalar multiplication: client blinds the point with a random scalar
const blindScalar = new Uint8Array(32);
crypto.getRandomValues(blindScalar);
const point = await hashToPoint(new TextEncoder().encode("password"));
const blinded = await ristretto255.mul(blindScalar, point);
```
## API Reference
### `ristretto255.add(p, q): Promise<Uint8Array>`
Adds two group elements. Commutative: `add(p, q) === add(q, p)`.
### `ristretto255.sub(p, q): Promise<Uint8Array>`
Subtracts `q` from `p`. Inverse of `add`.
### `ristretto255.mul(scalar, point): Promise<Uint8Array>`
Scalar multiplication: computes `scalar * point`. Scalar is a 32-byte little-endian integer.
### `ristretto255.fromHash(hash): Promise<Uint8Array>`
Maps a 64-byte hash uniformly to a group element via `crypto_core_ristretto255_from_hash`.
### `ristretto255.toBytes(point): Promise<Uint8Array>`
Returns a copy of the 32-byte canonical encoding of `point`.
### `ristretto255.fromBytes(bytes): Promise<Uint8Array>`
Validates and returns a group element. **Throws `RangeError`** if `bytes` is not a valid, non-identity Ristretto255 point.
### `ristretto255.random(): Promise<Uint8Array>`
Generates a uniformly random 32-byte group element.
## Implementation Notes
Ristretto255 represents each group element as a 32-byte canonical encoding. Unlike raw Edwards25519, every 32-byte encoding maps to at most one group element — there are no encoding ambiguities or cofactor issues.
`fromBytes` calls `crypto_core_ristretto255_is_valid_point` before returning, which rejects the identity element (all-zeros) and any bytes that do not correspond to a valid Ristretto255 point. This prevents callers from accidentally working with invalid inputs in downstream protocols.
All operations return new `Uint8Array` allocations; the input arrays are not mutated.

View File

@@ -0,0 +1,82 @@
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
import { sodiumReady } from "../../init/init.js";
import { ristretto255 } from "./ristretto255.js";
describe("ristretto255", () => {
beforeAll(async () => {
await sodiumReady();
}, 30_000);
it("random() produces 32-byte group element", {
timeout: 30_000,
}, async () => {
const p = await ristretto255.random();
expect(p.length).toBe(32);
});
it("random() produces different elements each call", {
timeout: 30_000,
}, async () => {
const p = await ristretto255.random();
const q = await ristretto255.random();
expect(p).not.toEqual(q);
});
it("fromHash() maps 64 bytes to a group element", {
timeout: 30_000,
}, async () => {
const hash = new Uint8Array(64).fill(0x42);
const point = await ristretto255.fromHash(hash);
expect(point.length).toBe(32);
});
it("add(p, q) is commutative: add(p,q) == add(q,p)", {
timeout: 30_000,
}, async () => {
const p = await ristretto255.random();
const q = await ristretto255.random();
const pq = await ristretto255.add(p, q);
const qp = await ristretto255.add(q, p);
expect(pq).toEqual(qp);
});
it("add then sub is identity", { timeout: 30_000 }, async () => {
const p = await ristretto255.random();
const q = await ristretto255.random();
const sum = await ristretto255.add(p, q);
const back = await ristretto255.sub(sum, q);
expect(back).toEqual(p);
});
it("mul scalar*point produces a 32-byte element", {
timeout: 30_000,
}, async () => {
const p = await ristretto255.random();
// Use a small non-zero scalar
const scalar = new Uint8Array(32);
scalar[0] = 2;
const result = await ristretto255.mul(scalar, p);
expect(result.length).toBe(32);
});
it("toBytes returns a copy of the point", { timeout: 30_000 }, async () => {
const p = await ristretto255.random();
const bytes = await ristretto255.toBytes(p);
expect(bytes).toEqual(p);
// Mutation of result doesn't affect original
bytes[0] ^= 0xff;
expect(bytes).not.toEqual(p);
});
it("fromBytes accepts a valid point", { timeout: 30_000 }, async () => {
const p = await ristretto255.random();
const back = await ristretto255.fromBytes(p);
expect(back).toEqual(p);
});
it("fromBytes throws for invalid point", { timeout: 30_000 }, async () => {
// High-bit patterns outside the ristretto255 field are not valid points
const bad = new Uint8Array(32).fill(0xff);
await expect(ristretto255.fromBytes(bad)).rejects.toThrow(RangeError);
});
});

View File

@@ -0,0 +1,111 @@
import * as sodium from "libsodium-wrappers-sumo";
import { sodiumReady } from "../../init/init.js";
/**
* Adds two Ristretto255 group elements.
*
* @param p First group element (32 bytes).
* @param q Second group element (32 bytes).
* @returns Sum as a 32-byte group element.
*/
async function add(p: Uint8Array, q: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_core_ristretto255_add(p, q);
}
/**
* Subtracts one Ristretto255 group element from another.
*
* @param p Minuend group element (32 bytes).
* @param q Subtrahend group element (32 bytes).
* @returns Difference as a 32-byte group element.
*/
async function sub(p: Uint8Array, q: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_core_ristretto255_sub(p, q);
}
/**
* Scalar multiplication on the Ristretto255 group: computes `scalar * point`.
*
* @param scalar 32-byte scalar (big-endian).
* @param point 32-byte group element.
* @returns Result as a 32-byte group element.
*/
async function mul(scalar: Uint8Array, point: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_scalarmult_ristretto255(scalar, point);
}
/**
* Maps a 64-byte hash to a Ristretto255 group element.
*
* Uses `crypto_core_ristretto255_from_hash`. Useful for hash-to-curve
* operations in OPRF / PAKEs.
*
* @param hash 64-byte input hash.
* @returns 32-byte group element.
*/
async function fromHash(hash: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_core_ristretto255_from_hash(hash);
}
/**
* Returns the canonical byte-encoding of a Ristretto255 group element.
*
* This is a no-op wrapper provided for API symmetry; Ristretto255 points are
* already stored as their 32-byte compressed encoding.
*
* @param point 32-byte group element.
* @returns Same 32-byte encoding (copy).
*/
async function toBytes(point: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
return point.slice();
}
/**
* Validates and returns a Ristretto255 group element from bytes.
*
* Calls `crypto_core_ristretto255_is_valid_point` and throws if the bytes do
* not represent a valid, non-identity group element.
*
* @param bytes 32-byte candidate point.
* @returns Validated 32-byte group element.
*/
async function fromBytes(bytes: Uint8Array): Promise<Uint8Array> {
await sodiumReady();
if (!sodium.crypto_core_ristretto255_is_valid_point(bytes)) {
throw new RangeError(
"ristretto255.fromBytes: invalid or identity point"
);
}
return bytes.slice();
}
/**
* Generates a uniformly random Ristretto255 group element.
*
* @returns Fresh 32-byte group element sampled uniformly at random.
*/
async function random(): Promise<Uint8Array> {
await sodiumReady();
return sodium.crypto_core_ristretto255_random();
}
/**
* Ristretto255 group-operation namespace.
*
* All operations return `Promise<Uint8Array>` and call `sodiumReady()`
* internally — no pre-warming required.
*/
export const ristretto255 = {
add,
sub,
mul,
fromHash,
toBytes,
fromBytes,
random,
} as const;

View File

@@ -0,0 +1,37 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED_GLOBAL: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\.[a-z]/,
/\bwindow\.[a-z]/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ avoids Node + browser built-ins", { timeout: 5000 }, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED_GLOBAL) {
if (pattern.test(content)) hits.push(`${file} ${pattern}`);
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exposes per-primitive subpath exports", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
// We have: ., ./init, ./engines, ./engines/aead, ./engines/sign,
// ./engines/agree, ./engines/sealed-box, ./engines/auth-box,
// ./primitives/kdf, ./primitives/auth, ./primitives/pwhash,
// ./primitives/ristretto255, ./errors + ./package.json = 14
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(
12
);
});
});

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"],
"paths": {
"libsodium-wrappers-sumo": ["./types/libsodium-wrappers-sumo/index.d.ts"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,71 @@
// Type declarations for libsodium-wrappers-sumo.
//
// The package doesn't ship its own .d.ts and @types/libsodium-wrappers-sumo
// is a deprecated stub. This declaration re-exports the base wrappers types
// (which cover most of the API) and adds the sumo-only extensions present in
// the sumo bundle but absent from @types/libsodium-wrappers@0.7.14.
//
// Source files use: import * as sodium from "libsodium-wrappers-sumo"
// which resolves to these named exports.
export * from "libsodium-wrappers";
// Sumo-only: AES-256-GCM (hardware-accelerated, not always available)
export declare function crypto_aead_aes256gcm_is_available(): boolean;
export declare const crypto_aead_aes256gcm_KEYBYTES: number;
export declare const crypto_aead_aes256gcm_NPUBBYTES: number;
export declare const crypto_aead_aes256gcm_ABYTES: number;
export declare function crypto_aead_aes256gcm_encrypt(
message: Uint8Array | string,
additional_data: Uint8Array | string | null,
secret_nonce: Uint8Array | null,
public_nonce: Uint8Array,
key: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_aead_aes256gcm_decrypt(
secret_nonce: Uint8Array | null,
ciphertext: Uint8Array,
additional_data: Uint8Array | string | null,
public_nonce: Uint8Array,
key: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
// Sumo-only: Ristretto255 prime-order group operations
export declare const crypto_core_ristretto255_BYTES: number;
export declare const crypto_core_ristretto255_HASHBYTES: number;
export declare const crypto_core_ristretto255_NONREDUCEDSCALARBYTES: number;
export declare const crypto_core_ristretto255_SCALARBYTES: number;
export declare function crypto_core_ristretto255_add(
p: Uint8Array,
q: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_core_ristretto255_sub(
p: Uint8Array,
q: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_core_ristretto255_from_hash(
r: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_core_ristretto255_is_valid_point(
p: Uint8Array
): boolean;
export declare function crypto_core_ristretto255_random(
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_core_ristretto255_scalar_random(
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_scalarmult_ristretto255(
scalar: Uint8Array,
p: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;
export declare function crypto_scalarmult_ristretto255_base(
scalar: Uint8Array,
outputFormat?: "uint8array" | null
): Uint8Array;

View File

@@ -0,0 +1,62 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-sodium",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"init/init": "src/init/init.ts",
"engines/index": "src/engines/index.ts",
"engines/sodium-aead-engine/sodium-aead-engine":
"src/engines/sodium-aead-engine/sodium-aead-engine.ts",
"engines/sodium-sign-engine/sodium-sign-engine":
"src/engines/sodium-sign-engine/sodium-sign-engine.ts",
"engines/sodium-agree-engine/sodium-agree-engine":
"src/engines/sodium-agree-engine/sodium-agree-engine.ts",
"engines/sodium-sealed-box-engine/sodium-sealed-box-engine":
"src/engines/sodium-sealed-box-engine/sodium-sealed-box-engine.ts",
"engines/sodium-auth-box-engine/sodium-auth-box-engine":
"src/engines/sodium-auth-box-engine/sodium-auth-box-engine.ts",
"primitives/crypto-kdf/crypto-kdf":
"src/primitives/crypto-kdf/crypto-kdf.ts",
"primitives/crypto-auth/crypto-auth":
"src/primitives/crypto-auth/crypto-auth.ts",
"primitives/crypto-pwhash/crypto-pwhash":
"src/primitives/crypto-pwhash/crypto-pwhash.ts",
"primitives/ristretto255/ristretto255":
"src/primitives/ristretto255/ristretto255.ts",
"errors/index": "src/errors/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"libsodium-wrappers-sumo",
"@suaveplan/crypto",
"@suaveplan/error",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/**/*.bench.ts",
"benchmarks/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/constants/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30_000,
},
});