Initial Commit
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user