From f39537ec295bd903cd4f57bd085185b6ca6ba904 Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Sat, 11 Apr 2026 14:55:29 -0700 Subject: initial commit --- README.md | 183 ++++++++++++++++++ banner.png | Bin 0 -> 126470 bytes deno.json | 10 + deno.lock | 24 +++ example.ts | 150 +++++++++++++++ mod.ts | 640 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1007 insertions(+) create mode 100644 README.md create mode 100644 banner.png create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 example.ts create mode 100644 mod.ts 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 Binary files /dev/null and b/banner.png 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 = { + "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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>> { + 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" + ); + } +} -- cgit v1.2.3