summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md183
-rw-r--r--banner.pngbin0 -> 126470 bytes
-rw-r--r--deno.json10
-rw-r--r--deno.lock24
-rw-r--r--example.ts150
-rw-r--r--mod.ts640
6 files changed, 1007 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bec0fe9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,183 @@
+![](banner.png)
+
+A JWT module for Deno that signs and verifies tokens using **ML-DSA** (FIPS 204) or a **hybrid ML-DSA + Ed25519** scheme for defence-in-depth during the post-quantum transition.
+
+
+
+## Features
+
+- **ML-DSA only** — `ML-DSA-44` / `ML-DSA-65` / `ML-DSA-87` via [`@oqs/liboqs-js`](https://www.npmjs.com/package/@oqs/liboqs-js)
+- **Hybrid mode** — ML-DSA + Ed25519 signed in parallel. Both signatures must verify. If either algorithm is later broken (classical or quantum), the other still protects the token.
+- Standard JWT claims: `exp`, `iat`, `iss`, `sub`
+- Zero-dependency verification helpers, typed errors via `JwtError`
+
+
+
+## Install
+
+```sh
+deno add jsr:@netopwibby/pq-jwt
+```
+
+Or import directly:
+
+```ts
+import { sign, verify } from "jsr:@netopwibby/pq-jwt";
+```
+
+
+
+## Usage
+
+### ML-DSA only
+
+```ts
+import { generateKeyPair, sign, verify } from "jsr:@netopwibby/pq-jwt";
+
+const { publicKey, secretKey } = await generateKeyPair("ML-DSA-65");
+
+const token = await sign(
+ {
+ role: "member",
+ sub: "user_123"
+ },
+ secretKey,
+ {
+ algorithm: "ML-DSA-65",
+ expiresIn: 3600,
+ issuer: "kern.neue"
+ }
+);
+
+const claims = await verify(token, publicKey, {
+ algorithm: "ML-DSA-65",
+ issuer: "kern.neue"
+});
+
+console.log(claims.sub); // "user_123"
+```
+
+### Hybrid ML-DSA + Ed25519
+
+```ts
+import {
+ generateHybridKeyPair,
+ hybridSign,
+ hybridVerify
+} from "jsr:@netopwibby/pq-jwt";
+
+const keys = await generateHybridKeyPair("ML-DSA-65");
+
+const token = await hybridSign(
+ {
+ role: "admin",
+ sub: "user_123"
+ },
+ keys,
+ {
+ expiresIn: 3600,
+ issuer: "kern.neue"
+ }
+);
+
+const claims = await hybridVerify(
+ token,
+ {
+ ed25519PublicKey: keys.ed25519.publicKey,
+ mlDsaPublicKey: keys.mlDsa.publicKey
+ },
+ {
+ issuer: "kern.neue",
+ variant: "ML-DSA-65"
+ }
+);
+```
+
+Both signatures must verify. If either fails, `hybridVerify` throws `JwtError`.
+
+### Decode without verifying
+
+```ts
+import { decode } from "jsr:@netopwibby/pq-jwt";
+
+const { header, payload } = decode(token);
+```
+
+Use only for inspection. Never use the result for authorization.
+
+### Error handling
+
+```ts
+import { JwtError, verify } from "jsr:@netopwibby/pq-jwt";
+
+try {
+ await verify(token, publicKey);
+} catch (err) {
+ if (err instanceof JwtError) {
+ console.error(err.code, err.message);
+ // ERR_MALFORMED, ERR_TYPE, ERR_ALGORITHM,
+ // ERR_SIGNATURE, ERR_SIGNATURE_MLDSA, ERR_SIGNATURE_ED25519,
+ // ERR_EXPIRED, ERR_ISSUER, ERR_SUBJECT
+ }
+}
+```
+
+
+
+## API
+
+| Export | Description |
+| ------------------------------------------- | ------------------------------------------------ |
+| `generateKeyPair(variant?)` | Generate an ML-DSA keypair (default `ML-DSA-65`) |
+| `generateHybridKeyPair(variant?)` | Generate a hybrid ML-DSA + Ed25519 keypair |
+| `sign(payload, secretKey, options?)` | Sign a payload with ML-DSA |
+| `verify(token, publicKey, options?)` | Verify an ML-DSA JWT |
+| `hybridSign(payload, keyPair, options?)` | Sign with both ML-DSA and Ed25519 |
+| `hybridVerify(token, publicKeys, options?)` | Verify a hybrid JWT (both must pass) |
+| `decode(token)` | Decode header + payload without verifying |
+| `KEY_SIZES` | Raw byte sizes of keys per variant |
+| `JwtError` | Error thrown on any verification failure |
+
+
+
+## Key sizes (raw bytes)
+
+| Variant | ML-DSA public | ML-DSA secret | Ed25519 public | Ed25519 secret |
+| --------- | ------------- | ------------- | -------------- | -------------- |
+| ML-DSA-44 | 1312 | 2560 | 32 | 32 |
+| ML-DSA-65 | 1952 | 4032 | 32 | 32 |
+| ML-DSA-87 | 2592 | 4896 | 32 | 32 |
+
+ML-DSA signature lengths: `44 → 2420`, `65 → 3293`, `87 → 4595` bytes.
+
+
+
+## Hybrid signature layout
+
+```
+[0..3] uint32 big-endian ML-DSA signature length
+[4..4+mlDsaSigLen-1] ML-DSA signature bytes
+[4+mlDsaSigLen..] Ed25519 signature bytes (always 64)
+```
+
+The entire blob is Base64url-encoded as the JWT signature segment.
+
+
+
+## Run the example
+
+```sh
+deno run example.ts
+```
+
+
+
+## Security notes
+
+`@oqs/liboqs-js` is research/prototyping software and has not been formally audited. The hybrid mode provides defence-in-depth during the PQC transition. Follow [NIST PQC guidance](https://csrc.nist.gov/projects/post-quantum-cryptography) for production deployments.
+
+
+
+## License
+
+MIT
diff --git a/banner.png b/banner.png
new file mode 100644
index 0000000..62bbb9c
--- /dev/null
+++ b/banner.png
Binary files differ
diff --git a/deno.json b/deno.json
new file mode 100644
index 0000000..90a8761
--- /dev/null
+++ b/deno.json
@@ -0,0 +1,10 @@
+{
+ "exports": "./mod.ts",
+ "imports": {
+ "@noble/ed25519": "jsr:@noble/ed25519@^3.0.1",
+ "@oqs/liboqs-js": "npm:@oqs/liboqs-js@^0.15.1"
+ },
+ "license": "MIT",
+ "name": "@netopwibby/pq-jwt",
+ "version": "0.1.2"
+}
diff --git a/deno.lock b/deno.lock
new file mode 100644
index 0000000..f3dc4e4
--- /dev/null
+++ b/deno.lock
@@ -0,0 +1,24 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@noble/ed25519@^3.0.1": "3.0.1",
+ "npm:@oqs/liboqs-js@~0.15.1": "0.15.1"
+ },
+ "jsr": {
+ "@noble/ed25519@3.0.1": {
+ "integrity": "a347b5fbb73cdefdef2adb5d79d5f62805657304be315de498479d7af51617bc"
+ }
+ },
+ "npm": {
+ "@oqs/liboqs-js@0.15.1": {
+ "integrity": "sha512-nt1M2CuI4JC1FGoLeD0771C/BcisCuJqh8o/54dcUY0nU88Bf7Qv8vF05SA+wLAVf8UTuZD/l6FlS2LI8NjGbg==",
+ "bin": true
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@noble/ed25519@^3.0.1",
+ "npm:@oqs/liboqs-js@~0.15.1"
+ ]
+ }
+}
diff --git a/example.ts b/example.ts
new file mode 100644
index 0000000..5ddf8ea
--- /dev/null
+++ b/example.ts
@@ -0,0 +1,150 @@
+/**
+ * example.ts — Usage example for mod.ts
+ * Run with: deno run example.ts
+ */
+
+/*** UTILITY ------------------------------------------ ***/
+
+import {
+ decode,
+ generateHybridKeyPair,
+ generateKeyPair,
+ hybridSign,
+ hybridVerify,
+ JwtError,
+ KEY_SIZES,
+ sign,
+ verify
+} from "./mod.ts";
+
+/*** 1. ML-DSA only ----------------------------------- ***/
+
+console.log("\n/*** ML-DSA only -------------------------------------- ***/\n");
+
+const { publicKey, secretKey } = await generateKeyPair("ML-DSA-65");
+console.log("Public key length (chars):", publicKey.length, "\n");
+
+const token = await sign(
+ {
+ role: "member",
+ sub: "user_01jrwx8k9e4fv3z2"
+ },
+ secretKey,
+ {
+ algorithm: "ML-DSA-65",
+ expiresIn: 3600,
+ issuer: "kern.neue"
+ }
+);
+
+console.log("Token length (chars):", token.length, "\n");
+
+const { header, payload } = decode(token);
+
+console.log("Header:", header, "\n");
+console.log("Payload:", payload, "\n");
+
+try {
+ const claims = await verify(token, publicKey, {
+ algorithm: "ML-DSA-65",
+ issuer: "kern.neue"
+ });
+
+ console.log("✓ ML-DSA verification passed. sub:", claims.sub);
+} catch (err) {
+ if (err instanceof JwtError)
+ console.error("✗", err.message, err.code);
+}
+
+/*** 2. Hybrid ML-DSA-65 + Ed25519 -------------------- ***/
+
+console.log("\n/*** Hybrid ML-DSA-65 + Ed25519 ----------------------- ***/\n");
+
+const hybridKeys = await generateHybridKeyPair("ML-DSA-65");
+
+console.log("ML-DSA public key length (chars):", hybridKeys.mlDsa.publicKey.length, "\n");
+console.log("Ed25519 public key length (chars):", hybridKeys.ed25519.publicKey.length, "\n");
+
+const hybridToken = await hybridSign(
+ {
+ role: "admin",
+ sub: "user_01jrwx8k9e4fv3z2"
+ },
+ hybridKeys,
+ {
+ expiresIn: 3600,
+ issuer: "kern.neue"
+ }
+);
+
+console.log("Hybrid token length (chars):", hybridToken.length, "\n");
+console.log("Hybrid header:", decode(hybridToken).header, "\n");
+
+try {
+ const claims = await hybridVerify(
+ hybridToken,
+ {
+ ed25519PublicKey: hybridKeys.ed25519.publicKey,
+ mlDsaPublicKey: hybridKeys.mlDsa.publicKey
+ },
+ {
+ issuer: "kern.neue",
+ variant: "ML-DSA-65"
+ }
+ );
+
+ console.log("✓ Hybrid verification passed. sub:", claims.sub);
+} catch (err) {
+ if (err instanceof JwtError)
+ console.error("✗", err.message, err.code);
+}
+
+/*** 3. Tamper detection ------------------------------ ***/
+
+console.log("\n/*** Tamper detection --------------------------------- ***/\n");
+
+const tampered = hybridToken.slice(0, -10) + "AAAAAAAAAA";
+
+try {
+ await hybridVerify(tampered, {
+ ed25519PublicKey: hybridKeys.ed25519.publicKey,
+ mlDsaPublicKey: hybridKeys.mlDsa.publicKey
+ }, { variant: "ML-DSA-65" });
+} catch (err) {
+ if (err instanceof JwtError)
+ console.log("✓ Tampered token rejected:", err.message, `[${err.code}]`);
+}
+
+/*** 4. Wrong public key ------------------------------ ***/
+
+console.log("\n/*** Wrong public key --------------------------------- ***/\n");
+
+const otherKeys = await generateHybridKeyPair("ML-DSA-65");
+
+try {
+ await hybridVerify(
+ hybridToken,
+ {
+ ed25519PublicKey: otherKeys.ed25519.publicKey,
+ mlDsaPublicKey: otherKeys.mlDsa.publicKey
+ },
+ {
+ variant: "ML-DSA-65"
+ }
+ );
+} catch (err) {
+ if (err instanceof JwtError)
+ console.log("✓ Wrong key rejected:", err.message, `[${err.code}]`);
+}
+
+/*** 5. Key sizes (raw bytes) ------------------------- ***/
+
+console.log("\n/*** Key sizes (raw bytes) ---------------------------- ***/\n");
+
+for (const [variant, sizes] of Object.entries(KEY_SIZES)) {
+ console.log(`${variant}`);
+ console.log(" ML-DSA public key: ", sizes.mlDsaPublicKey, "bytes");
+ console.log(" ML-DSA secret key: ", sizes.mlDsaSecretKey, "bytes");
+ console.log(" Ed25519 public key:", sizes.ed25519PublicKey, "bytes");
+ console.log(" Ed25519 secret key:", sizes.ed25519SecretKey, "bytes");
+}
diff --git a/mod.ts b/mod.ts
new file mode 100644
index 0000000..5ca7724
--- /dev/null
+++ b/mod.ts
@@ -0,0 +1,640 @@
+/**
+ * mod.ts — Post-quantum JWT module for Deno
+ *
+ * Two signing modes:
+ *
+ * 1. ML-DSA only — Uses ML-DSA (FIPS 204) via @oqs/liboqs-js.
+ * alg header: "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
+ *
+ * 2. Hybrid — ML-DSA + Ed25519 signed in parallel over the same
+ * signing input. Both signatures are concatenated into a single Base64url
+ * blob. BOTH must pass verification. If either algorithm is later broken
+ * (classical or quantum), the other still protects the token.
+ * alg header: "ML-DSA-44+Ed25519" | "ML-DSA-65+Ed25519" | "ML-DSA-87+Ed25519"
+ *
+ * Hybrid signature layout (raw bytes, fixed split at verification time):
+ * [ ML-DSA signature (variant-specific length) | Ed25519 signature (64 bytes) ]
+ *
+ * ML-DSA signature lengths (bytes):
+ * ML-DSA-44 → 2420
+ * ML-DSA-65 → 3293
+ * ML-DSA-87 → 4595
+ *
+ * ⚠️ @oqs/liboqs-js is research/prototyping software and has not been formally
+ * audited. The hybrid mode provides defence-in-depth during the transition
+ * period. Follow NIST PQC guidance for production deployments.
+ */
+
+/*** IMPORT ------------------------------------------- ***/
+
+import * as ed from "@noble/ed25519";
+import { createMLDSA44, createMLDSA65, createMLDSA87 } from "@oqs/liboqs-js/sig";
+
+/*** UTILITY ------------------------------------------ ***/
+
+/** Fixed byte length of an Ed25519 signature (RFC 8032, §5.1.6). */
+const ED25519_SIG_LENGTH = 64;
+
+/** Fixed byte length of an Ed25519 public key (RFC 8032, §5.1.5). */
+const ED25519_PK_LENGTH = 32;
+
+/** Fixed byte length of an Ed25519 secret key seed (RFC 8032, §5.1.5). */
+const ED25519_SK_LENGTH = 32;
+
+/**
+ * Byte length of the uint32 big-endian length prefix stored at the start of
+ * every hybrid signature blob. Encodes the ML-DSA signature length so the
+ * verifier can split the blob without relying on hardcoded constants.
+ *
+ * Hybrid signature layout:
+ * [0..3] — mlDsaSigLen as uint32 big-endian
+ * [4..4+mlDsaSigLen-1] — ML-DSA signature bytes
+ * [4+mlDsaSigLen..] — Ed25519 signature bytes (always 64)
+ */
+const HYBRID_PREFIX_LENGTH = 4;
+
+/*** EXPORT ------------------------------------------- ***/
+
+/**
+ * Approximate sizes of hybrid keypair components, for planning purposes.
+ * Ed25519 keys are always 32 bytes each.
+ * Sizes are in bytes (raw, before Base64url encoding).
+ */
+export const KEY_SIZES: Record<MlDsaVariant, { ed25519SecretKey: number; ed25519PublicKey: number; mlDsaPublicKey: number; mlDsaSecretKey: number; }> = {
+ "ML-DSA-44": {
+ ed25519PublicKey: ED25519_PK_LENGTH,
+ ed25519SecretKey: ED25519_SK_LENGTH,
+ mlDsaPublicKey: 1312,
+ mlDsaSecretKey: 2560
+ },
+ "ML-DSA-65": {
+ ed25519PublicKey: ED25519_PK_LENGTH,
+ ed25519SecretKey: ED25519_SK_LENGTH,
+ mlDsaPublicKey: 1952,
+ mlDsaSecretKey: 4032
+ },
+ "ML-DSA-87": {
+ ed25519PublicKey: ED25519_PK_LENGTH,
+ ed25519SecretKey: ED25519_SK_LENGTH,
+ mlDsaPublicKey: 2592,
+ mlDsaSecretKey: 4896
+ }
+};
+
+/**
+ * Supported ML-DSA parameter sets (FIPS 204).
+ * Higher numbers mean larger keys/signatures and stronger security margins:
+ * - ML-DSA-44 → NIST security level 2 (≈AES-128)
+ * - ML-DSA-65 → NIST security level 3 (≈AES-192) — recommended default
+ * - ML-DSA-87 → NIST security level 5 (≈AES-256)
+ */
+export type MlDsaVariant = "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87";
+
+/**
+ * Hybrid algorithm identifiers placed in the JWT `alg` header.
+ * Each variant pairs an ML-DSA parameter set with Ed25519 for
+ * classical + post-quantum defence-in-depth.
+ */
+export type HybridVariant =
+ | "ML-DSA-44+Ed25519"
+ | "ML-DSA-65+Ed25519"
+ | "ML-DSA-87+Ed25519";
+
+/**
+ * Union of every algorithm identifier this module may place in the
+ * JWT `alg` header — ML-DSA only or hybrid ML-DSA + Ed25519.
+ */
+export type Algorithm = MlDsaVariant | HybridVariant;
+
+/**
+ * JOSE header emitted for every token produced by this module.
+ * `typ` is always `"JWT"`; `alg` identifies the signing scheme.
+ */
+export interface JwtHeader {
+ alg: Algorithm;
+ typ: "JWT";
+}
+
+/**
+ * ML-DSA-only keypair returned by {@link generateKeyPair}.
+ * Store `secretKey` securely; `publicKey` is safe to distribute.
+ */
+export interface KeyPair {
+ /** Base64url-encoded ML-DSA public key. */
+ publicKey: string;
+ /** Base64url-encoded ML-DSA secret key. */
+ secretKey: string;
+}
+
+/**
+ * Hybrid keypair containing both ML-DSA and Ed25519 keys.
+ * Store all secret keys securely. Distribute all public keys to verifiers.
+ */
+export interface HybridKeyPair {
+ /** Ed25519 keypair, both values Base64url-encoded. */
+ ed25519: {
+ /** Base64url-encoded Ed25519 public key (32 bytes raw). */
+ publicKey: string;
+ /** Base64url-encoded Ed25519 secret key seed (32 bytes raw). */
+ secretKey: string;
+ };
+ /** ML-DSA keypair, both values Base64url-encoded. */
+ mlDsa: {
+ /** Base64url-encoded ML-DSA public key. */
+ publicKey: string;
+ /** Base64url-encoded ML-DSA secret key. */
+ secretKey: string;
+ };
+ /** ML-DSA parameter set this keypair was generated for. */
+ variant: MlDsaVariant;
+}
+
+/**
+ * Public half of a {@link HybridKeyPair}, suitable for distribution
+ * to verifiers. Both keys are required to verify a hybrid token.
+ */
+export interface HybridPublicKeys {
+ /** Base64url-encoded Ed25519 public key. */
+ ed25519PublicKey: string;
+ /** Base64url-encoded ML-DSA public key. */
+ mlDsaPublicKey: string;
+}
+
+/**
+ * Decoded JWT claims set. Standard registered claims (`exp`, `iat`,
+ * `iss`, `sub`) are typed explicitly; arbitrary additional claims
+ * are permitted via the index signature.
+ */
+export interface JwtPayload {
+ [key: string]: unknown;
+ /** Expiration time as a Unix timestamp in seconds (RFC 7519 §4.1.4). */
+ exp?: number;
+ /** Issued-at time as a Unix timestamp in seconds (RFC 7519 §4.1.6). */
+ iat?: number;
+ /** Issuer claim (RFC 7519 §4.1.1). */
+ iss?: string;
+ /** Subject claim (RFC 7519 §4.1.2). */
+ sub?: string;
+}
+
+/**
+ * Options accepted by {@link sign} when producing an ML-DSA-only token.
+ */
+export interface SignOptions {
+ /** ML-DSA parameter set to use. Defaults to `"ML-DSA-65"`. */
+ algorithm?: MlDsaVariant;
+ /** Lifetime in seconds from now. If set, adds an `exp` claim. */
+ expiresIn?: number;
+ /** Value for the `iss` claim, if any. */
+ issuer?: string;
+ /** Value for the `sub` claim, if any. */
+ subject?: string;
+}
+
+/**
+ * Options accepted by {@link hybridSign} when producing a hybrid
+ * ML-DSA + Ed25519 token.
+ */
+export interface HybridSignOptions {
+ /** Lifetime in seconds from now. If set, adds an `exp` claim. */
+ expiresIn?: number;
+ /** Value for the `iss` claim, if any. */
+ issuer?: string;
+ /** Value for the `sub` claim, if any. */
+ subject?: string;
+ /**
+ * Override the ML-DSA parameter set. Defaults to the `variant`
+ * stored on the supplied {@link HybridKeyPair}.
+ */
+ variant?: MlDsaVariant;
+}
+
+/**
+ * Options accepted by {@link verify} when validating an ML-DSA-only token.
+ */
+export interface VerifyOptions {
+ /** Expected ML-DSA parameter set. Defaults to `"ML-DSA-65"`. */
+ algorithm?: MlDsaVariant;
+ /** Require the `iss` claim to equal this value. */
+ issuer?: string;
+ /** Require the `sub` claim to equal this value. */
+ subject?: string;
+}
+
+/**
+ * Options accepted by {@link hybridVerify} when validating a hybrid
+ * ML-DSA + Ed25519 token.
+ */
+export interface HybridVerifyOptions {
+ /** Require the `iss` claim to equal this value. */
+ issuer?: string;
+ /** Require the `sub` claim to equal this value. */
+ subject?: string;
+ /** Expected ML-DSA parameter set. Defaults to `"ML-DSA-65"`. */
+ variant?: MlDsaVariant;
+}
+
+/**
+ * Error raised for any failure during signing, decoding, or verification.
+ * The `code` field carries a stable machine-readable identifier so callers
+ * can branch on failure modes without parsing error messages.
+ *
+ * Known codes:
+ * - `ERR_MALFORMED` — token structure or encoding is invalid
+ * - `ERR_TYPE` — header `typ` is not `"JWT"`
+ * - `ERR_ALGORITHM` — header `alg` does not match expectations
+ * - `ERR_SIGNATURE` — ML-DSA-only signature check failed
+ * - `ERR_SIGNATURE_MLDSA` — hybrid ML-DSA signature check failed
+ * - `ERR_SIGNATURE_ED25519` — hybrid Ed25519 signature check failed
+ * - `ERR_EXPIRED` — `exp` claim is in the past
+ * - `ERR_ISSUER` — `iss` claim did not match the expected issuer
+ * - `ERR_SUBJECT` — `sub` claim did not match the expected subject
+ */
+export class JwtError extends Error {
+ constructor(message: string, public readonly code: string) {
+ super(message);
+ this.name = "JwtError";
+ }
+}
+
+/**
+ * Decode a JWT without verifying the signature.
+ * Useful for inspecting claims before committing to verification.
+ * Never trust the output of this for authorization decisions.
+ */
+export function decode(token: string): { header: JwtHeader; payload: JwtPayload; } {
+ const parts = token.split(".");
+
+ if (parts.length !== 3)
+ throw new JwtError("Malformed token: expected 3 parts", "ERR_MALFORMED");
+
+ return {
+ header: JSON.parse(base64urlDecodeString(parts[0])),
+ payload: JSON.parse(base64urlDecodeString(parts[1]))
+ };
+}
+
+/**
+ * Generate a hybrid ML-DSA + Ed25519 keypair.
+ * Store all secret keys securely. Distribute all public keys to verifiers.
+ */
+export async function generateHybridKeyPair(variant: MlDsaVariant = "ML-DSA-65"): Promise<HybridKeyPair> {
+ const instance = await mlDsaFactory(variant);
+ const mlDsaKeys = instance.generateKeyPair();
+ const edKeys = await ed.keygenAsync();
+
+ return {
+ ed25519: {
+ publicKey: base64urlEncode(edKeys.publicKey),
+ secretKey: base64urlEncode(edKeys.secretKey)
+ },
+ mlDsa: {
+ publicKey: base64urlEncode(mlDsaKeys.publicKey),
+ secretKey: base64urlEncode(mlDsaKeys.secretKey)
+ },
+ variant
+ };
+}
+
+/**
+ * Generate an ML-DSA keypair.
+ * Store secretKey securely. publicKey is safe to distribute to verifiers.
+ */
+export async function generateKeyPair(algorithm: MlDsaVariant = "ML-DSA-65"): Promise<KeyPair> {
+ const instance = await mlDsaFactory(algorithm);
+ const { publicKey, secretKey } = instance.generateKeyPair();
+
+ return {
+ publicKey: base64urlEncode(publicKey),
+ secretKey: base64urlEncode(secretKey)
+ };
+}
+
+/**
+ * Sign a payload with both ML-DSA and Ed25519.
+ * The signature blob is: ML-DSA sig || Ed25519 sig (64 bytes).
+ * Both signatures are computed independently over the same signing input.
+ * A verifier must hold both public keys and both must pass.
+ *
+ * @param payload - Claims to encode in the token
+ * @param keyPair - Hybrid keypair from generateHybridKeyPair()
+ * @param options - Signing options
+ */
+export async function hybridSign(payload: JwtPayload, keyPair: HybridKeyPair, options: HybridSignOptions = {}): Promise<string> {
+ const { expiresIn, issuer, subject, variant = keyPair.variant } = options;
+ const alg: HybridVariant = `${variant}+Ed25519`;
+ const claims = buildClaims(payload, expiresIn, issuer, subject);
+ const header: JwtHeader = { alg, typ: "JWT" };
+ const headerB64 = base64urlEncodeString(JSON.stringify(header));
+ const payloadB64 = base64urlEncodeString(JSON.stringify(claims));
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const signingBytes = new TextEncoder().encode(signingInput);
+ const instance = await mlDsaFactory(variant);
+ const mlDsaSig = instance.sign(signingBytes, base64urlDecode(keyPair.mlDsa.secretKey));
+ const edSig = await ed.signAsync(signingBytes, base64urlDecode(keyPair.ed25519.secretKey));
+
+ /*** Encode: [uint32 BE mlDsaSig length][mlDsaSig][edSig] ***/
+ const prefix = new Uint8Array(HYBRID_PREFIX_LENGTH);
+ new DataView(prefix.buffer).setUint32(0, mlDsaSig.length, false);
+ const combined = concatBytes(prefix, mlDsaSig, edSig);
+
+ return `${signingInput}.${base64urlEncode(combined)}`;
+}
+
+/**
+ * Verify a hybrid JWT. BOTH ML-DSA and Ed25519 signatures must pass.
+ * If either fails, verification is rejected — this is intentional.
+ *
+ * @param token - Compact hybrid JWT string
+ * @param publicKeys - Both public keys from the issuing HybridKeyPair
+ * @param options - Verification options
+ */
+export async function hybridVerify(token: string, publicKeys: HybridPublicKeys, options: HybridVerifyOptions = {}): Promise<JwtPayload> {
+ const { issuer, subject, variant = "ML-DSA-65" } = options;
+ const parts = token.split(".");
+
+ if (parts.length !== 3)
+ throw new JwtError("Malformed token: expected 3 parts", "ERR_MALFORMED");
+
+ const [headerB64, payloadB64, signatureB64] = parts;
+ let header: JwtHeader;
+
+ try {
+ header = JSON.parse(base64urlDecodeString(headerB64));
+ } catch {
+ throw new JwtError("Malformed token: invalid header", "ERR_MALFORMED");
+ }
+
+ if (header.typ !== "JWT")
+ throw new JwtError(`Invalid token type: ${header.typ}`, "ERR_TYPE");
+
+ const expectedAlg: HybridVariant = `${variant}+Ed25519`;
+
+ if (header.alg !== expectedAlg) {
+ throw new JwtError(
+ `Algorithm mismatch: token uses ${header.alg}, expected ${expectedAlg}`,
+ "ERR_ALGORITHM"
+ );
+ }
+
+ /*** Read the length-prefixed hybrid blob: [uint32 BE len][mlDsaSig][edSig] ***/
+ const combinedBytes = base64urlDecode(signatureB64);
+
+ if (combinedBytes.length < HYBRID_PREFIX_LENGTH + ED25519_SIG_LENGTH)
+ throw new JwtError("Malformed hybrid signature: blob too short", "ERR_MALFORMED");
+
+ const mlDsaSigLength = new DataView(combinedBytes.buffer).getUint32(0, false);
+ const expectedLength = HYBRID_PREFIX_LENGTH + mlDsaSigLength + ED25519_SIG_LENGTH;
+
+ if (combinedBytes.length !== expectedLength) {
+ throw new JwtError(
+ `Malformed hybrid signature: expected ${expectedLength} bytes, got ${combinedBytes.length}`,
+ "ERR_MALFORMED"
+ );
+ }
+
+ const mlDsaSigBytes = combinedBytes.slice(HYBRID_PREFIX_LENGTH, HYBRID_PREFIX_LENGTH + mlDsaSigLength);
+ const edSigBytes = combinedBytes.slice(HYBRID_PREFIX_LENGTH + mlDsaSigLength);
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const signingBytes = new TextEncoder().encode(signingInput);
+ const instance = await mlDsaFactory(variant);
+
+ const mlDsaValid = instance.verify(
+ signingBytes,
+ mlDsaSigBytes,
+ base64urlDecode(publicKeys.mlDsaPublicKey)
+ );
+
+ const edValid = await ed.verifyAsync(
+ edSigBytes,
+ signingBytes,
+ base64urlDecode(publicKeys.ed25519PublicKey)
+ );
+
+ if (!mlDsaValid)
+ throw new JwtError("ML-DSA signature verification failed", "ERR_SIGNATURE_MLDSA");
+
+ if (!edValid)
+ throw new JwtError("Ed25519 signature verification failed", "ERR_SIGNATURE_ED25519");
+
+ const payload = parsePayload(payloadB64);
+ validateClaims(payload, issuer, subject);
+
+ return payload;
+}
+
+/**
+ * Sign a payload and return a compact JWT string using ML-DSA only.
+ *
+ * @param payload - Claims to encode in the token
+ * @param secretKey - Base64url-encoded ML-DSA secret key
+ * @param options - Signing options
+ */
+export async function sign(payload: JwtPayload, secretKey: string, options: SignOptions = {}): Promise<string> {
+ const { algorithm = "ML-DSA-65", expiresIn, issuer, subject } = options;
+ const claims = buildClaims(payload, expiresIn, issuer, subject);
+ const header: JwtHeader = { alg: algorithm, typ: "JWT" };
+ const headerB64 = base64urlEncodeString(JSON.stringify(header));
+ const payloadB64 = base64urlEncodeString(JSON.stringify(claims));
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const signingBytes = new TextEncoder().encode(signingInput);
+ const instance = await mlDsaFactory(algorithm);
+ const signature = instance.sign(signingBytes, base64urlDecode(secretKey));
+
+ return `${signingInput}.${base64urlEncode(signature)}`;
+}
+
+/**
+ * Verify a compact JWT string and return the decoded payload.
+ * Throws JwtError on any failure (invalid structure, bad signature, expiry).
+ *
+ * @param token - Compact JWT string
+ * @param publicKey - Base64url-encoded ML-DSA public key
+ * @param options - Verification options
+ */
+export async function verify(token: string, publicKey: string, options: VerifyOptions = {}): Promise<JwtPayload> {
+ const { algorithm = "ML-DSA-65", issuer, subject } = options;
+ const parts = token.split(".");
+
+ if (parts.length !== 3)
+ throw new JwtError("Malformed token: expected 3 parts", "ERR_MALFORMED");
+
+ const [headerB64, payloadB64, signatureB64] = parts;
+ let header: JwtHeader;
+
+ try {
+ header = JSON.parse(base64urlDecodeString(headerB64));
+ } catch {
+ throw new JwtError("Malformed token: invalid header", "ERR_MALFORMED");
+ }
+
+ if (header.typ !== "JWT")
+ throw new JwtError(`Invalid token type: ${header.typ}`, "ERR_TYPE");
+
+ if (header.alg !== algorithm) {
+ throw new JwtError(
+ `Algorithm mismatch: token uses ${header.alg}, expected ${algorithm}`,
+ "ERR_ALGORITHM"
+ );
+ }
+
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const signingBytes = new TextEncoder().encode(signingInput);
+ const instance = await mlDsaFactory(algorithm);
+
+ const valid = instance.verify(
+ signingBytes,
+ base64urlDecode(signatureB64),
+ base64urlDecode(publicKey)
+ );
+
+ if (!valid)
+ throw new JwtError("Signature verification failed", "ERR_SIGNATURE");
+
+ const payload = parsePayload(payloadB64);
+ validateClaims(payload, issuer, subject);
+
+ return payload;
+}
+
+/*** HELPER ------------------------------------------- ***/
+
+/**
+ * Decode a Base64url string (RFC 4648 §5, no padding) into raw bytes.
+ * Re-pads and translates the URL-safe alphabet back to standard Base64
+ * before delegating to {@link atob}.
+ */
+function base64urlDecode(str: string): Uint8Array {
+ const padded = str
+ .replaceAll("-", "+")
+ .replaceAll("_", "/")
+ .padEnd(str.length + (4 - (str.length % 4)) % 4, "=");
+
+ const binary = atob(padded);
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
+}
+
+/**
+ * Decode a Base64url string into a UTF-8 string. Convenience wrapper
+ * around {@link base64urlDecode} used for JSON header/payload segments.
+ */
+function base64urlDecodeString(str: string): string {
+ return new TextDecoder().decode(base64urlDecode(str));
+}
+
+/**
+ * Encode raw bytes as a Base64url string (RFC 4648 §5, no padding).
+ */
+function base64urlEncode(bytes: Uint8Array): string {
+ const b64 = btoa(String.fromCharCode(...bytes));
+ return b64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
+}
+
+/**
+ * Encode a UTF-8 string as Base64url. Convenience wrapper around
+ * {@link base64urlEncode} used for JSON header/payload segments.
+ */
+function base64urlEncodeString(str: string): string {
+ return base64urlEncode(new TextEncoder().encode(str));
+}
+
+/**
+ * Merge user-supplied claims with standard registered claims.
+ * Always injects `iat` (issued-at) using the current wall clock, and
+ * conditionally adds `exp`, `iss`, and `sub` when the corresponding
+ * options are provided. User-supplied values in `payload` are
+ * overwritten by `iat` but preserved otherwise.
+ */
+function buildClaims(payload: JwtPayload, expiresIn?: number, issuer?: string, subject?: string): JwtPayload {
+ const now = Math.floor(Date.now() / 1000);
+
+ return {
+ ...payload,
+ iat: now,
+ ...(expiresIn !== undefined ? { exp: now + expiresIn } : {}),
+ ...(issuer !== undefined ? { iss: issuer } : {}),
+ ...(subject !== undefined ? { sub: subject } : {})
+ };
+}
+
+/**
+ * Concatenate any number of `Uint8Array`s into a single buffer.
+ * Used to assemble the hybrid signature blob.
+ */
+function concatBytes(...arrays: Uint8Array[]): Uint8Array {
+ const total = arrays.reduce((n, a) => n + a.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+
+ for (const arr of arrays) {
+ out.set(arr, offset);
+ offset += arr.length;
+ }
+
+ return out;
+}
+
+/**
+ * Return an initialised ML-DSA instance for the requested variant.
+ * Each call constructs a fresh WebAssembly-backed instance from
+ * `@oqs/liboqs-js`; callers are expected to obtain and discard these
+ * per operation rather than cache them.
+ */
+async function mlDsaFactory(variant: MlDsaVariant): Promise<Awaited<ReturnType<typeof createMLDSA65>>> {
+ switch (variant) {
+ case "ML-DSA-44": {
+ return await createMLDSA44();
+ }
+
+ case "ML-DSA-65": {
+ return await createMLDSA65();
+ }
+
+ case "ML-DSA-87": {
+ return await createMLDSA87();
+ }
+ }
+}
+
+/**
+ * Decode and JSON-parse the payload segment of a JWT, raising a
+ * {@link JwtError} with code `ERR_MALFORMED` on any failure.
+ */
+function parsePayload(payloadB64: string): JwtPayload {
+ try {
+ return JSON.parse(base64urlDecodeString(payloadB64));
+ } catch {
+ throw new JwtError("Malformed token: invalid payload", "ERR_MALFORMED");
+ }
+}
+
+/**
+ * Enforce registered-claim constraints after a signature has verified.
+ * Checks `exp` against the current time and, when supplied, that
+ * `iss` and `sub` match the expected values. Throws a {@link JwtError}
+ * with an appropriate code on any mismatch.
+ */
+function validateClaims(payload: JwtPayload, issuer?: string, subject?: string): void {
+ const now = Math.floor(Date.now() / 1000);
+
+ if (payload.exp !== undefined && payload.exp < now) {
+ throw new JwtError(
+ `Token expired at ${new Date(payload.exp * 1000).toISOString()}`,
+ "ERR_EXPIRED"
+ );
+ }
+
+ if (issuer !== undefined && payload.iss !== issuer) {
+ throw new JwtError(
+ `Issuer mismatch: expected "${issuer}", got "${payload.iss}"`,
+ "ERR_ISSUER"
+ );
+ }
+
+ if (subject !== undefined && payload.sub !== subject) {
+ throw new JwtError(
+ `Subject mismatch: expected "${subject}", got "${payload.sub}"`,
+ "ERR_SUBJECT"
+ );
+ }
+}