Skip to content

Commit

Permalink
feat/support Ed25519 and Ed448
Browse files Browse the repository at this point in the history
  • Loading branch information
ottokruse committed Jan 2, 2025
1 parent 1fab445 commit 2831807
Show file tree
Hide file tree
Showing 10 changed files with 245 additions and 75 deletions.
29 changes: 27 additions & 2 deletions src/jwk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ type EcSignatureJwk = Jwk & {
y: string;
};

export type SignatureJwk = RsaSignatureJwk | EcSignatureJwk;
type OkpSignatureJwk = Jwk & {
use?: "sig";
kty: "OKP";
crv: "Ed25519" | "Ed448";
x: string;
};

export type SignatureJwk = RsaSignatureJwk | EcSignatureJwk | OkpSignatureJwk;

export type JwkWithKid = Jwk & {
kid: string;
Expand Down Expand Up @@ -166,14 +173,32 @@ export function assertIsSignatureJwk(jwk: Jwk): asserts jwk is SignatureJwk {
assertStringArrayContainsString(
"JWK kty",
jwk.kty,
["EC", "RSA"],
["EC", "RSA", "OKP"],
JwkInvalidKtyError
);
if (jwk.kty === "EC") {
assertIsEsSignatureJwk(jwk);
} else if (jwk.kty === "RSA") {
assertIsRsaSignatureJwk(jwk);
} else if (jwk.kty === "OKP") {
assertIsEdDSASignatureJwk(jwk);
}
}

function assertIsEdDSASignatureJwk(jwk: Jwk): asserts jwk is OkpSignatureJwk {
// Check JWK use
if (jwk.use) {
assertStringEquals("JWK use", jwk.use, "sig", JwkInvalidUseError);
}

// Check JWK kty
assertStringEquals("JWK kty", jwk.kty, "OKP", JwkInvalidKtyError);

// Check Curve (crv) has a value
if (!jwk.crv) throw new JwkValidationError("Missing Curve (crv)");

// Check X Coordinate (x) has a value
if (!jwk.x) throw new JwkValidationError("Missing X Coordinate (x)");
}

function assertIsEsSignatureJwk(jwk: Jwk): asserts jwk is EcSignatureJwk {
Expand Down
1 change: 1 addition & 0 deletions src/jwt-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const supportedSignatureAlgorithms = [
"ES256",
"ES384",
"ES512",
"EdDSA",
] as const;
export type SupportedSignatureAlgorithm =
(typeof supportedSignatureAlgorithms)[number];
Expand Down
57 changes: 24 additions & 33 deletions src/node-web-compat-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
//
// Node.js implementations for the node-web-compatibility layer

import { createPublicKey, createVerify, KeyObject } from "crypto";
import { createPublicKey, createVerify, KeyObject, verify } from "crypto";
import { SignatureJwk } from "./jwk.js";
import { fetch } from "./https-node.js";
import { NodeWebCompat } from "./node-web-compat.js";

/**
* Enum to map supported JWT signature algorithms with OpenSSL message digest algorithm names
*/
enum JwtSignatureAlgorithms {
enum JwtSignatureAlgorithmHashNames {
RS256 = "RSA-SHA256",
RS384 = "RSA-SHA384",
RS512 = "RSA-SHA512",
ES256 = RS256, // yes, openssl uses the same algorithm name
ES384 = RS384, // yes, openssl uses the same algorithm name
ES512 = RS512, // yes, openssl uses the same algorithm name
ES256 = RS256,
ES384 = RS384,
ES512 = RS512,
}

export const nodeWebCompat: NodeWebCompat = {
Expand All @@ -35,34 +35,25 @@ export const nodeWebCompat: NodeWebCompat = {
parseB64UrlString: (b64: string): string =>
Buffer.from(b64, "base64").toString("utf8"),
verifySignatureSync: ({ alg, keyObject, jwsSigningInput, signature }) =>
// eslint-disable-next-line security/detect-object-injection
createVerify(JwtSignatureAlgorithms[alg])
.update(jwsSigningInput)
.verify(
{
key: keyObject as KeyObject,
dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA)
},
signature,
"base64"
),
verifySignatureAsync: async ({
alg,
keyObject,
jwsSigningInput,
signature,
}) =>
// eslint-disable-next-line security/detect-object-injection
createVerify(JwtSignatureAlgorithms[alg])
.update(jwsSigningInput)
.verify(
{
key: keyObject as KeyObject,
dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA)
},
signature,
"base64"
),
alg !== "EdDSA"
? // eslint-disable-next-line security/detect-object-injection
createVerify(JwtSignatureAlgorithmHashNames[alg])
.update(jwsSigningInput)
.verify(
{
key: keyObject as KeyObject,
dsaEncoding: "ieee-p1363", // Signature format r || s (not used for RSA)
},
signature,
"base64"
)
: verify(
null,
Buffer.from(jwsSigningInput),
keyObject as KeyObject,
Buffer.from(signature, "base64")
),
verifySignatureAsync: async (args) => nodeWebCompat.verifySignatureSync(args),
defaultFetchTimeouts: {
socketIdle: 1500,
response: 3000,
Expand Down
41 changes: 24 additions & 17 deletions src/node-web-compat-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//
// Web implementations for the node-web-compatibility layer

import { SignatureJwk } from "jwk.js";
import {
FetchError,
NotSupportedError,
Expand All @@ -20,6 +21,11 @@ enum NamedCurvesWebCrypto {
ES512 = "P-521", // yes, 521
}

interface CryptoKeyWithJwk {
key: CryptoKey;
jwk: SignatureJwk;
}

export const nodeWebCompat: NodeWebCompat = {
fetch: async (
uri: string,
Expand Down Expand Up @@ -72,23 +78,22 @@ export const nodeWebCompat: NodeWebCompat = {
alg
);
}
return crypto.subtle.importKey(
"jwk",
jwk,
alg.startsWith("RS")
const algIdentifier = alg.startsWith("RS")
? {
name: "RSASSA-PKCS1-v1_5",
hash: `SHA-${alg.slice(2)}`,
}
: alg.startsWith("ES")
? {
name: "RSASSA-PKCS1-v1_5",
hash: `SHA-${alg.slice(2)}`,
}
: {
name: "ECDSA",
// eslint-disable-next-line security/detect-object-injection
namedCurve:
NamedCurvesWebCrypto[alg as keyof typeof NamedCurvesWebCrypto],
},
false,
["verify"]
);
}
: jwk.crv!; // Ed25519 or Ed448
return crypto.subtle
.importKey("jwk", jwk, algIdentifier, false, ["verify"])
.then((key) => ({ key, jwk }));
},
verifySignatureSync: () => {
throw new NotSupportedError(
Expand All @@ -101,11 +106,13 @@ export const nodeWebCompat: NodeWebCompat = {
? {
name: "RSASSA-PKCS1-v1_5",
}
: {
name: "ECDSA",
hash: `SHA-${alg.slice(2)}`,
},
keyObject as CryptoKey,
: alg.startsWith("ES")
? {
name: "ECDSA",
hash: `SHA-${alg.slice(2)}`,
}
: { name: (keyObject as CryptoKeyWithJwk).jwk.crv! },
(keyObject as CryptoKeyWithJwk).key,
bufferFromBase64url(signature),
new TextEncoder().encode(jwsSigningInput)
),
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/jwt-verifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,40 @@ describe("unit tests jwt verifier", () => {
verifyJwtSync(signedJwt, es512keypair.jwk, { issuer, audience })
).toMatchObject({ hello: "world" });
});
test("happy flow with jwk - Ed25519", () => {
const ed25519keypair = generateKeyPair({
kty: "OKP",
alg: "EdDSA",
crv: "Ed25519",
});
const issuer = "https://example.com";
const audience = "1234";
const signedJwt = signJwt(
{ alg: "EdDSA", kid: keypair.jwk.kid },
{ aud: audience, iss: issuer, hello: "world" },
ed25519keypair.privateKey
);
expect(
verifyJwtSync(signedJwt, ed25519keypair.jwk, { issuer, audience })
).toMatchObject({ hello: "world" });
});
test("happy flow with jwk - Ed448", () => {
const ed448keypair = generateKeyPair({
kty: "OKP",
alg: "EdDSA",
crv: "Ed448",
});
const issuer = "https://example.com";
const audience = "1234";
const signedJwt = signJwt(
{ alg: "EdDSA", kid: keypair.jwk.kid },
{ aud: audience, iss: issuer, hello: "world" },
ed448keypair.privateKey
);
expect(
verifyJwtSync(signedJwt, ed448keypair.jwk, { issuer, audience })
).toMatchObject({ hello: "world" });
});
test("happy flow with jwk without alg", () => {
const issuer = "https://example.com";
const audience = "1234";
Expand Down Expand Up @@ -999,6 +1033,22 @@ describe("unit tests jwt verifier", () => {
expect(statement).toThrow("Missing Curve (crv)");
expect(statement).toThrow(JwkValidationError);
});
test("missing crv on JWK - EdDSA", () => {
const { jwk, privateKey } = generateKeyPair({
kty: "OKP",
alg: "EdDSA",
crv: "Ed25519",
});
delete jwk.crv;
const signedJwt = signJwt({ alg: "EdDSA" }, {}, privateKey);
const statement = () =>
verifyJwtSync(signedJwt, jwk, {
audience: null,
issuer: null,
});
expect(statement).toThrow("Missing Curve (crv)");
expect(statement).toThrow(JwkValidationError);
});
test("missing x on JWK", () => {
const { jwk, privateKey } = generateKeyPair({
kty: "EC",
Expand All @@ -1014,6 +1064,22 @@ describe("unit tests jwt verifier", () => {
expect(statement).toThrow("Missing X Coordinate (x)");
expect(statement).toThrow(JwkValidationError);
});
test("missing x on JWK - EdDSA", () => {
const { jwk, privateKey } = generateKeyPair({
kty: "OKP",
alg: "EdDSA",
crv: "Ed448",
});
delete jwk.x;
const signedJwt = signJwt({ alg: "EdDSA" }, {}, privateKey);
const statement = () =>
verifyJwtSync(signedJwt, jwk, {
audience: null,
issuer: null,
});
expect(statement).toThrow("Missing X Coordinate (x)");
expect(statement).toThrow(JwkValidationError);
});
test("missing y on JWK", () => {
const { jwk, privateKey } = generateKeyPair({
kty: "EC",
Expand Down
Loading

0 comments on commit 2831807

Please sign in to comment.