Initial Commit

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

View File

@@ -0,0 +1,5 @@
export {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
webCryptoSignEngine,
} from "./webcrypto-sign-engine.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;