Initial Commit
This commit is contained in:
21
packages/security-stack/crypto/crypto-sodium/CHANGELOG.md
Normal file
21
packages/security-stack/crypto/crypto-sodium/CHANGELOG.md
Normal 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.
|
||||
89
packages/security-stack/crypto/crypto-sodium/README.md
Normal file
89
packages/security-stack/crypto/crypto-sodium/README.md
Normal file
File diff suppressed because one or more lines are too long
184
packages/security-stack/crypto/crypto-sodium/package.json
Normal file
184
packages/security-stack/crypto/crypto-sodium/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createSodiumAeadEngine,
|
||||
type SodiumAeadOptions,
|
||||
sodiumAeadEngine,
|
||||
sodiumAesGcmEngine,
|
||||
} from "./sodium-aead-engine.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { sodiumAgreeEngine } from "./sodium-agree-engine.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { sodiumAuthBoxEngine } from "./sodium-auth-box-engine.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { sodiumSealedBoxEngine } from "./sodium-sealed-box-engine.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { sodiumSignEngine } from "./sodium-sign-engine.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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];
|
||||
@@ -0,0 +1 @@
|
||||
export { SODIUM_CODES, type SodiumCode } from "./codes.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SODIUM_CODES, type SodiumCode } from "./codes/codes.js";
|
||||
export { SodiumError } from "./sodium-error/sodium-error.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { SodiumError } from "./sodium-error.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
35
packages/security-stack/crypto/crypto-sodium/src/index.ts
Normal file
35
packages/security-stack/crypto/crypto-sodium/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { sodiumReady } from "./init";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cryptoAuth, cryptoAuthVerify } from "./crypto-auth.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cryptoKdf, KDF_SUBKEY_MAX, KDF_SUBKEY_MIN } from "./crypto-kdf.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
cryptoPwhashStr,
|
||||
cryptoPwhashStrVerify,
|
||||
type PwhashOptions,
|
||||
} from "./crypto-pwhash.js";
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export { ristretto255 } from "./ristretto255.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
71
packages/security-stack/crypto/crypto-sodium/types/libsodium-wrappers-sumo/index.d.ts
vendored
Normal file
71
packages/security-stack/crypto/crypto-sodium/types/libsodium-wrappers-sumo/index.d.ts
vendored
Normal 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;
|
||||
62
packages/security-stack/crypto/crypto-sodium/vite.config.ts
Normal file
62
packages/security-stack/crypto/crypto-sodium/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user