Initial Commit
This commit is contained in:
16
packages/security-stack/crypto/crypto-browser/CHANGELOG.md
Normal file
16
packages/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
packages/security-stack/crypto/crypto-browser/README.md
Normal file
89
packages/security-stack/crypto/crypto-browser/README.md
Normal file
File diff suppressed because one or more lines are too long
182
packages/security-stack/crypto/crypto-browser/package.json
Normal file
182
packages/security-stack/crypto/crypto-browser/package.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"name": "@suaveplan/crypto-browser",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "WebCrypto-backed engine implementations for @suaveplan/crypto \u2014 AES-256-GCM, Ed25519, ECDSA-P256/P384, X25519, ECDH, RSA, AES-KW, and non-extractable key support via crypto.subtle.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./availability/availability": {
|
||||
"import": {
|
||||
"types": "./dist/availability/availability.d.ts",
|
||||
"default": "./dist/availability/availability.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/availability/availability.d.ts",
|
||||
"default": "./dist/availability/availability.js"
|
||||
}
|
||||
},
|
||||
"./engines/webcrypto-aead-engine/webcrypto-aead-engine": {
|
||||
"import": {
|
||||
"types": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
|
||||
}
|
||||
},
|
||||
"./engines/webcrypto-sign-engine/webcrypto-sign-engine": {
|
||||
"import": {
|
||||
"types": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.js"
|
||||
}
|
||||
},
|
||||
"./engines/webcrypto-agree-engine/webcrypto-agree-engine": {
|
||||
"import": {
|
||||
"types": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.d.ts",
|
||||
"default": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.js"
|
||||
}
|
||||
},
|
||||
"./errors/browser-crypto-error/browser-crypto-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/browser-crypto-error/browser-crypto-error.d.ts",
|
||||
"default": "./dist/errors/browser-crypto-error/browser-crypto-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/browser-crypto-error/browser-crypto-error.d.ts",
|
||||
"default": "./dist/errors/browser-crypto-error/browser-crypto-error.js"
|
||||
}
|
||||
},
|
||||
"./errors/codes/codes": {
|
||||
"import": {
|
||||
"types": "./dist/errors/codes/codes.d.ts",
|
||||
"default": "./dist/errors/codes/codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/codes/codes.d.ts",
|
||||
"default": "./dist/errors/codes/codes.js"
|
||||
}
|
||||
},
|
||||
"./rsa/rsa": {
|
||||
"import": {
|
||||
"types": "./dist/rsa/rsa.d.ts",
|
||||
"default": "./dist/rsa/rsa.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/rsa/rsa.d.ts",
|
||||
"default": "./dist/rsa/rsa.js"
|
||||
}
|
||||
},
|
||||
"./aes-kw/aes-kw": {
|
||||
"import": {
|
||||
"types": "./dist/aes-kw/aes-kw.d.ts",
|
||||
"default": "./dist/aes-kw/aes-kw.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/aes-kw/aes-kw.d.ts",
|
||||
"default": "./dist/aes-kw/aes-kw.js"
|
||||
}
|
||||
},
|
||||
"./ecdsa-p384/ecdsa-p384": {
|
||||
"import": {
|
||||
"types": "./dist/ecdsa-p384/ecdsa-p384.d.ts",
|
||||
"default": "./dist/ecdsa-p384/ecdsa-p384.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/ecdsa-p384/ecdsa-p384.d.ts",
|
||||
"default": "./dist/ecdsa-p384/ecdsa-p384.js"
|
||||
}
|
||||
},
|
||||
"./ecdh-p384/ecdh-p384": {
|
||||
"import": {
|
||||
"types": "./dist/ecdh-p384/ecdh-p384.d.ts",
|
||||
"default": "./dist/ecdh-p384/ecdh-p384.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/ecdh-p384/ecdh-p384.d.ts",
|
||||
"default": "./dist/ecdh-p384/ecdh-p384.js"
|
||||
}
|
||||
},
|
||||
"./non-extractable-key/non-extractable-key": {
|
||||
"import": {
|
||||
"types": "./dist/non-extractable-key/non-extractable-key.d.ts",
|
||||
"default": "./dist/non-extractable-key/non-extractable-key.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/non-extractable-key/non-extractable-key.d.ts",
|
||||
"default": "./dist/non-extractable-key/non-extractable-key.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:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@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,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,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
import { aesKw } from "./aes-kw.js";
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("aesKw", () => {
|
||||
it("wraps and unwraps a 16-byte key", { timeout: 30000 }, async () => {
|
||||
const wrappingKey = randomBytes(32);
|
||||
const keyToWrap = randomBytes(16);
|
||||
|
||||
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
|
||||
expect(wrapped).toBeInstanceOf(Uint8Array);
|
||||
// AES-KW adds 8 bytes
|
||||
expect(wrapped.length).toBe(keyToWrap.length + 8);
|
||||
|
||||
const unwrapped = await aesKw.unwrapKey(wrappingKey, wrapped);
|
||||
expect(unwrapped).toEqual(keyToWrap);
|
||||
});
|
||||
|
||||
it("wraps and unwraps a 32-byte key", { timeout: 30000 }, async () => {
|
||||
const wrappingKey = randomBytes(32);
|
||||
const keyToWrap = randomBytes(32);
|
||||
|
||||
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
|
||||
const unwrapped = await aesKw.unwrapKey(wrappingKey, wrapped);
|
||||
expect(unwrapped).toEqual(keyToWrap);
|
||||
});
|
||||
|
||||
it("unwrap fails with wrong wrapping key", { timeout: 30000 }, async () => {
|
||||
const wk1 = randomBytes(32);
|
||||
const wk2 = randomBytes(32);
|
||||
const keyToWrap = randomBytes(16);
|
||||
|
||||
const wrapped = await aesKw.wrapKey(wk1, keyToWrap);
|
||||
|
||||
await expect(aesKw.unwrapKey(wk2, wrapped)).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
});
|
||||
|
||||
it("wrapKey throws BrowserCryptoError for invalid wrapping key size", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// A 5-byte key is not a valid AES key size — importKey will reject
|
||||
const badWrappingKey = new Uint8Array(5);
|
||||
const keyToWrap = randomBytes(16);
|
||||
|
||||
await expect(
|
||||
aesKw.wrapKey(badWrappingKey, keyToWrap)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("wrapKey re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
aesKw.wrapKey(new Uint8Array(32), new Uint8Array(16))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("unwrapKey re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
aesKw.unwrapKey(new Uint8Array(32), new Uint8Array(24))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("wrapKey wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
aesKw.wrapKey(new Uint8Array(32), new Uint8Array(16))
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("unwrapKey wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
aesKw.unwrapKey(new Uint8Array(32), new Uint8Array(24))
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trip property: wrap then unwrap recovers original", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// AES-KW wraps keys that must be importable as AES keys (16, 24, or 32 bytes)
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.constantFrom(16, 24, 32), async (keyLen) => {
|
||||
const wrappingKey = randomBytes(32);
|
||||
const keyToWrap = randomBytes(keyLen);
|
||||
|
||||
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
|
||||
const recovered = await aesKw.unwrapKey(wrappingKey, wrapped);
|
||||
expect(recovered).toEqual(keyToWrap);
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { assertWebCryptoAvailable } from "../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a wrapping key for use with AES-KW.
|
||||
* Key must be 16, 24, or 32 bytes (128, 192, or 256 bits).
|
||||
*/
|
||||
async function importWrappingKey(
|
||||
rawKey: Uint8Array,
|
||||
usages: KeyUsage[]
|
||||
): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(rawKey),
|
||||
{ name: "AES-KW" },
|
||||
false,
|
||||
usages
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES Key Wrap (RFC 3394) operations via `crypto.subtle`.
|
||||
*
|
||||
* AES-KW provides authenticated wrapping of key material. The wrapped output
|
||||
* is 8 bytes longer than the input (a 64-bit integrity check value is
|
||||
* prepended). The input key must be a multiple of 8 bytes.
|
||||
*/
|
||||
export const aesKw = {
|
||||
/**
|
||||
* Wraps `keyToWrap` using `wrappingKey` with AES-KW.
|
||||
*
|
||||
* @param wrappingKey - 16, 24, or 32-byte AES key
|
||||
* @param keyToWrap - key bytes to wrap (must be multiple of 8 bytes)
|
||||
* @returns Promise resolving to wrapped key bytes (input length + 8)
|
||||
* @throws BrowserCryptoError NOT_AVAILABLE | OPERATION.FAILED
|
||||
*/
|
||||
async wrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
keyToWrap: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const wk = await importWrappingKey(wrappingKey, ["wrapKey"]);
|
||||
// Import keyToWrap as raw AES key to satisfy crypto.subtle.wrapKey
|
||||
const keyToWrapCk = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(keyToWrap),
|
||||
{ name: "AES-GCM" },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const wrapped = await crypto.subtle.wrapKey(
|
||||
"raw",
|
||||
keyToWrapCk,
|
||||
wk,
|
||||
{ name: "AES-KW" }
|
||||
);
|
||||
return new Uint8Array(wrapped);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{
|
||||
cause: err instanceof Error ? err : new Error(String(err)),
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unwraps a wrapped key using `wrappingKey` with AES-KW.
|
||||
*
|
||||
* @param wrappingKey - 16, 24, or 32-byte AES wrapping key
|
||||
* @param wrappedKey - wrapped key bytes (output of `wrapKey`)
|
||||
* @returns Promise resolving to original raw key bytes
|
||||
* @throws BrowserCryptoError NOT_AVAILABLE | OPERATION.FAILED
|
||||
*/
|
||||
async unwrapKey(
|
||||
wrappingKey: Uint8Array,
|
||||
wrappedKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const wk = await importWrappingKey(wrappingKey, ["unwrapKey"]);
|
||||
const unwrapped = await crypto.subtle.unwrapKey(
|
||||
"raw",
|
||||
toBuffer(wrappedKey),
|
||||
wk,
|
||||
{ name: "AES-KW" },
|
||||
{ name: "AES-GCM" },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const exported = await crypto.subtle.exportKey("raw", unwrapped);
|
||||
return new Uint8Array(exported);
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{
|
||||
cause: err instanceof Error ? err : new Error(String(err)),
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { aesKw } from "./aes-kw";
|
||||
@@ -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,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import {
|
||||
assertWebCryptoAvailable,
|
||||
isWebCryptoAvailable,
|
||||
} from "./availability.js";
|
||||
|
||||
describe("isWebCryptoAvailable", () => {
|
||||
it("returns true when crypto.subtle is present", { timeout: 5000 }, () => {
|
||||
// Node 20+ / Bun expose globalThis.crypto
|
||||
expect(isWebCryptoAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when crypto.subtle is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeCrypto = {} as Crypto;
|
||||
expect(isWebCryptoAvailable(fakeCrypto)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an explicitly passed undefined ref", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// When the caller passes a ref that lacks subtle, the guard
|
||||
// reports unavailable regardless of the global environment.
|
||||
const missingSubtle = {
|
||||
getRandomValues: () => {},
|
||||
} as unknown as Crypto;
|
||||
expect(isWebCryptoAvailable(missingSubtle)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWebCryptoAvailable", () => {
|
||||
it("does not throw when available", { timeout: 5000 }, () => {
|
||||
expect(() => assertWebCryptoAvailable()).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws BrowserCryptoError NOT_AVAILABLE when unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeCrypto = {} as Crypto;
|
||||
expect(() => assertWebCryptoAvailable(fakeCrypto)).toThrow(
|
||||
BrowserCryptoError
|
||||
);
|
||||
try {
|
||||
assertWebCryptoAvailable(fakeCrypto);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BrowserCryptoError);
|
||||
expect((err as BrowserCryptoError).code).toBe(
|
||||
"BROWSER_CRYPTO.NOT_AVAILABLE"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
/**
|
||||
* Returns `true` when `crypto.subtle` is available in the current runtime.
|
||||
*
|
||||
* Checks both that the global `crypto` object is defined and that the
|
||||
* `crypto.subtle` sub-API is present. Modern browsers (Chromium 37+, Firefox
|
||||
* 34+, Safari 10.1+), Node 20+, Bun, and Deno all pass this check. Older
|
||||
* browsers, pure Worker contexts without `crypto`, or environments where the
|
||||
* Web Crypto API has been stripped will return `false`.
|
||||
*
|
||||
* @param cryptoRef - Optional `Crypto` instance to inspect. Defaults to the
|
||||
* global `crypto` object when omitted.
|
||||
*/
|
||||
export function isWebCryptoAvailable(cryptoRef?: Crypto): boolean {
|
||||
const c =
|
||||
cryptoRef ??
|
||||
/* istanbul ignore next */ (typeof crypto !== "undefined"
|
||||
? crypto
|
||||
: undefined);
|
||||
return c !== undefined && typeof c.subtle !== "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws `BrowserCryptoError("BROWSER_CRYPTO.NOT_AVAILABLE")` if
|
||||
* `crypto.subtle` is not present in the current runtime.
|
||||
*
|
||||
* All engine methods and primitives call this guard before attempting any
|
||||
* `crypto.subtle` operation, so callers receive a structured error instead of
|
||||
* a raw `TypeError: Cannot read properties of undefined`.
|
||||
*
|
||||
* @throws {BrowserCryptoError} code `BROWSER_CRYPTO.NOT_AVAILABLE`
|
||||
* @param cryptoRef - Optional `Crypto` instance to inspect. Defaults to the
|
||||
* global `crypto` object when omitted.
|
||||
*/
|
||||
export function assertWebCryptoAvailable(cryptoRef?: Crypto): void {
|
||||
if (!isWebCryptoAvailable(cryptoRef)) {
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["NOT_AVAILABLE"],
|
||||
"browser-crypto.not-available"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { assertWebCryptoAvailable, isWebCryptoAvailable } from "./availability";
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Recommended AES-GCM nonce length in bytes (96-bit IV). */
|
||||
export const AES_GCM_NONCE_LENGTH = 12 as const;
|
||||
|
||||
/** AES-256 key length in bytes. */
|
||||
export const AES_256_KEY_LENGTH = 32 as const;
|
||||
|
||||
/** AES-128 key length in bytes. */
|
||||
export const AES_128_KEY_LENGTH = 16 as const;
|
||||
|
||||
/** Ed25519 public key length in bytes. */
|
||||
export const ED25519_PUBLIC_KEY_LENGTH = 32 as const;
|
||||
|
||||
/** Ed25519 signature length in bytes. */
|
||||
export const ED25519_SIGNATURE_LENGTH = 64 as const;
|
||||
|
||||
/** X25519 public key length in bytes. */
|
||||
export const X25519_PUBLIC_KEY_LENGTH = 32 as const;
|
||||
|
||||
/** X25519 shared secret length in bytes. */
|
||||
export const X25519_SHARED_SECRET_LENGTH = 32 as const;
|
||||
|
||||
/** P-384 uncompressed public key length in bytes. */
|
||||
export const P384_PUBLIC_KEY_LENGTH = 97 as const;
|
||||
|
||||
/** P-384 shared secret length in bytes (derived bits). */
|
||||
export const P384_SHARED_SECRET_LENGTH = 48 as const;
|
||||
|
||||
/** P-256 uncompressed public key length in bytes. */
|
||||
export const P256_PUBLIC_KEY_LENGTH = 65 as const;
|
||||
@@ -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,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
import { ecdhP384 } from "./ecdh-p384.js";
|
||||
|
||||
describe("ecdhP384", () => {
|
||||
it("generates a valid keypair", { timeout: 30000 }, async () => {
|
||||
const kp = await ecdhP384.generateKeypair();
|
||||
// P-384 uncompressed public key = 97 bytes
|
||||
expect(kp.publicKey.length).toBe(97);
|
||||
expect(kp.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.privateKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("two parties produce the same shared secret", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const alice = await ecdhP384.generateKeypair();
|
||||
const bob = await ecdhP384.generateKeypair();
|
||||
|
||||
const aliceShared = await ecdhP384.agree(
|
||||
alice.privateKey,
|
||||
bob.publicKey
|
||||
);
|
||||
const bobShared = await ecdhP384.agree(bob.privateKey, alice.publicKey);
|
||||
|
||||
expect(aliceShared).toEqual(bobShared);
|
||||
// P-384 deriveBits(384) = 48 bytes
|
||||
expect(aliceShared.length).toBe(48);
|
||||
});
|
||||
|
||||
it("different keypairs produce different shared secrets", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const alice = await ecdhP384.generateKeypair();
|
||||
const bob = await ecdhP384.generateKeypair();
|
||||
const charlie = await ecdhP384.generateKeypair();
|
||||
|
||||
const aliceBob = await ecdhP384.agree(alice.privateKey, bob.publicKey);
|
||||
const aliceCharlie = await ecdhP384.agree(
|
||||
alice.privateKey,
|
||||
charlie.publicKey
|
||||
);
|
||||
|
||||
expect(aliceBob).not.toEqual(aliceCharlie);
|
||||
});
|
||||
|
||||
it("agree throws BrowserCryptoError for invalid private key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badSecret = new Uint8Array(32); // not valid PKCS8 DER for P-384
|
||||
const validKp = await ecdhP384.generateKeypair();
|
||||
|
||||
await expect(
|
||||
ecdhP384.agree(badSecret, validKp.publicKey)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("agree throws BrowserCryptoError for invalid public key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const validKp = await ecdhP384.generateKeypair();
|
||||
const badPub = new Uint8Array(10); // too short for uncompressed P-384
|
||||
|
||||
await expect(
|
||||
ecdhP384.agree(validKp.privateKey, badPub)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("agree re-throws BrowserCryptoError from wrapErr without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdhP384.agree(new Uint8Array(97), new Uint8Array(97))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(ecdhP384.generateKeypair()).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(ecdhP384.generateKeypair()).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("agree wraps non-Error thrown value via wrapErr in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdhP384.agree(new Uint8Array(97), new Uint8Array(97))
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trip property: DH symmetry holds", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.constant(null), async () => {
|
||||
const alice = await ecdhP384.generateKeypair();
|
||||
const bob = await ecdhP384.generateKeypair();
|
||||
|
||||
const s1 = await ecdhP384.agree(
|
||||
alice.privateKey,
|
||||
bob.publicKey
|
||||
);
|
||||
const s2 = await ecdhP384.agree(
|
||||
bob.privateKey,
|
||||
alice.publicKey
|
||||
);
|
||||
|
||||
expect(s1).toEqual(s2);
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { assertWebCryptoAvailable } from "../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
const ALGO = { name: "ECDH", namedCurve: "P-384" } as const;
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
function wrapErr(err: unknown): never {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ECDH key agreement with P-384 curve via `crypto.subtle`.
|
||||
*
|
||||
* Keys are exported as: public key = 97-byte uncompressed point (raw),
|
||||
* private key = PKCS8 DER bytes.
|
||||
*/
|
||||
export const ecdhP384 = {
|
||||
/**
|
||||
* Generates an ECDH-P384 keypair.
|
||||
*
|
||||
* @returns Promise resolving to `{ publicKey: Uint8Array, privateKey: Uint8Array }`
|
||||
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async generateKeypair(): Promise<{
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const kp = (await crypto.subtle.generateKey(ALGO, true, [
|
||||
"deriveBits",
|
||||
])) as CryptoKeyPair;
|
||||
const [raw, pkcs8] = await Promise.all([
|
||||
crypto.subtle.exportKey("raw", kp.publicKey),
|
||||
crypto.subtle.exportKey("pkcs8", kp.privateKey),
|
||||
]);
|
||||
return {
|
||||
publicKey: new Uint8Array(raw),
|
||||
privateKey: new Uint8Array(pkcs8),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs ECDH key agreement on P-384.
|
||||
*
|
||||
* @param privateKey - PKCS8 DER private key bytes
|
||||
* @param publicKey - 97-byte uncompressed P-384 public key
|
||||
* @returns Promise resolving to 48-byte (384-bit) shared secret
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async agree(
|
||||
privateKey: Uint8Array,
|
||||
publicKey: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const [myKey, theirKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(privateKey),
|
||||
ALGO,
|
||||
false,
|
||||
["deriveBits"]
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(publicKey),
|
||||
ALGO,
|
||||
false,
|
||||
[]
|
||||
),
|
||||
]);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: "ECDH", public: theirKey },
|
||||
myKey,
|
||||
384
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
} catch (err) {
|
||||
wrapErr(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ecdhP384 } from "./ecdh-p384";
|
||||
@@ -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,209 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
import { ecdsaP384 } from "./ecdsa-p384.js";
|
||||
|
||||
describe("ecdsaP384", () => {
|
||||
it("generates a keypair", { timeout: 30000 }, async () => {
|
||||
const kp = await ecdsaP384.generateKeypair();
|
||||
// P-384 uncompressed public key = 97 bytes
|
||||
expect(kp.publicKey.length).toBe(97);
|
||||
expect(kp.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.privateKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("signs and verifies a message", { timeout: 30000 }, async () => {
|
||||
const kp = await ecdsaP384.generateKeypair();
|
||||
const message = new TextEncoder().encode("p384 test");
|
||||
|
||||
const signature = await ecdsaP384.sign(kp.privateKey, message);
|
||||
expect(signature).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const valid = await ecdsaP384.verify(kp.publicKey, signature, message);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("verification fails with different message", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const kp = await ecdsaP384.generateKeypair();
|
||||
const message = new TextEncoder().encode("original");
|
||||
const tampered = new TextEncoder().encode("tampered");
|
||||
|
||||
const signature = await ecdsaP384.sign(kp.privateKey, message);
|
||||
const valid = await ecdsaP384.verify(kp.publicKey, signature, tampered);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("verification fails with wrong public key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const kp1 = await ecdsaP384.generateKeypair();
|
||||
const kp2 = await ecdsaP384.generateKeypair();
|
||||
const message = new TextEncoder().encode("msg");
|
||||
|
||||
const signature = await ecdsaP384.sign(kp1.privateKey, message);
|
||||
const valid = await ecdsaP384.verify(kp2.publicKey, signature, message);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("sign throws BrowserCryptoError for invalid private key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid PKCS8 DER for P-384
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(ecdsaP384.sign(badKey, msg)).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
});
|
||||
|
||||
it("verify throws BrowserCryptoError for invalid public key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badPub = new Uint8Array(10); // too short for P-384 uncompressed
|
||||
const sig = new Uint8Array(96);
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(ecdsaP384.verify(badPub, sig, msg)).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
});
|
||||
|
||||
it("sign re-throws BrowserCryptoError from wrapErr without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdsaP384.sign(new Uint8Array(97), new Uint8Array(8))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("verify re-throws BrowserCryptoError from wrapErr without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdsaP384.verify(
|
||||
new Uint8Array(97),
|
||||
new Uint8Array(96),
|
||||
new Uint8Array(8)
|
||||
)
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(ecdsaP384.generateKeypair()).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(ecdsaP384.generateKeypair()).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("sign wraps non-Error thrown value via wrapErr in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdsaP384.sign(new Uint8Array(97), new Uint8Array(8))
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("verify wraps non-Error thrown value via wrapErr in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
ecdsaP384.verify(
|
||||
new Uint8Array(97),
|
||||
new Uint8Array(96),
|
||||
new Uint8Array(8)
|
||||
)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trip property: sign then verify", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.uint8Array({ minLength: 1, maxLength: 256 }),
|
||||
async (msgArr) => {
|
||||
const kp = await ecdsaP384.generateKeypair();
|
||||
const msg = new Uint8Array(msgArr);
|
||||
const sig = await ecdsaP384.sign(kp.privateKey, msg);
|
||||
const ok = await ecdsaP384.verify(kp.publicKey, sig, msg);
|
||||
expect(ok).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { assertWebCryptoAvailable } from "../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
const ALGO = { name: "ECDSA", namedCurve: "P-384" } as const;
|
||||
const SIGN_ALGO = { name: "ECDSA", hash: "SHA-384" } as const;
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
function wrapErr(err: unknown): never {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ECDSA with P-384 curve and SHA-384 — sign/verify/keypair generation via `crypto.subtle`.
|
||||
*
|
||||
* Keys are exported in raw (public, 97 bytes uncompressed) and PKCS8 DER (private) format.
|
||||
*/
|
||||
export const ecdsaP384 = {
|
||||
/**
|
||||
* Generates an ECDSA-P384 keypair.
|
||||
*
|
||||
* @returns Promise resolving to `{ publicKey: Uint8Array, privateKey: Uint8Array }`
|
||||
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async generateKeypair(): Promise<{
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const kp = (await crypto.subtle.generateKey(ALGO, true, [
|
||||
"sign",
|
||||
"verify",
|
||||
])) as CryptoKeyPair;
|
||||
const [spki, pkcs8] = await Promise.all([
|
||||
crypto.subtle.exportKey("raw", kp.publicKey),
|
||||
crypto.subtle.exportKey("pkcs8", kp.privateKey),
|
||||
]);
|
||||
return {
|
||||
publicKey: new Uint8Array(spki),
|
||||
privateKey: new Uint8Array(pkcs8),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Signs a message with an ECDSA-P384 private key.
|
||||
*
|
||||
* @param privateKey - PKCS8 DER private key bytes
|
||||
* @param message - bytes to sign
|
||||
* @returns Promise resolving to DER signature bytes
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async sign(
|
||||
privateKey: Uint8Array,
|
||||
message: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(privateKey),
|
||||
ALGO,
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
SIGN_ALGO,
|
||||
key,
|
||||
toBuffer(message)
|
||||
);
|
||||
return new Uint8Array(sig);
|
||||
} catch (err) {
|
||||
wrapErr(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies an ECDSA-P384 signature.
|
||||
*
|
||||
* @param publicKey - 97-byte uncompressed P-384 public key
|
||||
* @param signature - signature bytes
|
||||
* @param message - original message bytes
|
||||
* @returns Promise resolving to `true` if signature is valid
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async verify(
|
||||
publicKey: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
message: Uint8Array
|
||||
): Promise<boolean> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(publicKey),
|
||||
ALGO,
|
||||
false,
|
||||
["verify"]
|
||||
);
|
||||
return await crypto.subtle.verify(
|
||||
SIGN_ALGO,
|
||||
key,
|
||||
toBuffer(signature),
|
||||
toBuffer(message)
|
||||
);
|
||||
} catch (err) {
|
||||
wrapErr(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ecdsaP384 } from "./ecdsa-p384";
|
||||
@@ -0,0 +1,11 @@
|
||||
export { webCryptoAeadEngine } from "./webcrypto-aead-engine/webcrypto-aead-engine.js";
|
||||
export {
|
||||
webCryptoAgreeEngine,
|
||||
webCryptoEcdhP256Engine,
|
||||
webCryptoX25519Engine,
|
||||
} from "./webcrypto-agree-engine/webcrypto-agree-engine.js";
|
||||
export {
|
||||
webCryptoEcdsaP256Engine,
|
||||
webCryptoEd25519Engine,
|
||||
webCryptoSignEngine,
|
||||
} from "./webcrypto-sign-engine/webcrypto-sign-engine.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { webCryptoAeadEngine } from "./webcrypto-aead-engine.js";
|
||||
@@ -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,285 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
import { webCryptoAeadEngine } from "./webcrypto-aead-engine.js";
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("webCryptoAeadEngine", () => {
|
||||
it("encrypts and decrypts a known message", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new TextEncoder().encode("hello world");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
key,
|
||||
plaintext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(ciphertext).toBeInstanceOf(Uint8Array);
|
||||
expect(ciphertext.length).toBeGreaterThan(plaintext.length);
|
||||
|
||||
const decrypted = await (webCryptoAeadEngine.decrypt(
|
||||
key,
|
||||
ciphertext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(decrypted).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it("supports additional authenticated data (AAD)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new TextEncoder().encode("authenticated message");
|
||||
const aad = new TextEncoder().encode("header-data");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
key,
|
||||
plaintext,
|
||||
nonce,
|
||||
aad
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const decrypted = await (webCryptoAeadEngine.decrypt(
|
||||
key,
|
||||
ciphertext,
|
||||
nonce,
|
||||
aad
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(decrypted).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it("decryption fails with wrong key", { timeout: 30000 }, async () => {
|
||||
const key = randomBytes(32);
|
||||
const wrongKey = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new TextEncoder().encode("secret");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
key,
|
||||
plaintext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
wrongKey,
|
||||
ciphertext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("decryption fails when ciphertext is tampered", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new TextEncoder().encode("secret");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
key,
|
||||
plaintext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const tampered = new Uint8Array(ciphertext);
|
||||
tampered[0] = tampered[0] ^ 0xff;
|
||||
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
key,
|
||||
tampered,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("decryption fails with wrong AAD", { timeout: 30000 }, async () => {
|
||||
const key = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new TextEncoder().encode("secret");
|
||||
const aad = new TextEncoder().encode("correct-header");
|
||||
const wrongAad = new TextEncoder().encode("wrong-header");
|
||||
|
||||
const ciphertext = await (webCryptoAeadEngine.encrypt(
|
||||
key,
|
||||
plaintext,
|
||||
nonce,
|
||||
aad
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
key,
|
||||
ciphertext,
|
||||
nonce,
|
||||
wrongAad
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("encrypt throws BrowserCryptoError for invalid key size", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// A 5-byte key is not a valid AES key — importKey will reject
|
||||
const badKey = new Uint8Array(5);
|
||||
const nonce = new Uint8Array(12);
|
||||
const plaintext = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(
|
||||
webCryptoAeadEngine.encrypt(
|
||||
badKey,
|
||||
plaintext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("decrypt throws BrowserCryptoError for invalid key size", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(5);
|
||||
const nonce = new Uint8Array(12);
|
||||
const fakeCiphertext = new Uint8Array(32);
|
||||
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
badKey,
|
||||
fakeCiphertext,
|
||||
nonce
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("encrypt re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Monkey-patch importKey to throw a BrowserCryptoError directly
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoAeadEngine.encrypt(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(8),
|
||||
new Uint8Array(12)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("decrypt re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(12)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypt wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoAeadEngine.encrypt(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(8),
|
||||
new Uint8Array(12)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("decrypt wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoAeadEngine.decrypt(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(12)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trip property: encrypt then decrypt recovers plaintext", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.uint8Array({ minLength: 0, maxLength: 256 }),
|
||||
async (plaintextArr) => {
|
||||
const key = randomBytes(32);
|
||||
const nonce = randomBytes(12);
|
||||
const plaintext = new Uint8Array(plaintextArr);
|
||||
|
||||
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>);
|
||||
|
||||
expect(recovered).toEqual(plaintext);
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AeadEngine } from "@suaveplan/crypto-core/engines";
|
||||
import { assertWebCryptoAvailable } from "../../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a raw 32-byte key into a `CryptoKey` for AES-GCM.
|
||||
*/
|
||||
async function importAesKey(rawKey: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(rawKey),
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM AEAD engine backed by `crypto.subtle`.
|
||||
*
|
||||
* Implements the `AeadEngine` interface from `@suaveplan/crypto` so it can be
|
||||
* plugged into `encrypt()` and `decrypt()` calls as the `engine` option:
|
||||
*
|
||||
* ```ts
|
||||
* import { encrypt } from "@suaveplan/crypto-core/aead";
|
||||
* import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
|
||||
* const ciphertext = await encrypt(key, plaintext, nonce, { engine: webCryptoAeadEngine });
|
||||
* ```
|
||||
*
|
||||
* The engine methods return `Promise<Uint8Array>` even though the interface
|
||||
* declares `Uint8Array` — this is intentional: facade functions in
|
||||
* `@suaveplan/crypto` accept both sync and async engine results.
|
||||
*/
|
||||
export const webCryptoAeadEngine: AeadEngine = {
|
||||
/**
|
||||
* Encrypts `plaintext` with AES-256-GCM.
|
||||
*
|
||||
* @param key - 32-byte raw key
|
||||
* @param plaintext - plaintext bytes
|
||||
* @param nonce - 12-byte IV (recommended)
|
||||
* @param aad - optional additional authenticated data
|
||||
* @returns Promise resolving to ciphertext + 16-byte GCM authentication tag
|
||||
* @throws BrowserCryptoError BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
|
||||
*/
|
||||
encrypt(
|
||||
key: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
aad?: Uint8Array
|
||||
): Uint8Array {
|
||||
assertWebCryptoAvailable();
|
||||
return importAesKey(key)
|
||||
.then((cryptoKey) => {
|
||||
const params: AesGcmParams = {
|
||||
name: "AES-GCM",
|
||||
iv: toBuffer(nonce),
|
||||
};
|
||||
if (aad !== undefined && aad.length > 0) {
|
||||
params.additionalData = toBuffer(aad);
|
||||
}
|
||||
return crypto.subtle.encrypt(
|
||||
params,
|
||||
cryptoKey,
|
||||
toBuffer(plaintext)
|
||||
);
|
||||
})
|
||||
.then((buf) => new Uint8Array(buf))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{
|
||||
cause:
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
}
|
||||
);
|
||||
}) as unknown as Uint8Array;
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrypts `ciphertext` (with appended 16-byte GCM tag) using AES-256-GCM.
|
||||
*
|
||||
* @param key - 32-byte raw key
|
||||
* @param ciphertext - ciphertext + 16-byte authentication tag
|
||||
* @param nonce - 12-byte IV used during encryption
|
||||
* @param aad - optional additional authenticated data (must match encryption)
|
||||
* @returns Promise resolving to plaintext bytes
|
||||
* @throws BrowserCryptoError BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
|
||||
*/
|
||||
decrypt(
|
||||
key: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
aad?: Uint8Array
|
||||
): Uint8Array {
|
||||
assertWebCryptoAvailable();
|
||||
return importAesKey(key)
|
||||
.then((cryptoKey) => {
|
||||
const params: AesGcmParams = {
|
||||
name: "AES-GCM",
|
||||
iv: toBuffer(nonce),
|
||||
};
|
||||
if (aad !== undefined && aad.length > 0) {
|
||||
params.additionalData = toBuffer(aad);
|
||||
}
|
||||
return crypto.subtle.decrypt(
|
||||
params,
|
||||
cryptoKey,
|
||||
toBuffer(ciphertext)
|
||||
);
|
||||
})
|
||||
.then((buf) => new Uint8Array(buf))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{
|
||||
cause:
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
}
|
||||
);
|
||||
}) as unknown as Uint8Array;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
webCryptoAgreeEngine,
|
||||
webCryptoEcdhP256Engine,
|
||||
webCryptoX25519Engine,
|
||||
} from "./webcrypto-agree-engine.js";
|
||||
@@ -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,269 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
import {
|
||||
webCryptoAgreeEngine,
|
||||
webCryptoEcdhP256Engine,
|
||||
webCryptoX25519Engine,
|
||||
} from "./webcrypto-agree-engine.js";
|
||||
|
||||
describe("webCryptoX25519Engine", () => {
|
||||
it("generates a valid keypair", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.publicKey.length).toBe(32);
|
||||
});
|
||||
|
||||
it("two parties produce the same shared secret", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
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 aliceShared = await (webCryptoX25519Engine.agree(
|
||||
alice.secretKey,
|
||||
bob.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const bobShared = await (webCryptoX25519Engine.agree(
|
||||
bob.secretKey,
|
||||
alice.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(aliceShared).toEqual(bobShared);
|
||||
expect(aliceShared.length).toBe(32);
|
||||
});
|
||||
|
||||
it("round-trip property: both parties agree on same secret", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.constant(null), async () => {
|
||||
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 s1 = await (webCryptoX25519Engine.agree(
|
||||
alice.secretKey,
|
||||
bob.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
const s2 = await (webCryptoX25519Engine.agree(
|
||||
bob.secretKey,
|
||||
alice.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(s1).toEqual(s2);
|
||||
}),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoX25519Engine error paths", () => {
|
||||
it("agree throws BrowserCryptoError for invalid private key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// 32 bytes of zeroes is not valid X25519 PKCS8 DER
|
||||
const badSecret = new Uint8Array(32);
|
||||
const validKp =
|
||||
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
await expect(
|
||||
webCryptoX25519Engine.agree(
|
||||
badSecret,
|
||||
validKp.publicKey
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("agree throws BrowserCryptoError for invalid public key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const validKp =
|
||||
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const badPublic = new Uint8Array(10); // too short
|
||||
|
||||
await expect(
|
||||
webCryptoX25519Engine.agree(
|
||||
validKp.secretKey,
|
||||
badPublic
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("agree re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoX25519Engine.agree(
|
||||
new Uint8Array(48),
|
||||
new Uint8Array(32)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoX25519Engine.generateKeypair() as unknown as Promise<unknown>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoX25519Engine.generateKeypair() as unknown as Promise<unknown>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("agree wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoX25519Engine.agree(
|
||||
new Uint8Array(48),
|
||||
new Uint8Array(32)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoEcdhP256Engine", () => {
|
||||
it("generates a valid keypair", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
|
||||
// P-256 uncompressed public key = 65 bytes
|
||||
expect(kp.publicKey.length).toBe(65);
|
||||
});
|
||||
|
||||
it("two parties produce the same shared secret", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const alice =
|
||||
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const bob =
|
||||
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
const aliceShared = await (webCryptoEcdhP256Engine.agree(
|
||||
alice.secretKey,
|
||||
bob.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const bobShared = await (webCryptoEcdhP256Engine.agree(
|
||||
bob.secretKey,
|
||||
alice.publicKey
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(aliceShared).toEqual(bobShared);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoEcdhP256Engine error paths", () => {
|
||||
it("agree throws BrowserCryptoError for invalid private key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badSecret = new Uint8Array(32); // not valid PKCS8 DER for ECDH-P256
|
||||
const validKp =
|
||||
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
|
||||
await expect(
|
||||
webCryptoEcdhP256Engine.agree(
|
||||
badSecret,
|
||||
validKp.publicKey
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoAgreeEngine (default = X25519)", () => {
|
||||
it("is the X25519 engine", { timeout: 5000 }, () => {
|
||||
expect(webCryptoAgreeEngine).toBe(webCryptoX25519Engine);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { AgreeEngine, AgreeKeypair } from "@suaveplan/crypto-core/engines";
|
||||
import { assertWebCryptoAvailable } from "../../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
|
||||
type AgreeAlgorithm = "X25519" | "ECDH-P256";
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** Coerce any thrown value to an Error instance. */
|
||||
function toError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
function keyGenAlgo(
|
||||
algorithm: AgreeAlgorithm
|
||||
): EcKeyGenParams | { name: string } {
|
||||
if (algorithm === "ECDH-P256") {
|
||||
return { name: "ECDH", namedCurve: "P-256" };
|
||||
}
|
||||
return { name: "X25519" };
|
||||
}
|
||||
|
||||
async function exportPublicKey(
|
||||
key: CryptoKey,
|
||||
algorithm: AgreeAlgorithm
|
||||
): Promise<Uint8Array> {
|
||||
if (algorithm === "X25519") {
|
||||
// X25519 public key: SPKI last 32 bytes
|
||||
const spki = await crypto.subtle.exportKey("spki", key);
|
||||
return new Uint8Array(spki).slice(-32);
|
||||
}
|
||||
const raw = await crypto.subtle.exportKey("raw", key);
|
||||
return new Uint8Array(raw);
|
||||
}
|
||||
|
||||
async function exportPrivateKey(
|
||||
key: CryptoKey,
|
||||
_algorithm: AgreeAlgorithm
|
||||
): Promise<Uint8Array> {
|
||||
// Store full PKCS8 DER for both X25519 and ECDH-P256
|
||||
const pkcs8 = await crypto.subtle.exportKey("pkcs8", key);
|
||||
return new Uint8Array(pkcs8);
|
||||
}
|
||||
|
||||
async function importPublicKey(
|
||||
algorithm: AgreeAlgorithm,
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (algorithm === "X25519") {
|
||||
// Re-wrap into SPKI for X25519 (OID 1.3.101.110 = 2b 65 6e)
|
||||
const spkiPrefix = new Uint8Array([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21,
|
||||
0x00,
|
||||
]);
|
||||
const spki = new Uint8Array(spkiPrefix.length + keyBytes.length);
|
||||
spki.set(spkiPrefix, 0);
|
||||
spki.set(keyBytes, spkiPrefix.length);
|
||||
return crypto.subtle.importKey(
|
||||
"spki",
|
||||
toBuffer(spki),
|
||||
{ name: "X25519" },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(keyBytes),
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
async function importPrivateKey(
|
||||
algorithm: AgreeAlgorithm,
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (algorithm === "X25519") {
|
||||
// keyBytes is full PKCS8 DER (48 bytes as exported)
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(keyBytes),
|
||||
{ name: "X25519" },
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(keyBytes),
|
||||
{ name: "ECDH", namedCurve: "P-256" },
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
}
|
||||
|
||||
function makeAgreeEngine(algorithm: AgreeAlgorithm): AgreeEngine {
|
||||
return {
|
||||
generateKeypair(): AgreeKeypair {
|
||||
assertWebCryptoAvailable();
|
||||
return crypto.subtle
|
||||
.generateKey(keyGenAlgo(algorithm), true, ["deriveBits"])
|
||||
.then(async (kp) => {
|
||||
const pair = kp as CryptoKeyPair;
|
||||
const [publicKey, secretKey] = await Promise.all([
|
||||
exportPublicKey(pair.publicKey, algorithm),
|
||||
exportPrivateKey(pair.privateKey, algorithm),
|
||||
]);
|
||||
return { publicKey, secretKey };
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}) as unknown as AgreeKeypair;
|
||||
},
|
||||
|
||||
agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array {
|
||||
assertWebCryptoAvailable();
|
||||
return Promise.all([
|
||||
importPrivateKey(algorithm, mySecret),
|
||||
importPublicKey(algorithm, theirPublic),
|
||||
])
|
||||
.then(([myKey, theirKey]) => {
|
||||
const deriveAlgo =
|
||||
algorithm === "ECDH-P256"
|
||||
? { name: "ECDH", public: theirKey }
|
||||
: { name: "X25519", public: theirKey };
|
||||
return crypto.subtle.deriveBits(deriveAlgo, myKey, 256);
|
||||
})
|
||||
.then((buf) => new Uint8Array(buf))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}) as unknown as Uint8Array;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* X25519 key-agreement engine backed by `crypto.subtle`.
|
||||
*
|
||||
* Key encoding: public key is 32 raw bytes; secret key is 32-byte scalar.
|
||||
*/
|
||||
export const webCryptoX25519Engine: AgreeEngine = makeAgreeEngine("X25519");
|
||||
|
||||
/**
|
||||
* ECDH-P256 key-agreement engine backed by `crypto.subtle`.
|
||||
*
|
||||
* Key encoding: public key is 65-byte uncompressed point; secret key is PKCS8 DER.
|
||||
*/
|
||||
export const webCryptoEcdhP256Engine: AgreeEngine =
|
||||
makeAgreeEngine("ECDH-P256");
|
||||
|
||||
/**
|
||||
* Default `webCryptoAgreeEngine` — uses X25519.
|
||||
*/
|
||||
export const webCryptoAgreeEngine: AgreeEngine = webCryptoX25519Engine;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
webCryptoEcdsaP256Engine,
|
||||
webCryptoEd25519Engine,
|
||||
webCryptoSignEngine,
|
||||
} from "./webcrypto-sign-engine.js";
|
||||
@@ -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,331 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
import {
|
||||
webCryptoEcdsaP256Engine,
|
||||
webCryptoEd25519Engine,
|
||||
webCryptoSignEngine,
|
||||
} from "./webcrypto-sign-engine.js";
|
||||
|
||||
describe("webCryptoEd25519Engine", () => {
|
||||
it("generates a valid keypair", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.publicKey.length).toBe(32);
|
||||
});
|
||||
|
||||
it("signs and verifies a message", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const message = new TextEncoder().encode("test message");
|
||||
|
||||
const signature = await (webCryptoEd25519Engine.sign(
|
||||
kp.secretKey,
|
||||
message
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(signature).toBeInstanceOf(Uint8Array);
|
||||
expect(signature.length).toBe(64);
|
||||
|
||||
const valid = await (webCryptoEd25519Engine.verify(
|
||||
kp.publicKey,
|
||||
signature,
|
||||
message
|
||||
) as unknown as Promise<boolean>);
|
||||
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("verification fails with wrong public key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const kp1 =
|
||||
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const kp2 =
|
||||
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const message = new TextEncoder().encode("test");
|
||||
const signature = await (webCryptoEd25519Engine.sign(
|
||||
kp1.secretKey,
|
||||
message
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const valid = await (webCryptoEd25519Engine.verify(
|
||||
kp2.publicKey,
|
||||
signature,
|
||||
message
|
||||
) as unknown as Promise<boolean>);
|
||||
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trip property: sign then verify returns true", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.uint8Array({ minLength: 1, maxLength: 256 }),
|
||||
async (msgArr) => {
|
||||
const kp =
|
||||
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const msg = new Uint8Array(msgArr);
|
||||
const sig = await (webCryptoEd25519Engine.sign(
|
||||
kp.secretKey,
|
||||
msg
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
const result = await (webCryptoEd25519Engine.verify(
|
||||
kp.publicKey,
|
||||
sig,
|
||||
msg
|
||||
) as unknown as Promise<boolean>);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 10 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoEd25519Engine error paths", () => {
|
||||
it("sign re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.sign(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(8)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("verify re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.verify(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(64),
|
||||
new Uint8Array(8)
|
||||
) as unknown as Promise<boolean>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.generateKeypair() as unknown as Promise<unknown>
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.generateKeypair() as unknown as Promise<unknown>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("sign wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.sign(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(8)
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("verify wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
|
||||
crypto.subtle.importKey = async (): Promise<any> => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
webCryptoEd25519Engine.verify(
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(64),
|
||||
new Uint8Array(8)
|
||||
) as unknown as Promise<boolean>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoEcdsaP256Engine", () => {
|
||||
it("generates a valid keypair", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("signs and verifies a message", { timeout: 30000 }, async () => {
|
||||
const kp =
|
||||
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const message = new TextEncoder().encode("ecdsa p256 message");
|
||||
|
||||
const signature = await (webCryptoEcdsaP256Engine.sign(
|
||||
kp.secretKey,
|
||||
message
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
expect(signature).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const valid = await (webCryptoEcdsaP256Engine.verify(
|
||||
kp.publicKey,
|
||||
signature,
|
||||
message
|
||||
) as unknown as Promise<boolean>);
|
||||
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("verification fails with tampered message", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const kp =
|
||||
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
|
||||
publicKey: Uint8Array;
|
||||
secretKey: Uint8Array;
|
||||
}>);
|
||||
const message = new TextEncoder().encode("original");
|
||||
const tampered = new TextEncoder().encode("tampered");
|
||||
|
||||
const signature = await (webCryptoEcdsaP256Engine.sign(
|
||||
kp.secretKey,
|
||||
message
|
||||
) as unknown as Promise<Uint8Array>);
|
||||
|
||||
const valid = await (webCryptoEcdsaP256Engine.verify(
|
||||
kp.publicKey,
|
||||
signature,
|
||||
tampered
|
||||
) as unknown as Promise<boolean>);
|
||||
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoEcdsaP256Engine error paths", () => {
|
||||
it("sign throws BrowserCryptoError for invalid PKCS8 bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid PKCS8 DER
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(
|
||||
webCryptoEcdsaP256Engine.sign(
|
||||
badKey,
|
||||
msg
|
||||
) as unknown as Promise<Uint8Array>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("verify throws BrowserCryptoError for invalid public key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badPub = new Uint8Array(10); // too short for raw P-256
|
||||
const sig = new Uint8Array(64);
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(
|
||||
webCryptoEcdsaP256Engine.verify(
|
||||
badPub,
|
||||
sig,
|
||||
msg
|
||||
) as unknown as Promise<boolean>
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webCryptoSignEngine (default = Ed25519)", () => {
|
||||
it("is the Ed25519 engine", { timeout: 5000 }, () => {
|
||||
expect(webCryptoSignEngine).toBe(webCryptoEd25519Engine);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { SignEngine, SignKeypair } from "@suaveplan/crypto-core/engines";
|
||||
import { assertWebCryptoAvailable } from "../../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
|
||||
|
||||
type SignAlgorithm = "Ed25519" | "ECDSA-P256";
|
||||
|
||||
/** Normalize a Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** Coerce any thrown value to an Error instance. */
|
||||
function toError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
/** Build the algorithm params object for the given algorithm. */
|
||||
function algoParams(
|
||||
algorithm: SignAlgorithm
|
||||
): EcKeyGenParams | { name: string } {
|
||||
if (algorithm === "ECDSA-P256") {
|
||||
return { name: "ECDSA", namedCurve: "P-256" };
|
||||
}
|
||||
return { name: "Ed25519" };
|
||||
}
|
||||
|
||||
/** Extract raw public key bytes from a CryptoKey (SPKI format → raw bytes). */
|
||||
async function exportPublicKey(
|
||||
key: CryptoKey,
|
||||
algorithm: SignAlgorithm
|
||||
): Promise<Uint8Array> {
|
||||
if (algorithm === "Ed25519") {
|
||||
const spki = await crypto.subtle.exportKey("spki", key);
|
||||
// Ed25519 SPKI = 44 bytes header + 32 bytes raw key — last 32 bytes
|
||||
return new Uint8Array(spki).slice(-32);
|
||||
}
|
||||
const raw = await crypto.subtle.exportKey("raw", key);
|
||||
return new Uint8Array(raw);
|
||||
}
|
||||
|
||||
/** Extract raw private key bytes from a CryptoKey (PKCS8 DER → raw bytes). */
|
||||
async function exportPrivateKey(
|
||||
key: CryptoKey,
|
||||
algorithm: SignAlgorithm
|
||||
): Promise<Uint8Array> {
|
||||
const pkcs8 = await crypto.subtle.exportKey("pkcs8", key);
|
||||
if (algorithm === "Ed25519") {
|
||||
// Ed25519 PKCS8: ASN.1 wrapper; raw 32-byte seed is at offset 16
|
||||
return new Uint8Array(pkcs8).slice(16, 48);
|
||||
}
|
||||
// P-256 PKCS8: return full DER bytes for import
|
||||
return new Uint8Array(pkcs8);
|
||||
}
|
||||
|
||||
async function importPublicKey(
|
||||
algorithm: SignAlgorithm,
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (algorithm === "Ed25519") {
|
||||
// Re-wrap raw bytes into SPKI (SubjectPublicKeyInfo) for import
|
||||
const spkiPrefix = new Uint8Array([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21,
|
||||
0x00,
|
||||
]);
|
||||
const spki = new Uint8Array(spkiPrefix.length + keyBytes.length);
|
||||
spki.set(spkiPrefix, 0);
|
||||
spki.set(keyBytes, spkiPrefix.length);
|
||||
return crypto.subtle.importKey(
|
||||
"spki",
|
||||
toBuffer(spki),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"]
|
||||
);
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
toBuffer(keyBytes),
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["verify"]
|
||||
);
|
||||
}
|
||||
|
||||
async function importPrivateKey(
|
||||
algorithm: SignAlgorithm,
|
||||
keyBytes: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
if (algorithm === "Ed25519") {
|
||||
// Build minimal PKCS8 wrapper for Ed25519 private key (seed)
|
||||
const prefix = new Uint8Array([
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
||||
0x70, 0x04, 0x22, 0x04, 0x20,
|
||||
]);
|
||||
const pkcs8 = new Uint8Array(prefix.length + 32);
|
||||
pkcs8.set(prefix, 0);
|
||||
pkcs8.set(keyBytes.slice(0, 32), prefix.length);
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(pkcs8),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(keyBytes),
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
}
|
||||
|
||||
function makeSignEngine(algorithm: SignAlgorithm): SignEngine {
|
||||
return {
|
||||
generateKeypair(): SignKeypair {
|
||||
assertWebCryptoAvailable();
|
||||
return crypto.subtle
|
||||
.generateKey(algoParams(algorithm), true, ["sign", "verify"])
|
||||
.then(async (kp) => {
|
||||
const pair = kp as CryptoKeyPair;
|
||||
const [publicKey, secretKey] = await Promise.all([
|
||||
exportPublicKey(pair.publicKey, algorithm),
|
||||
exportPrivateKey(pair.privateKey, algorithm),
|
||||
]);
|
||||
return { publicKey, secretKey };
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}) as unknown as SignKeypair;
|
||||
},
|
||||
|
||||
sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
assertWebCryptoAvailable();
|
||||
return importPrivateKey(algorithm, secretKey)
|
||||
.then((ck) => {
|
||||
const signAlgo =
|
||||
algorithm === "ECDSA-P256"
|
||||
? { name: "ECDSA", hash: "SHA-256" }
|
||||
: { name: "Ed25519" };
|
||||
return crypto.subtle.sign(signAlgo, ck, toBuffer(message));
|
||||
})
|
||||
.then((buf) => new Uint8Array(buf))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}) as unknown as Uint8Array;
|
||||
},
|
||||
|
||||
verify(
|
||||
publicKey: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
message: Uint8Array
|
||||
): boolean {
|
||||
assertWebCryptoAvailable();
|
||||
return importPublicKey(algorithm, publicKey)
|
||||
.then((ck) => {
|
||||
const verifyAlgo =
|
||||
algorithm === "ECDSA-P256"
|
||||
? { name: "ECDSA", hash: "SHA-256" }
|
||||
: { name: "Ed25519" };
|
||||
return crypto.subtle.verify(
|
||||
verifyAlgo,
|
||||
ck,
|
||||
toBuffer(signature),
|
||||
toBuffer(message)
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed",
|
||||
{ cause: toError(err) }
|
||||
);
|
||||
}) as unknown as boolean;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ed25519 signing engine backed by `crypto.subtle`.
|
||||
*
|
||||
* Key encoding: public key is 32 raw bytes; secret key is 32-byte seed
|
||||
* (the first half of the 64-byte Ed25519 private key).
|
||||
*/
|
||||
export const webCryptoEd25519Engine: SignEngine = makeSignEngine("Ed25519");
|
||||
|
||||
/**
|
||||
* ECDSA-P256 signing engine backed by `crypto.subtle`.
|
||||
*
|
||||
* Key encoding: public key is 65-byte uncompressed point (0x04 || X || Y);
|
||||
* secret key is PKCS8 DER bytes.
|
||||
*/
|
||||
export const webCryptoEcdsaP256Engine: SignEngine =
|
||||
makeSignEngine("ECDSA-P256");
|
||||
|
||||
/**
|
||||
* Default `webCryptoSignEngine` — uses Ed25519.
|
||||
*/
|
||||
export const webCryptoSignEngine: SignEngine = webCryptoEd25519Engine;
|
||||
@@ -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,20 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import type { BrowserCryptoCode } from "../codes/codes.js";
|
||||
|
||||
/**
|
||||
* Structured error thrown by all `@suaveplan/crypto-browser` operations.
|
||||
*
|
||||
* Extends `SuaveplanError` so it participates in the monorepo-wide cause-chain
|
||||
* traversal, OTel span-event adapter, and wire-format serialization provided by
|
||||
* `@suaveplan/error`.
|
||||
*/
|
||||
export class BrowserCryptoError extends SuaveplanError<BrowserCryptoCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: BrowserCryptoCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BrowserCryptoError } from "./browser-crypto-error.js";
|
||||
@@ -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,10 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
export const BROWSER_CRYPTO_CODES = defineCodes("BROWSER_CRYPTO", [
|
||||
"NOT_AVAILABLE",
|
||||
"KEY.GENERATION_FAILED",
|
||||
"OPERATION.FAILED",
|
||||
] as const);
|
||||
|
||||
export type BrowserCryptoCode =
|
||||
(typeof BROWSER_CRYPTO_CODES)[keyof typeof BROWSER_CRYPTO_CODES];
|
||||
@@ -0,0 +1 @@
|
||||
export { BROWSER_CRYPTO_CODES, type BrowserCryptoCode } from "./codes.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BrowserCryptoError } from "./browser-crypto-error/browser-crypto-error.js";
|
||||
export { BROWSER_CRYPTO_CODES, type BrowserCryptoCode } from "./codes/codes.js";
|
||||
35
packages/security-stack/crypto/crypto-browser/src/index.ts
Normal file
35
packages/security-stack/crypto/crypto-browser/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Availability
|
||||
|
||||
// AES Key Wrap
|
||||
export { aesKw } from "./aes-kw/aes-kw.js";
|
||||
export {
|
||||
assertWebCryptoAvailable,
|
||||
isWebCryptoAvailable,
|
||||
} from "./availability/availability.js";
|
||||
// Constants
|
||||
export * from "./constants/index.js";
|
||||
export { ecdhP384 } from "./ecdh-p384/ecdh-p384.js";
|
||||
// P-384 curves
|
||||
export { ecdsaP384 } from "./ecdsa-p384/ecdsa-p384.js";
|
||||
// Engine implementations
|
||||
export { webCryptoAeadEngine } from "./engines/webcrypto-aead-engine/webcrypto-aead-engine.js";
|
||||
export {
|
||||
webCryptoAgreeEngine,
|
||||
webCryptoEcdhP256Engine,
|
||||
webCryptoX25519Engine,
|
||||
} from "./engines/webcrypto-agree-engine/webcrypto-agree-engine.js";
|
||||
export {
|
||||
webCryptoEcdsaP256Engine,
|
||||
webCryptoEd25519Engine,
|
||||
webCryptoSignEngine,
|
||||
} from "./engines/webcrypto-sign-engine/webcrypto-sign-engine.js";
|
||||
// Errors
|
||||
export {
|
||||
BROWSER_CRYPTO_CODES,
|
||||
type BrowserCryptoCode,
|
||||
BrowserCryptoError,
|
||||
} from "./errors/index.js";
|
||||
// Non-extractable key generation
|
||||
export { nonExtractableKey } from "./non-extractable-key/non-extractable-key.js";
|
||||
// RSA primitives
|
||||
export { type RsaAlgorithm, rsa } from "./rsa/rsa.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { nonExtractableKey } from "./non-extractable-key";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
import { nonExtractableKey } from "./non-extractable-key.js";
|
||||
|
||||
describe("nonExtractableKey", () => {
|
||||
it("generates an AES-GCM non-extractable key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = await nonExtractableKey({ name: "AES-GCM", length: 256 }, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
|
||||
expect(key).toBeInstanceOf(CryptoKey);
|
||||
expect(key.extractable).toBe(false);
|
||||
expect(key.type).toBe("secret");
|
||||
expect(key.usages).toContain("encrypt");
|
||||
expect(key.usages).toContain("decrypt");
|
||||
});
|
||||
|
||||
it("key cannot be exported (extractable=false)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = await nonExtractableKey({ name: "AES-GCM", length: 256 }, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
|
||||
await expect(crypto.subtle.exportKey("raw", key)).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("generates an HMAC non-extractable key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = await nonExtractableKey({ name: "HMAC", hash: "SHA-256" }, [
|
||||
"sign",
|
||||
"verify",
|
||||
]);
|
||||
|
||||
expect(key.extractable).toBe(false);
|
||||
expect(key.type).toBe("secret");
|
||||
});
|
||||
|
||||
it("generates an ECDSA P-256 non-extractable private key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = await nonExtractableKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
["sign"]
|
||||
);
|
||||
|
||||
expect(key.extractable).toBe(false);
|
||||
expect(key.type).toBe("private");
|
||||
});
|
||||
|
||||
it("generates an AES-256 non-extractable key for AES-KW", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const key = await nonExtractableKey({ name: "AES-KW", length: 256 }, [
|
||||
"wrapKey",
|
||||
"unwrapKey",
|
||||
]);
|
||||
|
||||
expect(key.extractable).toBe(false);
|
||||
expect(key.usages).toContain("wrapKey");
|
||||
});
|
||||
|
||||
it("throws BrowserCryptoError for invalid algorithm", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// "AES-GCM" with invalid length triggers a DOMException
|
||||
await expect(
|
||||
nonExtractableKey(
|
||||
{ name: "AES-GCM", length: 7 } as AesKeyGenParams,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("re-throws BrowserCryptoError without double-wrapping", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
nonExtractableKey({ name: "AES-GCM", length: 256 }, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
])
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown value in new Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
nonExtractableKey({ name: "AES-GCM", length: 256 }, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
])
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { assertWebCryptoAvailable } from "../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
/**
|
||||
* Generates a non-extractable `CryptoKey` handle.
|
||||
*
|
||||
* The key material is generated inside the browser's cryptographic engine and
|
||||
* is never accessible to JavaScript code. The returned `CryptoKey` can only be
|
||||
* used for the operations specified in `usages`; it cannot be exported via
|
||||
* `crypto.subtle.exportKey`.
|
||||
*
|
||||
* This is useful when key material must remain hardware-bound or when
|
||||
* compliance requirements forbid key export (e.g., HSM-like constraints in
|
||||
* browser contexts, FIDO2 resident keys, session-scoped encryption keys).
|
||||
*
|
||||
* @param algorithm - Key generation algorithm descriptor. Supported:
|
||||
* - AES-GCM / AES-CBC / AES-KW: `{ name: "AES-GCM", length: 256 }`
|
||||
* - HMAC: `{ name: "HMAC", hash: "SHA-256" }`
|
||||
* - RSA-PSS / RSA-OAEP / RSASSA-PKCS1-v1_5: `{ name: "RSA-PSS", modulusLength: 2048, publicExponent: ..., hash: "SHA-256" }`
|
||||
* - ECDSA / ECDH: `{ name: "ECDSA", namedCurve: "P-256" }`
|
||||
* - Ed25519 / X25519: `{ name: "Ed25519" }`
|
||||
* @param usages - Key usages array, e.g. `["encrypt", "decrypt"]`, `["sign", "verify"]`
|
||||
* @returns Promise resolving to a `CryptoKey` with `extractable === false`
|
||||
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
export async function nonExtractableKey(
|
||||
algorithm:
|
||||
| AlgorithmIdentifier
|
||||
| RsaHashedKeyGenParams
|
||||
| EcKeyGenParams
|
||||
| HmacKeyGenParams
|
||||
| AesKeyGenParams,
|
||||
usages: KeyUsage[]
|
||||
): Promise<CryptoKey> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const result = await crypto.subtle.generateKey(
|
||||
algorithm,
|
||||
false,
|
||||
usages
|
||||
);
|
||||
// For asymmetric algorithms, generateKey returns a CryptoKeyPair
|
||||
if (isCryptoKeyPair(result)) {
|
||||
return result.privateKey;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed",
|
||||
{
|
||||
cause: err instanceof Error ? err : new Error(String(err)),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isCryptoKeyPair(
|
||||
value: CryptoKey | CryptoKeyPair
|
||||
): value is CryptoKeyPair {
|
||||
return "privateKey" in value && "publicKey" in value;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type RsaAlgorithm, rsa } from "./rsa";
|
||||
138
packages/security-stack/crypto/crypto-browser/src/rsa/rsa.md
Normal file
138
packages/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.
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
import { rsa } from "./rsa.js";
|
||||
|
||||
describe("rsa", () => {
|
||||
describe("RSA-PSS", () => {
|
||||
it("generates a keypair, signs and verifies", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { publicKey, privateKey } =
|
||||
await rsa.generateKeypair("RSA-PSS");
|
||||
|
||||
expect(publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(publicKey.length).toBeGreaterThan(0);
|
||||
|
||||
const message = new TextEncoder().encode("rsa-pss test message");
|
||||
const signature = await rsa.sign("RSA-PSS", privateKey, message);
|
||||
|
||||
expect(signature).toBeInstanceOf(Uint8Array);
|
||||
expect(signature.length).toBeGreaterThan(0);
|
||||
|
||||
const valid = await rsa.verify(
|
||||
"RSA-PSS",
|
||||
publicKey,
|
||||
signature,
|
||||
message
|
||||
);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("verification fails with wrong message", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { publicKey, privateKey } =
|
||||
await rsa.generateKeypair("RSA-PSS");
|
||||
const message = new TextEncoder().encode("correct");
|
||||
const wrong = new TextEncoder().encode("wrong");
|
||||
const signature = await rsa.sign("RSA-PSS", privateKey, message);
|
||||
|
||||
const valid = await rsa.verify(
|
||||
"RSA-PSS",
|
||||
publicKey,
|
||||
signature,
|
||||
wrong
|
||||
);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RSA-OAEP", () => {
|
||||
it("generates a keypair, encrypts and decrypts", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { publicKey, privateKey } =
|
||||
await rsa.generateKeypair("RSA-OAEP");
|
||||
|
||||
const plaintext = new TextEncoder().encode("rsa-oaep secret");
|
||||
const ciphertext = await rsa.encrypt(publicKey, plaintext);
|
||||
|
||||
expect(ciphertext).toBeInstanceOf(Uint8Array);
|
||||
expect(ciphertext.length).toBeGreaterThan(plaintext.length);
|
||||
|
||||
const decrypted = await rsa.decrypt(privateKey, ciphertext);
|
||||
expect(decrypted).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it("decryption fails with wrong private key", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const kp1 = await rsa.generateKeypair("RSA-OAEP");
|
||||
const kp2 = await rsa.generateKeypair("RSA-OAEP");
|
||||
|
||||
const plaintext = new TextEncoder().encode("secret");
|
||||
const ciphertext = await rsa.encrypt(kp1.publicKey, plaintext);
|
||||
|
||||
await expect(
|
||||
rsa.decrypt(kp2.privateKey, ciphertext)
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RSASSA-PKCS1-v1_5", () => {
|
||||
it("generates a keypair, signs and verifies", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { publicKey, privateKey } =
|
||||
await rsa.generateKeypair("RSASSA-PKCS1-v1_5");
|
||||
|
||||
const message = new TextEncoder().encode("pkcs1 test");
|
||||
const signature = await rsa.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
privateKey,
|
||||
message
|
||||
);
|
||||
|
||||
expect(signature).toBeInstanceOf(Uint8Array);
|
||||
|
||||
const valid = await rsa.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
publicKey,
|
||||
signature,
|
||||
message
|
||||
);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateKeypair with custom modulus length", () => {
|
||||
it("accepts 4096-bit modulus", { timeout: 30000 }, async () => {
|
||||
const { publicKey, privateKey } = await rsa.generateKeypair(
|
||||
"RSA-PSS",
|
||||
4096
|
||||
);
|
||||
expect(publicKey.length).toBeGreaterThan(256); // larger key = larger SPKI
|
||||
expect(privateKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error paths", () => {
|
||||
it("sign throws BrowserCryptoError for invalid private key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid PKCS8 DER
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(
|
||||
rsa.sign("RSA-PSS", badKey, msg)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("verify throws BrowserCryptoError for invalid public key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid SPKI DER
|
||||
const sig = new Uint8Array(256);
|
||||
const msg = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(
|
||||
rsa.verify("RSA-PSS", badKey, sig, msg)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("encrypt throws BrowserCryptoError for invalid public key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid SPKI DER for RSA-OAEP
|
||||
const plaintext = new TextEncoder().encode("hello");
|
||||
|
||||
await expect(rsa.encrypt(badKey, plaintext)).rejects.toBeInstanceOf(
|
||||
BrowserCryptoError
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypt throws BrowserCryptoError for invalid private key bytes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const badKey = new Uint8Array(32); // not valid PKCS8 DER
|
||||
const ciphertext = new Uint8Array(256);
|
||||
|
||||
await expect(
|
||||
rsa.decrypt(badKey, ciphertext)
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
});
|
||||
|
||||
it("generateKeypair re-throws BrowserCryptoError via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
|
||||
"browser-crypto.key-generation-failed"
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(rsa.generateKeypair("RSA-PSS")).rejects.toBe(
|
||||
sentinel
|
||||
);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("generateKeypair wraps non-Error thrown value via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
(crypto.subtle as any).generateKey = async () => {
|
||||
throw "string-error";
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
rsa.generateKeypair("RSA-PSS")
|
||||
).rejects.toBeInstanceOf(BrowserCryptoError);
|
||||
} finally {
|
||||
(crypto.subtle as any).generateKey = originalGenerateKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("sign re-throws BrowserCryptoError via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
rsa.sign("RSA-PSS", new Uint8Array(256), new Uint8Array(8))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("verify re-throws BrowserCryptoError via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
rsa.verify(
|
||||
"RSA-PSS",
|
||||
new Uint8Array(256),
|
||||
new Uint8Array(256),
|
||||
new Uint8Array(8)
|
||||
)
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypt re-throws BrowserCryptoError via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
rsa.encrypt(new Uint8Array(256), new Uint8Array(8))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("decrypt re-throws BrowserCryptoError via wrapError", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalImportKey = crypto.subtle.importKey.bind(
|
||||
crypto.subtle
|
||||
);
|
||||
const sentinel = new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
|
||||
"browser-crypto.operation-failed"
|
||||
);
|
||||
crypto.subtle.importKey = async () => {
|
||||
throw sentinel;
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
rsa.decrypt(new Uint8Array(256), new Uint8Array(256))
|
||||
).rejects.toBe(sentinel);
|
||||
} finally {
|
||||
crypto.subtle.importKey = originalImportKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
235
packages/security-stack/crypto/crypto-browser/src/rsa/rsa.ts
Normal file
235
packages/security-stack/crypto/crypto-browser/src/rsa/rsa.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { assertWebCryptoAvailable } from "../availability/availability.js";
|
||||
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
|
||||
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
|
||||
|
||||
/** Supported RSA algorithm identifiers. */
|
||||
export type RsaAlgorithm = "RSA-PSS" | "RSA-OAEP" | "RSASSA-PKCS1-v1_5";
|
||||
|
||||
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
|
||||
function toBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function genAlgo(
|
||||
algorithm: RsaAlgorithm,
|
||||
modulusLength: number
|
||||
): RsaHashedKeyGenParams {
|
||||
return {
|
||||
name: algorithm,
|
||||
modulusLength,
|
||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
|
||||
hash: "SHA-256",
|
||||
};
|
||||
}
|
||||
|
||||
function signAlgo(algorithm: RsaAlgorithm): AlgorithmIdentifier | RsaPssParams {
|
||||
if (algorithm === "RSA-PSS") {
|
||||
return { name: "RSA-PSS", saltLength: 32 };
|
||||
}
|
||||
return { name: algorithm };
|
||||
}
|
||||
|
||||
async function importPublicKey(
|
||||
algorithm: RsaAlgorithm,
|
||||
keyBytes: Uint8Array,
|
||||
usages: KeyUsage[]
|
||||
): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"spki",
|
||||
toBuffer(keyBytes),
|
||||
{ name: algorithm, hash: "SHA-256" },
|
||||
false,
|
||||
usages
|
||||
);
|
||||
}
|
||||
|
||||
async function importPrivateKey(
|
||||
algorithm: RsaAlgorithm,
|
||||
keyBytes: Uint8Array,
|
||||
usages: KeyUsage[]
|
||||
): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toBuffer(keyBytes),
|
||||
{ name: algorithm, hash: "SHA-256" },
|
||||
false,
|
||||
usages
|
||||
);
|
||||
}
|
||||
|
||||
function wrapError(
|
||||
err: unknown,
|
||||
code: keyof typeof BROWSER_CRYPTO_CODES
|
||||
): never {
|
||||
if (err instanceof BrowserCryptoError) throw err;
|
||||
throw new BrowserCryptoError(
|
||||
BROWSER_CRYPTO_CODES[code],
|
||||
`browser-crypto.${code === "KEY.GENERATION_FAILED" ? "key-generation-failed" : "operation-failed"}`,
|
||||
{ cause: err instanceof Error ? err : new Error(String(err)) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA cryptographic operations via `crypto.subtle`.
|
||||
*
|
||||
* Keys are represented as DER bytes: SPKI for public keys, PKCS8 for private
|
||||
* keys. This matches the format returned by `generateKeypair()` and accepted
|
||||
* by `sign()`, `verify()`, `encrypt()`, and `decrypt()`.
|
||||
*/
|
||||
export const rsa = {
|
||||
/**
|
||||
* Generates an RSA keypair.
|
||||
*
|
||||
* @param algorithm - RSA algorithm variant
|
||||
* @param modulusLength - modulus bit length (default 2048)
|
||||
* @returns Promise resolving to `{ publicKey, privateKey }` as DER bytes
|
||||
* @throws BrowserCryptoError KEY.GENERATION_FAILED
|
||||
*/
|
||||
async generateKeypair(
|
||||
algorithm: RsaAlgorithm,
|
||||
modulusLength = 2048
|
||||
): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const usages: KeyUsage[] =
|
||||
algorithm === "RSA-OAEP"
|
||||
? ["encrypt", "decrypt"]
|
||||
: ["sign", "verify"];
|
||||
|
||||
const kp = (await crypto.subtle.generateKey(
|
||||
genAlgo(algorithm, modulusLength),
|
||||
true,
|
||||
usages
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
const [spki, pkcs8] = await Promise.all([
|
||||
crypto.subtle.exportKey("spki", kp.publicKey),
|
||||
crypto.subtle.exportKey("pkcs8", kp.privateKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
publicKey: new Uint8Array(spki),
|
||||
privateKey: new Uint8Array(pkcs8),
|
||||
};
|
||||
} catch (err) {
|
||||
wrapError(err, "KEY.GENERATION_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Signs a message with RSA-PSS or RSASSA-PKCS1-v1_5.
|
||||
*
|
||||
* @param algorithm - must be `"RSA-PSS"` or `"RSASSA-PKCS1-v1_5"`
|
||||
* @param privateKey - PKCS8 DER private key bytes
|
||||
* @param message - message to sign
|
||||
* @returns Promise resolving to DER-encoded signature bytes
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async sign(
|
||||
algorithm: RsaAlgorithm,
|
||||
privateKey: Uint8Array,
|
||||
message: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await importPrivateKey(algorithm, privateKey, ["sign"]);
|
||||
const sig = await crypto.subtle.sign(
|
||||
signAlgo(algorithm),
|
||||
key,
|
||||
toBuffer(message)
|
||||
);
|
||||
return new Uint8Array(sig);
|
||||
} catch (err) {
|
||||
wrapError(err, "OPERATION.FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies an RSA signature.
|
||||
*
|
||||
* @param algorithm - must match algorithm used during signing
|
||||
* @param publicKey - SPKI DER public key bytes
|
||||
* @param signature - signature bytes
|
||||
* @param message - original message bytes
|
||||
* @returns Promise resolving to `true` if signature is valid
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async verify(
|
||||
algorithm: RsaAlgorithm,
|
||||
publicKey: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
message: Uint8Array
|
||||
): Promise<boolean> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await importPublicKey(algorithm, publicKey, ["verify"]);
|
||||
return await crypto.subtle.verify(
|
||||
signAlgo(algorithm),
|
||||
key,
|
||||
toBuffer(signature),
|
||||
toBuffer(message)
|
||||
);
|
||||
} catch (err) {
|
||||
wrapError(err, "OPERATION.FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Encrypts plaintext using RSA-OAEP.
|
||||
*
|
||||
* @param publicKey - SPKI DER public key bytes
|
||||
* @param plaintext - bytes to encrypt (max ~190 bytes for RSA-2048)
|
||||
* @returns Promise resolving to ciphertext bytes
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async encrypt(
|
||||
publicKey: Uint8Array,
|
||||
plaintext: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await importPublicKey("RSA-OAEP", publicKey, [
|
||||
"encrypt",
|
||||
]);
|
||||
const ct = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
key,
|
||||
toBuffer(plaintext)
|
||||
);
|
||||
return new Uint8Array(ct);
|
||||
} catch (err) {
|
||||
wrapError(err, "OPERATION.FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrypts ciphertext using RSA-OAEP.
|
||||
*
|
||||
* @param privateKey - PKCS8 DER private key bytes
|
||||
* @param ciphertext - bytes to decrypt
|
||||
* @returns Promise resolving to plaintext bytes
|
||||
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
|
||||
*/
|
||||
async decrypt(
|
||||
privateKey: Uint8Array,
|
||||
ciphertext: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
assertWebCryptoAvailable();
|
||||
try {
|
||||
const key = await importPrivateKey("RSA-OAEP", privateKey, [
|
||||
"decrypt",
|
||||
]);
|
||||
const pt = await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
key,
|
||||
toBuffer(ciphertext)
|
||||
);
|
||||
return new Uint8Array(pt);
|
||||
} catch (err) {
|
||||
wrapError(err, "OPERATION.FAILED");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_DIR = join(import.meta.dirname, "..", "src");
|
||||
|
||||
async function collectTsFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectTsFiles(full)));
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts")
|
||||
) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe("source quality: no disallowed patterns", () => {
|
||||
it("no vi.mock / jest.mock in src/", { timeout: 5000 }, async () => {
|
||||
const files = await collectTsFiles(SRC_DIR);
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, "utf-8");
|
||||
if (content.includes("vi.mock") || content.includes("jest.mock")) {
|
||||
violations.push(file);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("no TODO or FIXME in src/", { timeout: 5000 }, async () => {
|
||||
const files = await collectTsFiles(SRC_DIR);
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, "utf-8");
|
||||
if (/\bTODO\b|\bFIXME\b/.test(content)) {
|
||||
violations.push(file);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("no ts-ignore in src/", { timeout: 5000 }, async () => {
|
||||
const files = await collectTsFiles(SRC_DIR);
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, "utf-8");
|
||||
if (content.includes("@ts-ignore")) {
|
||||
violations.push(file);
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto; CryptoKey?: typeof CryptoKey };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
if (g.CryptoKey === undefined) {
|
||||
// Node exposes CryptoKey on the webcrypto object
|
||||
g.CryptoKey = (
|
||||
webcrypto as unknown as { CryptoKey: typeof CryptoKey }
|
||||
).CryptoKey;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake: selective imports work", () => {
|
||||
it("importing only availability does not throw", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { isWebCryptoAvailable } = await import(
|
||||
"../src/availability/availability.js"
|
||||
);
|
||||
expect(typeof isWebCryptoAvailable).toBe("function");
|
||||
expect(isWebCryptoAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("importing only AEAD engine does not throw", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { webCryptoAeadEngine } = await import(
|
||||
"../src/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
|
||||
);
|
||||
expect(typeof webCryptoAeadEngine.encrypt).toBe("function");
|
||||
expect(typeof webCryptoAeadEngine.decrypt).toBe("function");
|
||||
});
|
||||
|
||||
it("importing only errors does not throw", { timeout: 30000 }, async () => {
|
||||
const { BrowserCryptoError, BROWSER_CRYPTO_CODES } = await import(
|
||||
"../src/errors/index.js"
|
||||
);
|
||||
expect(BrowserCryptoError).toBeDefined();
|
||||
expect(BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]).toBe(
|
||||
"BROWSER_CRYPTO.NOT_AVAILABLE"
|
||||
);
|
||||
});
|
||||
|
||||
it("barrel index exports all expected symbols", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(typeof mod.isWebCryptoAvailable).toBe("function");
|
||||
expect(typeof mod.webCryptoAeadEngine).toBe("object");
|
||||
expect(typeof mod.webCryptoSignEngine).toBe("object");
|
||||
expect(typeof mod.webCryptoAgreeEngine).toBe("object");
|
||||
expect(typeof mod.rsa).toBe("object");
|
||||
expect(typeof mod.aesKw).toBe("object");
|
||||
expect(typeof mod.ecdsaP384).toBe("object");
|
||||
expect(typeof mod.ecdhP384).toBe("object");
|
||||
expect(typeof mod.nonExtractableKey).toBe("function");
|
||||
expect(mod.BrowserCryptoError).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
55
packages/security-stack/crypto/crypto-browser/vite.config.ts
Normal file
55
packages/security-stack/crypto/crypto-browser/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/crypto-browser",
|
||||
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",
|
||||
"availability/availability": "src/availability/availability.ts",
|
||||
"engines/webcrypto-aead-engine/webcrypto-aead-engine":
|
||||
"src/engines/webcrypto-aead-engine/webcrypto-aead-engine.ts",
|
||||
"engines/webcrypto-sign-engine/webcrypto-sign-engine":
|
||||
"src/engines/webcrypto-sign-engine/webcrypto-sign-engine.ts",
|
||||
"engines/webcrypto-agree-engine/webcrypto-agree-engine":
|
||||
"src/engines/webcrypto-agree-engine/webcrypto-agree-engine.ts",
|
||||
"rsa/rsa": "src/rsa/rsa.ts",
|
||||
"aes-kw/aes-kw": "src/aes-kw/aes-kw.ts",
|
||||
"ecdsa-p384/ecdsa-p384": "src/ecdsa-p384/ecdsa-p384.ts",
|
||||
"ecdh-p384/ecdh-p384": "src/ecdh-p384/ecdh-p384.ts",
|
||||
"non-extractable-key/non-extractable-key":
|
||||
"src/non-extractable-key/non-extractable-key.ts",
|
||||
"errors/index": "src/errors/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/crypto",
|
||||
"@suaveplan/error",
|
||||
"tslib",
|
||||
/^@suaveplan\//,
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/constants/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user