first commit
This commit is contained in:
16
docs/security-stack/crypto/crypto-browser/CHANGELOG.md
Normal file
16
docs/security-stack/crypto/crypto-browser/CHANGELOG.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# @suaveplan/crypto-browser Changelog
|
||||
|
||||
## 0.0.0 — suaveplan initial
|
||||
|
||||
- Initial implementation: WebCrypto-backed AEAD, sign, and agree engines
|
||||
- `webCryptoAeadEngine` — AES-256-GCM via `crypto.subtle`
|
||||
- `webCryptoSignEngine` / `webCryptoEd25519Engine` / `webCryptoEcdsaP256Engine` — Ed25519 and ECDSA-P256
|
||||
- `webCryptoAgreeEngine` / `webCryptoX25519Engine` / `webCryptoEcdhP256Engine` — X25519 and ECDH-P256
|
||||
- `rsa` namespace — RSA-PSS, RSA-OAEP, RSASSA-PKCS1-v1_5
|
||||
- `aesKw` namespace — AES Key Wrap (RFC 3394)
|
||||
- `ecdsaP384` namespace — ECDSA with P-384 / SHA-384
|
||||
- `ecdhP384` namespace — ECDH with P-384
|
||||
- `nonExtractableKey()` — non-extractable CryptoKey generation
|
||||
- `isWebCryptoAvailable()` / `assertWebCryptoAvailable()` — runtime checks
|
||||
- `BrowserCryptoError` with codes: `NOT_AVAILABLE`, `KEY.GENERATION_FAILED`, `OPERATION.FAILED`
|
||||
- 100% coverage threshold (statements, branches, functions, lines)
|
||||
89
docs/security-stack/crypto/crypto-browser/README.md
Normal file
89
docs/security-stack/crypto/crypto-browser/README.md
Normal file
File diff suppressed because one or more lines are too long
106
docs/security-stack/crypto/crypto-browser/src/aes-kw/aes-kw.md
Normal file
106
docs/security-stack/crypto/crypto-browser/src/aes-kw/aes-kw.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# aes-kw
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides AES Key Wrap (AES-KW, RFC 3394) operations via `crypto.subtle`. AES-KW is an authenticated key-wrapping algorithm that encapsulates cryptographic key material with integrity protection, producing output that is 8 bytes longer than the input. It is widely used in key management systems, JOSE (JSON Web Encryption), CMS enveloped-data, and TLS session ticket encryption.
|
||||
|
||||
Unlike general-purpose AES-GCM, AES-KW is specifically designed for wrapping other cryptographic keys. The algorithm provides integrity verification — any tampering with the wrapped output will be detected during unwrapping.
|
||||
|
||||
## Features
|
||||
|
||||
- `wrapKey(wrappingKey, keyToWrap)` — wraps raw key bytes using AES-KW via `crypto.subtle.wrapKey`
|
||||
- `unwrapKey(wrappingKey, wrappedKey)` — unwraps and returns raw key bytes via `crypto.subtle.unwrapKey`
|
||||
- Supports 128, 192, and 256-bit (16, 24, 32-byte) wrapping keys
|
||||
- Input key must be a multiple of 8 bytes (AES-KW requirement)
|
||||
- Wrapped output is input length + 8 (integrity check value)
|
||||
- `assertWebCryptoAvailable()` guard on every call
|
||||
- Typed `BrowserCryptoError` on all failure paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { aesKw } from "@suaveplan/crypto-browser/aes-kw/aes-kw";
|
||||
|
||||
const wrappingKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit KEK
|
||||
const aesKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit content key
|
||||
|
||||
const wrapped = await aesKw.wrapKey(wrappingKey, aesKey);
|
||||
// wrapped.length === aesKey.length + 8 === 40
|
||||
|
||||
const recovered = await aesKw.unwrapKey(wrappingKey, wrapped);
|
||||
// recovered deepEquals aesKey
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { aesKw } from "@suaveplan/crypto-browser/aes-kw/aes-kw";
|
||||
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
// Key Encryption Key (KEK) derived from a user password or stored securely
|
||||
declare const kek: Uint8Array; // 32-byte wrapping key
|
||||
|
||||
// Wrap content encryption key for storage or transmission
|
||||
async function storeWrappedKey(contentKey: Uint8Array): Promise<Uint8Array> {
|
||||
try {
|
||||
return await aesKw.wrapKey(kek, contentKey);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError && err.code === "BROWSER_CRYPTO.OPERATION.FAILED") {
|
||||
throw new Error("Key wrapping failed — check wrapping key size");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Later: unwrap and use the content key
|
||||
async function loadAndEncrypt(
|
||||
wrappedKey: Uint8Array,
|
||||
plaintext: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const contentKey = await aesKw.unwrapKey(kek, wrappedKey);
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
return webCryptoAeadEngine.encrypt(contentKey, plaintext, nonce) as unknown as Promise<Uint8Array>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `aesKw.wrapKey(wrappingKey, keyToWrap): Promise<Uint8Array>`
|
||||
|
||||
Wraps `keyToWrap` using AES-KW.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `wrappingKey` | `Uint8Array` | 16, 24, or 32-byte AES Key Encryption Key (KEK) |
|
||||
| `keyToWrap` | `Uint8Array` | Key bytes to wrap; must be a multiple of 8 bytes |
|
||||
|
||||
**Returns:** `Promise<Uint8Array>` — wrapped bytes, `keyToWrap.length + 8` bytes long.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
---
|
||||
|
||||
### `aesKw.unwrapKey(wrappingKey, wrappedKey): Promise<Uint8Array>`
|
||||
|
||||
Unwraps `wrappedKey` using AES-KW and returns the original key bytes.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `wrappingKey` | `Uint8Array` | 16, 24, or 32-byte KEK (must match wrapping key) |
|
||||
| `wrappedKey` | `Uint8Array` | Previously wrapped bytes from `wrapKey()` |
|
||||
|
||||
**Returns:** `Promise<Uint8Array>` — original raw key bytes.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` when integrity check fails (wrong key or tampered data).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`crypto.subtle.wrapKey` requires a `CryptoKey` object as input (not a raw `Uint8Array`). The implementation imports `keyToWrap` as a temporary `AES-GCM` key with `extractable: true` before wrapping — this is the minimal ceremony needed to satisfy the `crypto.subtle` API. The temporary key is never exported directly; only the AES-KW wrapped form is returned.
|
||||
|
||||
Similarly, `crypto.subtle.unwrapKey` produces a `CryptoKey` internally; the implementation immediately re-exports it as raw bytes via `exportKey("raw", ...)` so the caller receives a portable `Uint8Array`. This round-trip through `CryptoKey` is required by the Web Crypto API and adds negligible overhead.
|
||||
|
||||
AES-KW does not use a nonce — it is deterministic for a given (wrapping-key, key-to-wrap) pair. This is intentional for RFC 3394 compliance; nonce-based confidentiality is provided by AES-GCM when encrypting arbitrary data.
|
||||
@@ -0,0 +1,85 @@
|
||||
# availability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a runtime check for the presence of the Web Crypto API (`crypto.subtle`). The check is a prerequisite for every operation in `@suaveplan/crypto-browser`: all engine methods and primitives call `assertWebCryptoAvailable()` before attempting any `crypto.subtle` call, so callers receive a structured, typed `BrowserCryptoError` rather than an opaque `TypeError: Cannot read properties of undefined (reading 'subtle')`.
|
||||
|
||||
The two exported helpers form a small contract: `isWebCryptoAvailable` is a pure predicate useful for conditional branching or feature-flag evaluation, while `assertWebCryptoAvailable` is the guard variant used internally throughout the package.
|
||||
|
||||
## Features
|
||||
|
||||
- `isWebCryptoAvailable(): boolean` — returns `true` when both `globalThis.crypto` and `globalThis.crypto.subtle` are defined; otherwise `false`
|
||||
- `assertWebCryptoAvailable(): void` — throws `BrowserCryptoError` with code `BROWSER_CRYPTO.NOT_AVAILABLE` if the check fails
|
||||
- Zero side effects — purely reads globals, never modifies them
|
||||
- Works across Node 20+, Bun, Deno, and modern browsers without polyfills
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isWebCryptoAvailable } from "@suaveplan/crypto-browser/availability/availability";
|
||||
|
||||
if (!isWebCryptoAvailable()) {
|
||||
console.warn("WebCrypto not available — using fallback cipher");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { assertWebCryptoAvailable } from "@suaveplan/crypto-browser/availability/availability";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
async function encryptUserData(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||
// Guard before any crypto.subtle calls
|
||||
assertWebCryptoAvailable();
|
||||
|
||||
// ... proceed with crypto.subtle.encrypt
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
// Caller can catch the specific structured error
|
||||
try {
|
||||
await encryptUserData(key, data);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError && err.code === "BROWSER_CRYPTO.NOT_AVAILABLE") {
|
||||
// Fall back to a server-side encryption endpoint
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isWebCryptoAvailable(): boolean`
|
||||
|
||||
Returns `true` when `typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| _(none)_ | — | — |
|
||||
|
||||
**Returns:** `boolean` — `true` if `crypto.subtle` is accessible.
|
||||
|
||||
**Throws:** never.
|
||||
|
||||
---
|
||||
|
||||
### `assertWebCryptoAvailable(): void`
|
||||
|
||||
Calls `isWebCryptoAvailable()` and throws if the result is `false`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| _(none)_ | — | — |
|
||||
|
||||
**Returns:** `void`
|
||||
|
||||
**Throws:** `BrowserCryptoError` — code `BROWSER_CRYPTO.NOT_AVAILABLE` when `crypto.subtle` is absent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The check deliberately uses `typeof crypto !== 'undefined'` (rather than a `try/catch` or optional chaining on a possibly-unset global) to avoid triggering a `ReferenceError` in environments where `crypto` is not declared at all. Optional chaining `globalThis?.crypto?.subtle` is not used because `globalThis` itself might be unavailable in some older embedded runtimes.
|
||||
|
||||
Both exports are side-effect-free and tree-shakeable: `"sideEffects": false` is set on the package, so bundlers that import only `isWebCryptoAvailable` will not include `BrowserCryptoError` in the output unless it is actually used.
|
||||
@@ -0,0 +1,105 @@
|
||||
# ecdh-p384
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Elliptic Curve Diffie-Hellman key agreement using the NIST P-384 curve via `crypto.subtle`. P-384 ECDH delivers 192 bits of security, matching the security level of AES-256 and making it appropriate for long-term key agreement in high-security contexts. This module is the P-384 analogue of the `webCryptoEcdhP256Engine` provided in the engines module, exposed as a standalone namespace for direct use outside of the `AgreeEngine` interface.
|
||||
|
||||
Key agreement produces a 48-byte (384-bit) shared secret by calling `crypto.subtle.deriveBits` with `length: 384`. Callers are responsible for passing this secret through a KDF before using it as an encryption key.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeypair()` — generates a P-384 ECDH keypair via `crypto.subtle.generateKey`
|
||||
- `agree(privateKey, publicKey)` — derives 48-byte shared secret via `crypto.subtle.deriveBits`
|
||||
- Full DH symmetry: `agree(alice, bob.pub)` equals `agree(bob, alice.pub)`
|
||||
- Public key: 97-byte uncompressed point (raw format, 0x04 prefix)
|
||||
- Private key: PKCS8 DER bytes
|
||||
- `assertWebCryptoAvailable()` guard on every call
|
||||
- Typed `BrowserCryptoError` for all failure paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ecdhP384 } from "@suaveplan/crypto-browser/ecdh-p384/ecdh-p384";
|
||||
|
||||
const alice = await ecdhP384.generateKeypair();
|
||||
const bob = await ecdhP384.generateKeypair();
|
||||
|
||||
// Both sides independently compute the same 48-byte secret
|
||||
const aliceShared = await ecdhP384.agree(alice.privateKey, bob.publicKey);
|
||||
const bobShared = await ecdhP384.agree(bob.privateKey, alice.publicKey);
|
||||
|
||||
// aliceShared deepEquals bobShared — 48 bytes (384 bits)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ecdhP384 } from "@suaveplan/crypto-browser/ecdh-p384/ecdh-p384";
|
||||
|
||||
// KDF using HKDF to derive a 32-byte AES key from the P-384 shared secret
|
||||
async function deriveAesKey(
|
||||
privateKey: Uint8Array,
|
||||
peerPublicKey: Uint8Array,
|
||||
info: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const sharedSecret = await ecdhP384.agree(privateKey, peerPublicKey);
|
||||
|
||||
// Import shared secret as HKDF key material
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecret,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
|
||||
const aesKeyBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-384",
|
||||
salt: new Uint8Array(48), // zero salt
|
||||
info,
|
||||
},
|
||||
hkdfKey,
|
||||
256
|
||||
);
|
||||
|
||||
return new Uint8Array(aesKeyBits);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ecdhP384.generateKeypair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>`
|
||||
|
||||
Generates a P-384 ECDH keypair.
|
||||
|
||||
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — 97-byte uncompressed public key and PKCS8 DER private key.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
---
|
||||
|
||||
### `ecdhP384.agree(privateKey, publicKey): Promise<Uint8Array>`
|
||||
|
||||
Performs ECDH key agreement, deriving 384 bits from `deriveBits`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `privateKey` | `Uint8Array` | PKCS8 DER private key bytes |
|
||||
| `publicKey` | `Uint8Array` | 97-byte uncompressed P-384 public key |
|
||||
|
||||
**Returns:** `Promise<Uint8Array>` — 48-byte (384-bit) shared secret.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
P-384 `deriveBits` is called with `length: 384` to extract the full 384-bit shared secret. This matches the curve's security level and avoids the truncation that would occur if 256 bits were requested. Callers using the output as an AES-256 key should pass through HKDF first to properly condition the key material.
|
||||
|
||||
The public key is exported from `crypto.subtle` via `"raw"` format, which produces the standard uncompressed point encoding. This is directly importable on any Web Crypto implementation that supports P-384.
|
||||
|
||||
P-384 is approximately 2–3× slower than P-256 for key generation and agreement. For applications where P-256's 128-bit security is sufficient, `webCryptoEcdhP256Engine` provides the same API with better performance.
|
||||
@@ -0,0 +1,117 @@
|
||||
# ecdsa-p384
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides ECDSA (Elliptic Curve Digital Signature Algorithm) operations using the NIST P-384 curve and SHA-384 hash, backed by `crypto.subtle`. P-384 offers 192 bits of security — stronger than P-256's 128 bits — and is required by many compliance frameworks (NIST SP 800-57, CNSA Suite 2.0, NSA Suite B). This module is intended for contexts where P-384 is mandated by policy or interoperability requirements.
|
||||
|
||||
The public key format is the 97-byte uncompressed point representation (0x04 prefix followed by 48-byte X and Y coordinates). The private key is PKCS8 DER. Both formats are standard and directly importable by other Web Crypto implementations.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeypair()` — generates a P-384 ECDSA keypair using `crypto.subtle.generateKey`
|
||||
- `sign(privateKey, message)` — signs with ECDSA-P384 / SHA-384
|
||||
- `verify(publicKey, signature, message)` — verifies an ECDSA-P384 signature
|
||||
- Public key: 97-byte uncompressed point (raw format)
|
||||
- Private key: PKCS8 DER bytes
|
||||
- Signature format: IEEE P1363 (R || S) as returned by `crypto.subtle`
|
||||
- `assertWebCryptoAvailable()` guard on every call
|
||||
- Typed `BrowserCryptoError` for all failure paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ecdsaP384 } from "@suaveplan/crypto-browser/ecdsa-p384/ecdsa-p384";
|
||||
|
||||
const kp = await ecdsaP384.generateKeypair();
|
||||
// kp.publicKey is 97 bytes (uncompressed P-384 point)
|
||||
// kp.privateKey is PKCS8 DER bytes
|
||||
|
||||
const message = new TextEncoder().encode("document to sign");
|
||||
const signature = await ecdsaP384.sign(kp.privateKey, message);
|
||||
const valid = await ecdsaP384.verify(kp.publicKey, signature, message);
|
||||
console.log(valid); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ecdsaP384 } from "@suaveplan/crypto-browser/ecdsa-p384/ecdsa-p384";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
// Issue a signed certificate-like structure
|
||||
async function issueAttestation(
|
||||
issuerPrivateKey: Uint8Array,
|
||||
payload: Uint8Array
|
||||
): Promise<{ signature: Uint8Array; algorithm: string }> {
|
||||
try {
|
||||
const signature = await ecdsaP384.sign(issuerPrivateKey, payload);
|
||||
return { signature, algorithm: "ECDSA-P384-SHA384" };
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) {
|
||||
throw new Error(`Signing failed: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the attestation with a stored public key
|
||||
async function verifyAttestation(
|
||||
publicKey: Uint8Array,
|
||||
payload: Uint8Array,
|
||||
signature: Uint8Array
|
||||
): Promise<boolean> {
|
||||
return ecdsaP384.verify(publicKey, signature, payload);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ecdsaP384.generateKeypair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>`
|
||||
|
||||
Generates a P-384 ECDSA keypair.
|
||||
|
||||
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — 97-byte uncompressed public key and PKCS8 DER private key.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
---
|
||||
|
||||
### `ecdsaP384.sign(privateKey, message): Promise<Uint8Array>`
|
||||
|
||||
Signs `message` using ECDSA-P384 with SHA-384.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `privateKey` | `Uint8Array` | PKCS8 DER private key bytes |
|
||||
| `message` | `Uint8Array` | Bytes to sign |
|
||||
|
||||
**Returns:** `Promise<Uint8Array>` — IEEE P1363 signature (96 bytes for P-384).
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
---
|
||||
|
||||
### `ecdsaP384.verify(publicKey, signature, message): Promise<boolean>`
|
||||
|
||||
Verifies an ECDSA-P384 / SHA-384 signature.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `publicKey` | `Uint8Array` | 97-byte uncompressed P-384 public key |
|
||||
| `signature` | `Uint8Array` | IEEE P1363 signature bytes |
|
||||
| `message` | `Uint8Array` | Original message bytes |
|
||||
|
||||
**Returns:** `Promise<boolean>` — `true` if signature is valid.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SHA-384 is paired with P-384 because the security level of the hash should match that of the curve. Using SHA-256 with P-384 would create a mismatch: the 192-bit curve security would be limited to the 128-bit collision resistance of SHA-256.
|
||||
|
||||
The signature output format from `crypto.subtle.sign` for ECDSA is IEEE P1363 (raw R || S concatenation), not DER/ASN.1. This differs from OpenSSL's default DER encoding. When interoperating with systems that expect DER signatures, a conversion layer is needed.
|
||||
|
||||
P-384 key generation is typically 2–5× slower than P-256 due to the larger field size. For high-throughput signing workloads where P-256 security is sufficient, consider using `webCryptoEcdsaP256Engine` instead.
|
||||
@@ -0,0 +1,108 @@
|
||||
# webcrypto-aead-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides an `AeadEngine` implementation that delegates all AES-256-GCM operations to the browser's (or Node's) native `crypto.subtle` API. This lets `@suaveplan/crypto` facade functions — `encrypt()` and `decrypt()` from the `aead` sub-module — run on hardware-accelerated paths wherever the Web Crypto API is available, instead of the default pure-JavaScript `@noble/ciphers` path.
|
||||
|
||||
The engine is designed for environments where minimising bundle size matters most: importing it adds zero third-party cryptography code because everything is provided by the host runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `encrypt(key, plaintext, nonce, aad?)` — AES-256-GCM encryption via `crypto.subtle.encrypt`, including automatic 16-byte authentication tag appended to ciphertext
|
||||
- `decrypt(key, ciphertext, nonce, aad?)` — AES-256-GCM decryption via `crypto.subtle.decrypt` with authenticated tag verification
|
||||
- Optional additional authenticated data (AAD) for binding ciphertext to a context
|
||||
- Throws typed `BrowserCryptoError` with stable codes for every failure path
|
||||
- Returns `Promise<Uint8Array>` — engine methods are async even though the `AeadEngine` interface declares sync return types; facade functions accept Promise results
|
||||
- Guards every call with `assertWebCryptoAvailable()` to produce a structured error instead of a runtime `TypeError`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
|
||||
|
||||
const key = crypto.getRandomValues(new Uint8Array(32));
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode("hello world");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(key, plaintext, nonce) as unknown as Promise<Uint8Array>);
|
||||
const recovered = await (webCryptoAeadEngine.decrypt(key, ciphertext, nonce) as unknown as Promise<Uint8Array>);
|
||||
|
||||
console.log(new TextDecoder().decode(recovered)); // "hello world"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encrypt } from "@suaveplan/crypto/aead";
|
||||
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
async function sealPayload(
|
||||
key: Uint8Array,
|
||||
payload: Uint8Array,
|
||||
context: Uint8Array // AAD binds the ciphertext to e.g. a session ID
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const sealed = await (webCryptoAeadEngine.encrypt(key, payload, nonce, context) as unknown as Promise<Uint8Array>);
|
||||
// Prepend nonce for transport: [12 bytes nonce | ciphertext+tag]
|
||||
const out = new Uint8Array(12 + sealed.length);
|
||||
out.set(nonce, 0);
|
||||
out.set(sealed, 12);
|
||||
return out;
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) {
|
||||
// err.code is one of: BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `webCryptoAeadEngine: AeadEngine`
|
||||
|
||||
An object conforming to the `AeadEngine` interface from `@suaveplan/crypto/engines`.
|
||||
|
||||
#### `.encrypt(key, plaintext, nonce, aad?): Uint8Array`
|
||||
|
||||
Encrypts plaintext using AES-256-GCM.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `Uint8Array` | 32-byte (256-bit) AES key |
|
||||
| `plaintext` | `Uint8Array` | Bytes to encrypt |
|
||||
| `nonce` | `Uint8Array` | 12-byte IV (use a fresh random nonce per message) |
|
||||
| `aad` | `Uint8Array \| undefined` | Optional additional authenticated data |
|
||||
|
||||
**Returns:** `Uint8Array` (runtime: `Promise<Uint8Array>`) — ciphertext with 16-byte GCM tag appended.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
#### `.decrypt(key, ciphertext, nonce, aad?): Uint8Array`
|
||||
|
||||
Decrypts ciphertext and verifies the GCM authentication tag.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `key` | `Uint8Array` | 32-byte AES key (must match encryption key) |
|
||||
| `ciphertext` | `Uint8Array` | Ciphertext with 16-byte GCM tag appended |
|
||||
| `nonce` | `Uint8Array` | 12-byte IV used during encryption |
|
||||
| `aad` | `Uint8Array \| undefined` | Optional AAD (must match encryption AAD exactly) |
|
||||
|
||||
**Returns:** `Uint8Array` (runtime: `Promise<Uint8Array>`) — decrypted plaintext.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` on authentication failure or bad key.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`crypto.subtle.importKey` is called on every encrypt/decrypt invocation rather than being cached, because raw `Uint8Array` keys are the idiomatic interface in `AeadEngine` and caching a `CryptoKey` across calls would require a `Map<Uint8Array, CryptoKey>` with careful lifecycle management. The overhead of `importKey` is dominated by the actual AES-GCM operation for typical payload sizes.
|
||||
|
||||
The `AeadEngine` interface uses synchronous return types (`Uint8Array`), while `crypto.subtle` is entirely asynchronous. The implementation returns `Promise<Uint8Array>` cast to `Uint8Array` at the type level. Facade functions in `@suaveplan/crypto` use `await` on engine results, so this is transparent to consumers using those facades.
|
||||
|
||||
The 12-byte nonce is the NIST-recommended IV length for AES-GCM and provides the best performance with `crypto.subtle`. Longer IVs (96–128 bits) would work but require internal counter splitting which is not done here — callers must supply exactly a 12-byte nonce for maximum compatibility.
|
||||
@@ -0,0 +1,118 @@
|
||||
# webcrypto-agree-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides two `AgreeEngine` implementations — `webCryptoX25519Engine` (X25519 Diffie-Hellman) and `webCryptoEcdhP256Engine` (ECDH with P-256) — both backed by the native `crypto.subtle` API. A convenience alias `webCryptoAgreeEngine` resolves to the X25519 engine. These engines implement the `AgreeEngine` interface from `@suaveplan/crypto/engines`, making them drop-in substitutes for the default `@noble/curves`-backed engine in environments where `crypto.subtle` is available.
|
||||
|
||||
Key agreement is the foundation of forward-secrecy protocols (TLS, Noise, Signal). Using `crypto.subtle` for ECDH ensures hardware acceleration is used when the runtime supports it, and the key material never leaves the key management subsystem if non-extractable keys are used.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeypair()` — generates an ephemeral X25519 or ECDH-P256 keypair; returns portable raw bytes
|
||||
- `agree(mySecret, theirPublic)` — derives a 32-byte shared secret via `crypto.subtle.deriveBits`
|
||||
- Full DH symmetry: `agree(alice, bob.pub) === agree(bob, alice.pub)` for any keypair pair
|
||||
- All methods are async at runtime despite `AgreeEngine` sync declarations
|
||||
- `assertWebCryptoAvailable()` guard on every call
|
||||
- Typed `BrowserCryptoError` on generation or agreement failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { webCryptoX25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-agree-engine";
|
||||
|
||||
const alice = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const bob = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
const aliceSecret = await (webCryptoX25519Engine.agree(alice.secretKey, bob.publicKey) as unknown as Promise<Uint8Array>);
|
||||
const bobSecret = await (webCryptoX25519Engine.agree(bob.secretKey, alice.publicKey) as unknown as Promise<Uint8Array>);
|
||||
|
||||
// aliceSecret deepEquals bobSecret — both parties derived the same 32 bytes
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { webCryptoX25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-agree-engine";
|
||||
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
|
||||
|
||||
// Simple Diffie-Hellman key exchange followed by AES-GCM encryption
|
||||
async function ephemeralEncrypt(
|
||||
recipientPublicKey: Uint8Array,
|
||||
plaintext: Uint8Array
|
||||
): Promise<{ ephemeralPublic: Uint8Array; ciphertext: Uint8Array }> {
|
||||
const ephemeral = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
const sharedSecret = await (webCryptoX25519Engine.agree(
|
||||
ephemeral.secretKey,
|
||||
recipientPublicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
sharedSecret,
|
||||
plaintext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const out = new Uint8Array(12 + ciphertext.length);
|
||||
out.set(nonce, 0);
|
||||
out.set(ciphertext, 12);
|
||||
|
||||
return { ephemeralPublic: ephemeral.publicKey, ciphertext: out };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `webCryptoX25519Engine: AgreeEngine`
|
||||
|
||||
X25519 engine via `crypto.subtle` with `{ name: "X25519" }`.
|
||||
|
||||
#### `.generateKeypair(): AgreeKeypair`
|
||||
|
||||
Returns `Promise<AgreeKeypair>` at runtime. Public key is 32 raw bytes. Secret key is 32-byte scalar.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
|
||||
|
||||
#### `.agree(mySecret, theirPublic): Uint8Array`
|
||||
|
||||
Returns `Promise<Uint8Array>` (32 bytes) at runtime.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `mySecret` | `Uint8Array` | 32-byte X25519 private scalar |
|
||||
| `theirPublic` | `Uint8Array` | 32-byte X25519 public key |
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
---
|
||||
|
||||
### `webCryptoEcdhP256Engine: AgreeEngine`
|
||||
|
||||
ECDH-P256 engine via `crypto.subtle`. Public key is 65-byte uncompressed point. Secret key is PKCS8 DER. `agree()` returns 32 bytes via `deriveBits(..., 256)`.
|
||||
|
||||
---
|
||||
|
||||
### `webCryptoAgreeEngine: AgreeEngine`
|
||||
|
||||
Alias for `webCryptoX25519Engine`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
X25519 public keys are exported from `crypto.subtle` in SPKI format and the raw 32-byte scalar is extracted from the last 32 bytes of the DER envelope. On import the scalar is re-wrapped with a minimal 48-byte PKCS8 structure. This keeps the external representation simple (just 32 bytes) while satisfying `crypto.subtle`'s requirement for DER-encoded keys.
|
||||
|
||||
For ECDH-P256 the key agreement produces a 256-bit shared secret by calling `deriveBits` with `length: 256`. The raw shared secret should be passed through a KDF (e.g., HKDF) before use as an encryption key; this is not done inside the engine so callers retain full control over key derivation.
|
||||
|
||||
X25519 requires Node 20+, Bun 1.0+, or Chrome 113+/Firefox 130+/Safari 15.4+. Older environments will receive `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
|
||||
@@ -0,0 +1,119 @@
|
||||
# webcrypto-sign-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides two `SignEngine` implementations — `webCryptoEd25519Engine` (Ed25519) and `webCryptoEcdsaP256Engine` (ECDSA with P-256) — both backed by `crypto.subtle`. The module also exports a convenience alias `webCryptoSignEngine` that resolves to the Ed25519 engine. These engines implement the `SignEngine` interface from `@suaveplan/crypto/engines`, making them drop-in replacements for the default `@noble/curves`-backed engine wherever `crypto.subtle` is available.
|
||||
|
||||
Ed25519 provides faster key generation, smaller signatures (64 bytes), and stronger security properties for most use-cases. ECDSA-P256 is provided for interoperability with systems that require NIST curves.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeypair()` — generates an ephemeral Ed25519 or ECDSA-P256 keypair; returns raw public key bytes and raw/PKCS8 private key bytes
|
||||
- `sign(secretKey, message)` — signs an arbitrary byte array using `crypto.subtle.sign`
|
||||
- `verify(publicKey, signature, message)` — verifies a signature using `crypto.subtle.verify`
|
||||
- All methods are async at runtime despite the `SignEngine` interface's sync declarations
|
||||
- All methods guard with `assertWebCryptoAvailable()` before any `crypto.subtle` calls
|
||||
- Typed `BrowserCryptoError` thrown on generation failures and operation failures
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { webCryptoEd25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-sign-engine";
|
||||
|
||||
const kp = await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
const message = new TextEncoder().encode("hello");
|
||||
const signature = await (webCryptoEd25519Engine.sign(kp.secretKey, message) as unknown as Promise<Uint8Array>);
|
||||
const valid = await (webCryptoEd25519Engine.verify(kp.publicKey, signature, message) as unknown as Promise<boolean>);
|
||||
console.log(valid); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
webCryptoEcdsaP256Engine,
|
||||
webCryptoEd25519Engine,
|
||||
} from "@suaveplan/crypto-browser/engines/webcrypto-sign-engine";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
// Use P-256 for JWS / JOSE interoperability
|
||||
async function signJwsPayload(payload: Uint8Array): Promise<{ publicKey: Uint8Array; signature: Uint8Array }> {
|
||||
try {
|
||||
const kp = await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const signature = await (webCryptoEcdsaP256Engine.sign(kp.secretKey, payload) as unknown as Promise<Uint8Array>);
|
||||
return { publicKey: kp.publicKey, signature };
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) {
|
||||
// Structured error with err.code = "BROWSER_CRYPTO.KEY.GENERATION_FAILED"
|
||||
// or "BROWSER_CRYPTO.OPERATION.FAILED"
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `webCryptoEd25519Engine: SignEngine`
|
||||
|
||||
Ed25519 engine via `crypto.subtle` with `{ name: "Ed25519" }` algorithm.
|
||||
|
||||
#### `.generateKeypair(): SignKeypair`
|
||||
|
||||
Returns `Promise<SignKeypair>` at runtime. Public key is 32 raw bytes. Secret key is the 32-byte Ed25519 seed.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
|
||||
|
||||
#### `.sign(secretKey, message): Uint8Array`
|
||||
|
||||
Returns `Promise<Uint8Array>` at runtime. Signature is 64 bytes.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `secretKey` | `Uint8Array` | 32-byte Ed25519 seed |
|
||||
| `message` | `Uint8Array` | Arbitrary bytes to sign |
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
#### `.verify(publicKey, signature, message): boolean`
|
||||
|
||||
Returns `Promise<boolean>` at runtime.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `publicKey` | `Uint8Array` | 32-byte Ed25519 public key |
|
||||
| `signature` | `Uint8Array` | 64-byte signature |
|
||||
| `message` | `Uint8Array` | Original message bytes |
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
---
|
||||
|
||||
### `webCryptoEcdsaP256Engine: SignEngine`
|
||||
|
||||
ECDSA-P256 engine via `crypto.subtle` with `{ name: "ECDSA", namedCurve: "P-256" }`. Same method signatures as above. Public key is 65-byte uncompressed (0x04 prefix). Secret key is PKCS8 DER.
|
||||
|
||||
---
|
||||
|
||||
### `webCryptoSignEngine: SignEngine`
|
||||
|
||||
Alias for `webCryptoEd25519Engine`. Use this when you want the recommended default.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Key export: `crypto.subtle.exportKey("pkcs8", privateKey)` returns the PKCS8 DER-encoded private key. For Ed25519, the 32-byte seed is located at byte offset 16 within the DER envelope; this is extracted to produce the compact secret key stored and transported by callers. On import, the seed is re-wrapped into the minimal 48-byte PKCS8 ASN.1 structure before calling `crypto.subtle.importKey`.
|
||||
|
||||
For ECDSA-P256, public keys are exported via `"raw"` format (65-byte uncompressed point), which is directly importable via `"raw"` as well. This avoids SPKI round-trip complexity.
|
||||
|
||||
Ed25519 support requires Node 20+, Bun 1.0+, or a modern browser (Chrome 113+, Firefox 129+, Safari 17+). Calling these engines in older environments will produce a `BrowserCryptoError.KEY.GENERATION_FAILED` from the `crypto.subtle.generateKey` rejection.
|
||||
@@ -0,0 +1,91 @@
|
||||
# browser-crypto-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `BrowserCryptoError`, the structured error class for all `@suaveplan/crypto-browser` failure paths. It extends `SuaveplanError<BrowserCryptoCode>` from `@suaveplan/error`, inheriting cause-chain traversal, OTel span-event adapter support, and wire-format serialization. Every thrown error in this package is an instance of `BrowserCryptoError` with a stable `BrowserCryptoCode` string code, enabling callers to discriminate failure modes with type-safe `instanceof` + `err.code` checks.
|
||||
|
||||
The error class follows the project convention: a package-scoped error with a typed code union, `cause`, `context`, and `timestamp` fields. This makes crypto failures observable, loggable, and serializable without depending on runtime-specific error formats.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<BrowserCryptoCode>` — participates in all project error infrastructure
|
||||
- `code` is one of the stable `BROWSER_CRYPTO.*` codes from `codes.ts`
|
||||
- `cause` captures the underlying `DOMException` or `Error` from `crypto.subtle`
|
||||
- `context` carries optional structured diagnostic data
|
||||
- `timestamp` records when the error was thrown
|
||||
- Stacktrace captured via `Error.captureStackTrace` where available (V8/Node)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
try {
|
||||
await webCryptoAeadEngine.decrypt(wrongKey, ciphertext, nonce);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) {
|
||||
console.error(err.code); // "BROWSER_CRYPTO.OPERATION.FAILED"
|
||||
console.error(err.cause); // DOMException: The operation failed ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BrowserCryptoError, BROWSER_CRYPTO_CODES } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
function isNotAvailable(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof BrowserCryptoError &&
|
||||
err.code === BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptOrFallback(
|
||||
key: Uint8Array,
|
||||
data: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return await (webCryptoAeadEngine.encrypt(key, data, nonce) as unknown as Promise<Uint8Array>);
|
||||
} catch (err) {
|
||||
if (isNotAvailable(err)) {
|
||||
// Fall back to @noble/ciphers-backed implementation
|
||||
return nobleAeadFallback(key, data);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class BrowserCryptoError extends SuaveplanError<BrowserCryptoCode>`
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```ts
|
||||
new BrowserCryptoError(code: BrowserCryptoCode, messageKey: string, options?: SuaveplanErrorOptions)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `code` | `BrowserCryptoCode` | Stable error code, e.g. `"BROWSER_CRYPTO.NOT_AVAILABLE"` |
|
||||
| `messageKey` | `string` | i18n key for the human-readable message |
|
||||
| `options` | `SuaveplanErrorOptions` | Optional `{ cause, context, traceId, clock }` |
|
||||
|
||||
**Inherited properties:**
|
||||
|
||||
- `code: BrowserCryptoCode` — stable error code
|
||||
- `cause?: Error` — underlying cause
|
||||
- `context?: ErrorContext` — structured diagnostic data
|
||||
- `timestamp: number` — `Date.now()` at throw time
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is declared `public` despite mirroring the protected base — Biome lint would flag a "useless constructor" but the intent is to widen visibility. The `biome-ignore` comment is present to document this intent.
|
||||
|
||||
All `crypto.subtle` rejections produce `DOMException` instances. These are captured as the `cause` field, making the original platform error available for debugging while presenting a structured `BrowserCryptoError` to callers.
|
||||
@@ -0,0 +1,81 @@
|
||||
# BROWSER_CRYPTO_CODES
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`codes.ts` defines the complete set of stable error codes used throughout `@suaveplan/crypto-browser`. Each code is a namespaced string constant produced by `defineCodes` from `@suaveplan/error`, ensuring codes are unique within the `"BROWSER_CRYPTO"` namespace and that the `BrowserCryptoCode` union type is always in sync with the registered values at compile time. All WebCrypto-related failures in this package surface under one of these codes, allowing consumers to branch deterministically without parsing message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Three codes covering the main WebCrypto failure domains: API unavailability, key generation, and general operation failures
|
||||
- Generated via `defineCodes("BROWSER_CRYPTO", [...])` which prefixes each entry and freezes the resulting object
|
||||
- `BrowserCryptoCode` is derived as a `typeof` union for compile-time exhaustive narrowing
|
||||
- No runtime dependencies beyond `@suaveplan/error`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { BROWSER_CRYPTO_CODES } from "@suaveplan/crypto-browser/errors/codes";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors/browser-crypto-error";
|
||||
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["NOT_AVAILABLE"],
|
||||
"error.crypto-browser.not-available"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
BROWSER_CRYPTO_CODES,
|
||||
type BrowserCryptoCode,
|
||||
} from "@suaveplan/crypto-browser/errors/codes";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors/browser-crypto-error";
|
||||
|
||||
function handleBrowserCryptoError(err: BrowserCryptoError): string {
|
||||
switch (err.code as BrowserCryptoCode) {
|
||||
case BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]:
|
||||
return "WebCrypto API is not available in this environment (requires HTTPS or localhost).";
|
||||
case BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"]:
|
||||
return "Key generation failed — the requested algorithm may not be supported by this browser.";
|
||||
case BROWSER_CRYPTO_CODES["OPERATION.FAILED"]:
|
||||
return "Cryptographic operation failed — check key type, algorithm, and input size.";
|
||||
default:
|
||||
return "Unknown browser crypto error.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BROWSER_CRYPTO_CODES`
|
||||
|
||||
```typescript
|
||||
const BROWSER_CRYPTO_CODES: Readonly<Record<string, string>>
|
||||
```
|
||||
|
||||
A frozen map from short code keys to their namespaced string values. All values are prefixed with `"BROWSER_CRYPTO."` by `defineCodes`.
|
||||
|
||||
| Key | Namespaced value | When thrown |
|
||||
|------------------------|----------------------------------------------|-------------------------------------------------------------------|
|
||||
| `NOT_AVAILABLE` | `"BROWSER_CRYPTO.NOT_AVAILABLE"` | `globalThis.crypto.subtle` is `undefined` or `null` |
|
||||
| `KEY.GENERATION_FAILED`| `"BROWSER_CRYPTO.KEY.GENERATION_FAILED"` | `SubtleCrypto.generateKey` or `importKey` rejects |
|
||||
| `OPERATION.FAILED` | `"BROWSER_CRYPTO.OPERATION.FAILED"` | Any `SubtleCrypto` operation (`encrypt`, `decrypt`, `sign`, `verify`, `deriveBits`, `wrapKey`, `unwrapKey`) rejects |
|
||||
|
||||
### `BrowserCryptoCode`
|
||||
|
||||
```typescript
|
||||
type BrowserCryptoCode =
|
||||
(typeof BROWSER_CRYPTO_CODES)[keyof typeof BROWSER_CRYPTO_CODES];
|
||||
```
|
||||
|
||||
Union of all namespaced code strings. Use this type to annotate `err.code` when narrowing `BrowserCryptoError` in typed catch blocks.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a plain frozen object and the `BrowserCryptoCode` type collapses to a string literal union. Adding a new entry to the array automatically widens the union without requiring any manual type maintenance.
|
||||
|
||||
`OPERATION.FAILED` intentionally covers a broad set of `SubtleCrypto` rejections rather than splitting each operation into its own code. The distinction between, say, a failed `encrypt` and a failed `sign` is captured in the structured `context` field on the thrown `BrowserCryptoError` rather than in the code itself, which keeps the code registry small and stable across algorithm additions.
|
||||
@@ -0,0 +1,97 @@
|
||||
# non-extractable-key
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `nonExtractableKey()`, a helper that generates a `CryptoKey` with `extractable: false`. Key material generated this way never leaves the cryptographic engine — it cannot be read by JavaScript code via `crypto.subtle.exportKey`. The key handle can be used for cryptographic operations but its raw bytes are permanently inaccessible, providing a hardware-security-module-like boundary in software.
|
||||
|
||||
This is the correct approach whenever keys must remain hardware-bound or compliance requirements prohibit key export. Examples include session-scoped encryption keys, FIDO2-style signing keys, and keys derived from hardware-backed key material in PWA contexts. The non-extractability also means that if an adversary gains script execution access, they cannot exfiltrate the key bytes — they can only use the key handle for operations it was created for.
|
||||
|
||||
## Features
|
||||
|
||||
- Generates a `CryptoKey` with `extractable: false` for any `crypto.subtle`-supported algorithm
|
||||
- Supports symmetric algorithms: AES-GCM, AES-CBC, AES-KW, HMAC
|
||||
- Supports asymmetric algorithms: ECDSA (P-256, P-384), ECDH (P-256, P-384), Ed25519, X25519
|
||||
- For asymmetric algorithms, returns only the private key (the non-extractable half)
|
||||
- `assertWebCryptoAvailable()` guard before any `crypto.subtle` call
|
||||
- Typed `BrowserCryptoError` on generation failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { nonExtractableKey } from "@suaveplan/crypto-browser/non-extractable-key/non-extractable-key";
|
||||
|
||||
// Generate a non-extractable AES-256-GCM key for session encryption
|
||||
const sessionKey = await nonExtractableKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// sessionKey.extractable === false
|
||||
// crypto.subtle.exportKey("raw", sessionKey) will throw
|
||||
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
sessionKey,
|
||||
new TextEncoder().encode("private data")
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { nonExtractableKey } from "@suaveplan/crypto-browser/non-extractable-key/non-extractable-key";
|
||||
|
||||
// Hardware-bound ECDSA signing key — private key material never leaves the engine
|
||||
async function createBoundSigningKey(): Promise<CryptoKey> {
|
||||
return nonExtractableKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
["sign"]
|
||||
);
|
||||
}
|
||||
|
||||
// Use the key for signing without any risk of key extraction
|
||||
async function signChallenge(
|
||||
key: CryptoKey,
|
||||
challenge: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const sig = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
key,
|
||||
challenge
|
||||
);
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
// Store the key in IndexedDB for persistence (CryptoKey is structured-cloneable)
|
||||
// async function storeKey(db: IDBDatabase, key: CryptoKey): Promise<void> { ... }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `nonExtractableKey(algorithm, usages): Promise<CryptoKey>`
|
||||
|
||||
Generates a non-extractable `CryptoKey`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `algorithm` | `AlgorithmIdentifier \| RsaHashedKeyGenParams \| EcKeyGenParams \| HmacKeyGenParams \| AesKeyGenParams` | Key generation algorithm descriptor |
|
||||
| `usages` | `KeyUsage[]` | Intended key operations, e.g. `["encrypt", "decrypt"]` |
|
||||
|
||||
**Returns:** `Promise<CryptoKey>` with `extractable === false`.
|
||||
|
||||
- For symmetric algorithms: returns the `CryptoKey` directly.
|
||||
- For asymmetric algorithms: returns the `privateKey` from the generated `CryptoKeyPair`.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation calls `crypto.subtle.generateKey` with `extractable: false`. The Web Crypto API guarantees that once a key is created non-extractable, no subsequent API call can extract the raw bytes — `exportKey` will throw `InvalidAccessError` unconditionally.
|
||||
|
||||
For asymmetric algorithms, `generateKey` returns a `CryptoKeyPair`. The function detects this via `"privateKey" in result` and returns only the private key. The public key is discarded; if callers need the public key they should use the specific engine modules (`ecdsaP384.generateKeypair()`, etc.) which export both.
|
||||
|
||||
Non-extractable keys are structured-cloneable and can be persisted to `IndexedDB`, shared with `BroadcastChannel`, or transferred to `Worker` contexts via `postMessage` — without ever exposing the raw key bytes. This makes them suitable for long-lived application keys that survive page reloads.
|
||||
138
docs/security-stack/crypto/crypto-browser/src/rsa/rsa.md
Normal file
138
docs/security-stack/crypto/crypto-browser/src/rsa/rsa.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# rsa
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a high-level `rsa` namespace object wrapping RSA cryptographic operations from `crypto.subtle`. Supports three algorithm variants — `RSA-PSS`, `RSA-OAEP`, and `RSASSA-PKCS1-v1_5` — for signing/verification and asymmetric encryption/decryption. Keys are represented as standard DER byte arrays (SPKI for public keys, PKCS8 for private keys), which are universally portable across platforms and easy to store and transmit.
|
||||
|
||||
RSA is provided alongside the elliptic-curve engines for interoperability with legacy systems, PKI infrastructure, and protocols that require RSA (e.g., certain TLS cipher suites, JWT RSA algorithms, legacy JOSE). For new designs, Ed25519 and ECDH-X25519 are preferred.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeypair(algorithm, modulusLength?)` — generates an RSA keypair; default 2048-bit modulus, configurable up to 4096 bits
|
||||
- `sign(algorithm, privateKey, message)` — RSA-PSS or RSASSA-PKCS1-v1_5 signature generation
|
||||
- `verify(algorithm, publicKey, signature, message)` — RSA signature verification
|
||||
- `encrypt(publicKey, plaintext)` — RSA-OAEP encryption (public-key encryption)
|
||||
- `decrypt(privateKey, ciphertext)` — RSA-OAEP decryption
|
||||
- SHA-256 hash used for all operations
|
||||
- RSA-PSS uses 32-byte salt length
|
||||
- All methods guard with `assertWebCryptoAvailable()`
|
||||
- Typed `BrowserCryptoError` for all failure paths
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rsa } from "@suaveplan/crypto-browser/rsa/rsa";
|
||||
|
||||
// Generate and use RSA-PSS for signing
|
||||
const { publicKey, privateKey } = await rsa.generateKeypair("RSA-PSS");
|
||||
const message = new TextEncoder().encode("document to sign");
|
||||
|
||||
const signature = await rsa.sign("RSA-PSS", privateKey, message);
|
||||
const valid = await rsa.verify("RSA-PSS", publicKey, signature, message);
|
||||
console.log(valid); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rsa, type RsaAlgorithm } from "@suaveplan/crypto-browser/rsa/rsa";
|
||||
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
|
||||
|
||||
// RSA-OAEP for encrypting a small payload (e.g., a symmetric key)
|
||||
async function wrapAesKey(
|
||||
recipientPublicKey: Uint8Array,
|
||||
aesKey: Uint8Array // must be <= ~190 bytes for RSA-2048
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return await rsa.encrypt(recipientPublicKey, aesKey);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) {
|
||||
console.error(`Crypto failure: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function unwrapAesKey(
|
||||
recipientPrivateKey: Uint8Array,
|
||||
wrappedKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
return await rsa.decrypt(recipientPrivateKey, wrappedKey);
|
||||
}
|
||||
|
||||
// 4096-bit keypair for higher-security contexts
|
||||
const { publicKey, privateKey } = await rsa.generateKeypair("RSA-OAEP", 4096);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rsa.generateKeypair(algorithm, modulusLength?): Promise<{ publicKey, privateKey }>`
|
||||
|
||||
Generates an RSA keypair.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `algorithm` | `RsaAlgorithm` | — | `"RSA-PSS"` \| `"RSA-OAEP"` \| `"RSASSA-PKCS1-v1_5"` |
|
||||
| `modulusLength` | `number` | `2048` | Key bit length (2048 or 4096 recommended) |
|
||||
|
||||
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — SPKI/PKCS8 DER bytes.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
|
||||
|
||||
---
|
||||
|
||||
### `rsa.sign(algorithm, privateKey, message): Promise<Uint8Array>`
|
||||
|
||||
Signs a message. Use with `"RSA-PSS"` or `"RSASSA-PKCS1-v1_5"`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `algorithm` | `RsaAlgorithm` | Must match keypair generation algorithm |
|
||||
| `privateKey` | `Uint8Array` | PKCS8 DER private key |
|
||||
| `message` | `Uint8Array` | Message to sign |
|
||||
|
||||
**Returns:** `Promise<Uint8Array>` — signature bytes.
|
||||
|
||||
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED`.
|
||||
|
||||
---
|
||||
|
||||
### `rsa.verify(algorithm, publicKey, signature, message): Promise<boolean>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `algorithm` | `RsaAlgorithm` | Must match signing algorithm |
|
||||
| `publicKey` | `Uint8Array` | SPKI DER public key |
|
||||
| `signature` | `Uint8Array` | Signature bytes |
|
||||
| `message` | `Uint8Array` | Original message |
|
||||
|
||||
**Returns:** `Promise<boolean>`.
|
||||
|
||||
---
|
||||
|
||||
### `rsa.encrypt(publicKey, plaintext): Promise<Uint8Array>`
|
||||
|
||||
RSA-OAEP encryption. For RSA-2048, plaintext must be ≤ 190 bytes (2048-bit modulus minus OAEP overhead).
|
||||
|
||||
---
|
||||
|
||||
### `rsa.decrypt(privateKey, ciphertext): Promise<Uint8Array>`
|
||||
|
||||
RSA-OAEP decryption.
|
||||
|
||||
---
|
||||
|
||||
### `type RsaAlgorithm`
|
||||
|
||||
`"RSA-PSS" | "RSA-OAEP" | "RSASSA-PKCS1-v1_5"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All RSA operations use SHA-256 as the hash algorithm, which is the most widely supported and appropriate choice for 2048- and 4096-bit RSA. RSA-PSS uses a 32-byte (256-bit) salt, matching the hash output length, which is the PKCS#1v2.2 recommendation.
|
||||
|
||||
The `encrypt()` and `decrypt()` methods are hardcoded to RSA-OAEP — only RSA-OAEP provides semantic security for asymmetric encryption. `RSA-PSS` and `RSASSA-PKCS1-v1_5` are signing-only algorithms and cannot be used with `encrypt`/`decrypt`.
|
||||
|
||||
Key generation for RSA-2048 typically takes 50–200 ms in `crypto.subtle`; RSA-4096 can take 500–2000 ms. This is significantly slower than elliptic-curve key generation (< 5 ms). All operations use `await` and do not block the main thread.
|
||||
Reference in New Issue
Block a user