@carecard/auth-util 3.25.0 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,10 +103,11 @@ depend on those folders being present.
103
103
  - `index.d.ts` must stay aligned with every public export in `index.js`.
104
104
  - `lib/jwtUtilAuth.js` owns JWT creation, service-to-service JWT creation,
105
105
  verification, and decomposition.
106
- - `lib/pwdUtilAuth.js` owns password hashing and saved-hash verification.
106
+ - `lib/jwkUtilAuth.js` owns strict signing-JWK and verification-JWKS parsing.
107
+ - `lib/pwdUtilAuth.js` owns password keyring parsing and versioned credential
108
+ hashing and verification.
107
109
  - `lib/cryptoUtilAuth.js` wraps Node.js `crypto` primitives for signing,
108
110
  verifying, generating HMACs, and salts.
109
- - `lib/keyGen.js` owns Ed25519 and RSA key generation.
110
111
  - `lib/stringUtilAuth.js` owns legacy base64, base64-url-safe, and JWT string
111
112
  helpers. Password-hash parsing stays private to `lib/pwdUtilAuth.js`.
112
113
  - Keep direct exports as the public package API. Do not add password aliases,
@@ -116,10 +117,15 @@ depend on those folders being present.
116
117
 
117
118
  ## JWT Layer
118
119
 
119
- - Use EdDSA/Ed25519 as the default JWT algorithm unless a task explicitly
120
- changes crypto behavior.
121
- - Preserve standardized headers and payload behavior, including automatic `iat`
122
- and `exp` population where the existing API does that.
120
+ - Accept only parsed Ed25519 signing JWKs and verification JWKS values. Do not
121
+ add PEM, RSA, algorithm-selection, missing-`kid`, or single-key fallbacks.
122
+ - Derive every `kid` as the RFC 7638 SHA-256 base64url thumbprint of canonical
123
+ `{ "crv": "Ed25519", "kty": "OKP", "x": ... }` members.
124
+ - Require exact JWK metadata: `alg: "EdDSA"`, `use: "sig"`, and one `key_ops`
125
+ value (`sign` for private keys, `verify` for public keys). Ensure private and
126
+ public key material agree before accepting signing configuration.
127
+ - Create JWTs with the fixed `{ alg: "EdDSA", typ: "JWT", kid }` header and
128
+ verify by selecting the exact public key named by `kid`.
123
129
  - Keep service-to-service JWT creation here, not in `@carecard/jwt-read`.
124
130
  Public service-token creation exports are `jwtCreateServiceToken` and
125
131
  `jwtCreateServiceAuthorizationHeader`.
@@ -143,17 +149,22 @@ depend on those folders being present.
143
149
  $argon2id$v=19$m=19456,t=2,p=1$base64(salt)$base64(tag)
144
150
  ```
145
151
 
146
- - `createPasswordHash(password, pepper)` and
147
- `verifyPassword(password, savedHash, pepper)` are the only public password
148
- credential APIs; both are asynchronous.
152
+ - `parsePasswordHashKeyring(activeKeyId, serializedKeyring)`,
153
+ `createPasswordCredential(password, keyring)`, and
154
+ `verifyPasswordCredential(password, credential, keyring)` are the only
155
+ public password credential APIs. Keyring entries are comma-separated
156
+ `key-id:canonical-base64-32-byte-key` values.
157
+ - Persist the returned `hash` and `hashKeyId` together. Verification returns
158
+ `{ isValid, needsRehash }`; `needsRehash` is true only after a valid retiring
159
+ key credential is verified.
149
160
  - Generate salts internally for every credential. Never accept caller-selected
150
161
  salts and never persist or log the pepper.
151
162
  - Normalize well-formed Unicode passwords to NFC before deriving the tag, but
152
163
  do not trim or impose password policy here. `@carecard/validate` owns policy,
153
164
  while login intentionally permits existing passwords of any length.
154
- - Return `false` for malformed, non-canonical, differently parameterized, or
155
- legacy saved hashes. Throw for programmer/configuration errors such as an
156
- invalid pepper.
165
+ - Return an invalid verification result for unknown key IDs, malformed,
166
+ non-canonical, differently parameterized, or legacy saved hashes. Throw for
167
+ programmer/configuration errors such as an invalid keyring.
157
168
  - Do not add legacy HMAC verification. Seeded accounts must be rehashed before
158
169
  the Argon2id-only service starts.
159
170
  - Prefer Node.js `crypto` primitives over new dependencies.
package/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface JwtHeader {
12
12
  alg?: string;
13
13
  /** The media type of the JWT. Defaults to 'JWT'. */
14
14
  typ?: string;
15
+ /** RFC 7638 thumbprint identifying the signing key. */
16
+ kid?: string;
15
17
 
16
18
  /** Any other custom header fields. */
17
19
  [key: string]: unknown;
@@ -58,16 +60,14 @@ export interface ServiceJwtOptions {
58
60
  issuer: string;
59
61
  /** Receiving microservice name or names. */
60
62
  audience: string | string[];
61
- /** Private key owned by the issuing microservice. */
62
- privateKey: string;
63
+ /** Parsed private Ed25519 JWK owned by the issuing microservice. */
64
+ signingJwk: JwtSigningJwk;
63
65
  /** Subject claim. Defaults to issuer. */
64
66
  subject?: string;
65
67
  /** Issued-at timestamp in seconds or milliseconds. Defaults to now. */
66
68
  issuedAt?: number;
67
69
  /** Token lifetime in seconds. Defaults to 60. */
68
70
  expiresInSeconds?: number;
69
- /** JWT signing algorithm. Defaults to EdDSA. */
70
- algorithm?: string;
71
71
  /** Additional non-sensitive JWT claims. */
72
72
  claims?: Record<string, unknown>;
73
73
  }
@@ -112,49 +112,35 @@ export interface AuthenticatedRequest extends Request {
112
112
  visitor?: VisitorRequestObject | null;
113
113
  }
114
114
 
115
- /**
116
- * Contains a pair of public and private cryptographic keys.
117
- */
118
- export interface KeyPair {
119
- /** PEM formatted public key string. */
120
- publicKey: string;
121
- /** PEM formatted private key string. */
122
- privateKey: string;
115
+ declare const jwtSigningJwkBrand: unique symbol;
116
+ declare const jwtVerificationJwksBrand: unique symbol;
117
+ declare const passwordHashKeyringBrand: unique symbol;
118
+
119
+ export interface JwtSigningJwk {
120
+ readonly kid: string;
121
+ readonly [jwtSigningJwkBrand]: true;
123
122
  }
124
123
 
125
- /**
126
- * Utility functions for creating, verifying, and parsing JSON Web Tokens (JWT).
127
- * @deprecated use direct import of the new functions.
128
- */
129
- export const jwtUtilAuth: {
130
- /**
131
- * Creates a signed JWT from header and payload objects.
132
- * Automatically normalizes header (sets alg/typ) and payload (sets iat/exp/etc. in seconds).
133
- * @param headerObject - Header data for the JWT.
134
- * @param payloadObject - Payload data for the JWT.
135
- * @param privateKey - PEM formatted private key to sign the token.
136
- * @returns Signed JWT string, or null when the private key is missing. Unexpected failures throw.
137
- */
138
- createSignedJwtFromObject: (
139
- headerObject: JwtHeader,
140
- payloadObject: JwtPayload,
141
- privateKey: string,
142
- ) => string | null;
143
- /**
144
- * Verifies the signature of a JWT using a public key.
145
- * @param jwt - The JWT string to verify.
146
- * @param publicKey - PEM formatted public key.
147
- * @returns True if the signature is valid, false for malformed or invalid tokens. Unexpected failures throw.
148
- */
149
- verifyJwtSignature: (jwt: string, publicKey: string) => boolean;
150
- /**
151
- * Decodes a JWT and returns its header and payload as objects.
152
- * Note: This does NOT verify the signature.
153
- * @param jwt - The JWT string to parse.
154
- * @returns An object containing the header and payload, or null if parsing fails.
155
- */
156
- getHeaderPayloadFromJwt: (jwt: string) => JwtParts | null;
157
- };
124
+ export interface JwtVerificationJwks {
125
+ readonly kids: readonly string[];
126
+ readonly [jwtVerificationJwksBrand]: true;
127
+ }
128
+
129
+ export interface PasswordHashKeyring {
130
+ readonly activeKeyId: string;
131
+ readonly keyIds: readonly string[];
132
+ readonly [passwordHashKeyringBrand]: true;
133
+ }
134
+
135
+ export interface PasswordCredential {
136
+ readonly hash: string;
137
+ readonly hashKeyId: string;
138
+ }
139
+
140
+ export interface PasswordCredentialVerification {
141
+ readonly isValid: boolean;
142
+ readonly needsRehash: boolean;
143
+ }
158
144
 
159
145
  /**
160
146
  * Utility functions for string manipulation, base64 encoding, and parsing auth-related strings.
@@ -217,24 +203,14 @@ export const stringUtilAuth: {
217
203
  };
218
204
 
219
205
  /**
220
- * Generates a public/private key pair for JWT signing.
221
- * @param algorithm - The algorithm to use ('ed25519' or 'rsa'). Defaults to 'ed25519'.
222
- * @returns A KeyPair object containing PEM formatted keys.
223
- */
224
- export function generateKeyPair(algorithm?: 'ed25519' | 'rsa' | string): KeyPair;
225
-
226
- /**
227
- * Creates a signed JWT from header and payload objects.
228
- * Automatically normalizes header (sets alg/typ) and payload (sets iat/exp/etc. in seconds).
229
- * @param headerObject - Header data for the JWT.
206
+ * Creates an EdDSA JWT with a fixed JWT header and the signing JWK's kid.
230
207
  * @param payloadObject - Payload data for the JWT.
231
- * @param privateKey - PEM formatted private key to sign the token.
232
- * @returns Signed JWT string or null if an error occurs.
208
+ * @param signingJwk - A parsed private Ed25519 signing JWK.
209
+ * @returns Signed JWT string or null for invalid input.
233
210
  */
234
211
  export function jwtCreateSignedToken(
235
- headerObject: JwtHeader,
236
212
  payloadObject: JwtPayload,
237
- privateKey: string,
213
+ signingJwk: JwtSigningJwk,
238
214
  ): string | null;
239
215
 
240
216
  /**
@@ -252,12 +228,18 @@ export function jwtCreateServiceToken(options: ServiceJwtOptions): string | null
252
228
  export function jwtCreateServiceAuthorizationHeader(options: ServiceJwtOptions): string | null;
253
229
 
254
230
  /**
255
- * Verifies the signature of a JWT using a public key.
231
+ * Verifies a JWT by selecting its kid from a parsed public JWKS.
256
232
  * @param jwt - The JWT string to verify.
257
- * @param publicKey - PEM formatted public key.
233
+ * @param verificationJwks - Parsed public Ed25519 verification keys.
258
234
  * @returns True if the signature is valid, false otherwise.
259
235
  */
260
- export function jwtVerifySignedToken(jwt: string, publicKey: string): boolean;
236
+ export function jwtVerifySignedToken(jwt: string, verificationJwks: JwtVerificationJwks): boolean;
237
+
238
+ /** Parses and validates one private Ed25519 signing JWK. */
239
+ export function parseJwtSigningJwk(serializedJwk: string): JwtSigningJwk;
240
+
241
+ /** Parses and validates an RFC 7517 public Ed25519 JWKS. */
242
+ export function parseJwtVerificationJwks(serializedJwks: string): JwtVerificationJwks;
261
243
 
262
244
  /**
263
245
  * Decodes a JWT and returns its header and payload as objects.
@@ -267,23 +249,21 @@ export function jwtVerifySignedToken(jwt: string, publicKey: string): boolean;
267
249
  */
268
250
  export function jwtGetHeaderPayload(jwt: string): JwtParts | null;
269
251
 
270
- /**
271
- * Creates an Argon2id PHC password hash with a unique random salt.
272
- * @param password - The plain-text password to hash.
273
- * @param pepper - A secret pepper containing at least 32 UTF-8 bytes.
274
- * @returns The strict Argon2id PHC string.
275
- */
276
- export function createPasswordHash(password: string, pepper: string): Promise<string>;
252
+ /** Parses a validated active/retiring password hash keyring. */
253
+ export function parsePasswordHashKeyring(
254
+ activeKeyId: string,
255
+ serializedKeyring: string,
256
+ ): PasswordHashKeyring;
277
257
 
278
- /**
279
- * Verifies a password against a strict CareCard Argon2id PHC hash.
280
- * @param password - The plain-text password to verify.
281
- * @param savedHash - The stored Argon2id PHC hash.
282
- * @param pepper - The same secret pepper used when creating the hash.
283
- * @returns False for a mismatch or malformed/unsupported saved hash.
284
- */
285
- export function verifyPassword(
258
+ /** Creates a freshly salted active-key Argon2id credential. */
259
+ export function createPasswordCredential(
260
+ password: string,
261
+ keyring: PasswordHashKeyring,
262
+ ): Promise<PasswordCredential>;
263
+
264
+ /** Verifies a credential and reports whether it should move to the active key. */
265
+ export function verifyPasswordCredential(
286
266
  password: string,
287
- savedHash: string,
288
- pepper: string,
289
- ): Promise<boolean>;
267
+ credential: PasswordCredential,
268
+ keyring: PasswordHashKeyring,
269
+ ): Promise<PasswordCredentialVerification>;
package/index.js CHANGED
@@ -1,27 +1,18 @@
1
1
  const pwdUtilAuth = require('./lib/pwdUtilAuth');
2
2
  const jwtUtilAuth = require('./lib/jwtUtilAuth');
3
- const keyGen = require('./lib/keyGen');
3
+ const jwkUtilAuth = require('./lib/jwkUtilAuth');
4
4
 
5
5
  module.exports = {
6
- // New
7
- generateKeyPair: keyGen.generateKeyPair,
8
6
  jwtCreateSignedToken: jwtUtilAuth.createSignedJwtFromObject,
9
7
  jwtCreateServiceToken: jwtUtilAuth.createServiceJwt,
10
8
  jwtCreateServiceAuthorizationHeader: jwtUtilAuth.createServiceAuthorizationHeader,
11
9
  jwtVerifySignedToken: jwtUtilAuth.verifyJwtSignature,
12
10
  jwtGetHeaderPayload: jwtUtilAuth.getHeaderPayloadFromJwt,
13
- createPasswordHash: pwdUtilAuth.createPasswordHash,
14
- verifyPassword: pwdUtilAuth.verifyPassword,
15
-
16
- // Deprecated
17
- /**
18
- * @deprecated use direct import of the new functions.
19
- */
20
- jwtUtilAuth: {
21
- createSignedJwtFromObject: jwtUtilAuth.createSignedJwtFromObject,
22
- verifyJwtSignature: jwtUtilAuth.verifyJwtSignature,
23
- getHeaderPayloadFromJwt: jwtUtilAuth.getHeaderPayloadFromJwt,
24
- },
11
+ parseJwtSigningJwk: jwkUtilAuth.parseJwtSigningJwk,
12
+ parseJwtVerificationJwks: jwkUtilAuth.parseJwtVerificationJwks,
13
+ createPasswordCredential: pwdUtilAuth.createPasswordCredential,
14
+ parsePasswordHashKeyring: pwdUtilAuth.parsePasswordHashKeyring,
15
+ verifyPasswordCredential: pwdUtilAuth.verifyPasswordCredential,
25
16
  /**
26
17
  * @deprecated Use native Buffer methods or other modern alternatives.
27
18
  */
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const { createHash, createPrivateKey, createPublicKey } = require('node:crypto');
4
+
5
+ const ED25519_KEY_BYTES = 32;
6
+ const signingKeys = new WeakMap();
7
+ const verificationKeys = new WeakMap();
8
+ const SIGNING_JWK_MEMBERS = ['alg', 'crv', 'd', 'key_ops', 'kid', 'kty', 'use', 'x'];
9
+ const VERIFICATION_JWK_MEMBERS = ['alg', 'crv', 'key_ops', 'kid', 'kty', 'use', 'x'];
10
+
11
+ function parseJsonObject(serializedValue, configurationName) {
12
+ try {
13
+ const value = JSON.parse(serializedValue);
14
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
15
+ throw new TypeError();
16
+ }
17
+ return value;
18
+ } catch {
19
+ throw new Error(`Invalid ${configurationName} configuration.`);
20
+ }
21
+ }
22
+
23
+ function isCanonicalBase64Url(value) {
24
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
25
+ return false;
26
+ }
27
+ const decodedValue = Buffer.from(value, 'base64url');
28
+ return decodedValue.length === ED25519_KEY_BYTES && decodedValue.toString('base64url') === value;
29
+ }
30
+
31
+ function deriveJwkThumbprint(jwk) {
32
+ const canonicalJwk = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: jwk.x });
33
+ return createHash('sha256').update(canonicalJwk).digest('base64url');
34
+ }
35
+
36
+ function hasExactKeyOperation(jwk, operation) {
37
+ return Array.isArray(jwk.key_ops) && jwk.key_ops.length === 1 && jwk.key_ops[0] === operation;
38
+ }
39
+
40
+ function hasExactMembers(value, expectedMembers) {
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
42
+ return false;
43
+ }
44
+ const members = Object.keys(value).sort();
45
+ return (
46
+ members.length === expectedMembers.length &&
47
+ members.every((member, index) => member === expectedMembers[index])
48
+ );
49
+ }
50
+
51
+ function hasValidSharedMetadata(jwk) {
52
+ return (
53
+ jwk.kty === 'OKP' &&
54
+ jwk.crv === 'Ed25519' &&
55
+ jwk.alg === 'EdDSA' &&
56
+ jwk.use === 'sig' &&
57
+ isCanonicalBase64Url(jwk.x) &&
58
+ jwk.kid === deriveJwkThumbprint(jwk)
59
+ );
60
+ }
61
+
62
+ function createSigningKey(jwk) {
63
+ try {
64
+ const privateKey = createPrivateKey({ key: jwk, format: 'jwk' });
65
+ const derivedPublicJwk = createPublicKey(privateKey).export({ format: 'jwk' });
66
+ if (derivedPublicJwk.x !== jwk.x) {
67
+ throw new TypeError();
68
+ }
69
+ return privateKey;
70
+ } catch {
71
+ throw new Error('Invalid JWT signing JWK configuration.');
72
+ }
73
+ }
74
+
75
+ function parseJwtSigningJwk(serializedJwk) {
76
+ const jwk = parseJsonObject(serializedJwk, 'JWT signing JWK');
77
+ if (!hasExactMembers(jwk, SIGNING_JWK_MEMBERS) || !hasValidSharedMetadata(jwk)) {
78
+ throw new Error('Invalid JWT signing JWK configuration.');
79
+ }
80
+ if (!hasExactKeyOperation(jwk, 'sign') || !isCanonicalBase64Url(jwk.d)) {
81
+ throw new Error('Invalid JWT signing JWK configuration.');
82
+ }
83
+ const parsedJwk = Object.freeze({ ...jwk, key_ops: Object.freeze(['sign']) });
84
+ signingKeys.set(parsedJwk, createSigningKey(jwk));
85
+ return parsedJwk;
86
+ }
87
+
88
+ function parseVerificationJwk(jwk) {
89
+ if (!hasExactMembers(jwk, VERIFICATION_JWK_MEMBERS) || !hasValidSharedMetadata(jwk)) {
90
+ throw new Error('Invalid JWT verification JWKS configuration.');
91
+ }
92
+ if (!hasExactKeyOperation(jwk, 'verify')) {
93
+ throw new Error('Invalid JWT verification JWKS configuration.');
94
+ }
95
+ try {
96
+ return [jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })];
97
+ } catch {
98
+ throw new Error('Invalid JWT verification JWKS configuration.');
99
+ }
100
+ }
101
+
102
+ function parseJwtVerificationJwks(serializedJwks) {
103
+ const jwks = parseJsonObject(serializedJwks, 'JWT verification JWKS');
104
+ if (!hasExactMembers(jwks, ['keys']) || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
105
+ throw new Error('Invalid JWT verification JWKS configuration.');
106
+ }
107
+ const entries = jwks.keys.map(parseVerificationJwk);
108
+ const keyMap = new Map(entries);
109
+ if (keyMap.size !== entries.length) {
110
+ throw new Error('Invalid JWT verification JWKS configuration.');
111
+ }
112
+ const parsedJwks = Object.freeze({ kids: Object.freeze([...keyMap.keys()]) });
113
+ verificationKeys.set(parsedJwks, keyMap);
114
+ return parsedJwks;
115
+ }
116
+
117
+ function getJwtSigningKey(signingJwk) {
118
+ return signingKeys.get(signingJwk) ?? null;
119
+ }
120
+
121
+ function getJwtVerificationKey(jwks, kid) {
122
+ return verificationKeys.get(jwks)?.get(kid) ?? null;
123
+ }
124
+
125
+ module.exports = {
126
+ getJwtSigningKey,
127
+ getJwtVerificationKey,
128
+ parseJwtSigningJwk,
129
+ parseJwtVerificationJwks,
130
+ };
@@ -1,89 +1,43 @@
1
- const crypto = require('crypto');
1
+ 'use strict';
2
2
 
3
- const supportedVerificationHashes = new Set(crypto.getHashes().map(hash => hash.toLowerCase()));
3
+ const { sign, verify } = require('node:crypto');
4
+ const { getJwtSigningKey, getJwtVerificationKey } = require('./jwkUtilAuth');
4
5
 
5
- // Pattern: Normalizer - preserves explicit registered times while supplying omitted defaults.
6
- const _normalizePayload = payloadObject => {
6
+ const normalizePayload = payloadObject => {
7
7
  const payload = { ...payloadObject };
8
8
  const now = Math.floor(Date.now() / 1000);
9
9
  const fieldsToNormalize = ['iat', 'exp', 'nbf', 'auth_time'];
10
10
  const msThreshold = 1000000000000;
11
-
12
11
  if (!Number.isFinite(payload.iat)) {
13
12
  payload.iat = now;
14
13
  }
15
-
16
14
  fieldsToNormalize.forEach(field => {
17
15
  if (Number.isFinite(payload[field]) && payload[field] > msThreshold) {
18
16
  payload[field] = Math.floor(payload[field] / 1000);
19
17
  }
20
18
  });
21
-
22
19
  if (!Number.isFinite(payload.exp)) {
23
20
  payload.exp = payload.iat + 3600;
24
21
  }
25
22
  return payload;
26
23
  };
27
24
 
28
- const _encode = obj => {
29
- return Buffer.from(JSON.stringify(obj)).toString('base64url');
30
- };
31
-
32
- const _decode = str => {
33
- return JSON.parse(Buffer.from(str, 'base64url').toString('utf8'));
34
- };
35
-
36
- const _sign = (token, alg, privateKey) => {
37
- if (alg === 'EdDSA' || alg === 'Ed25519') {
38
- return crypto.sign(null, Buffer.from(token), privateKey).toString('base64url');
39
- }
40
-
41
- const sign = crypto.createSign(alg);
42
- sign.write(token);
43
- sign.end();
44
- return sign.sign(privateKey, 'base64url');
45
- };
46
-
47
- const _verify = (token, signature, alg, publicKey) => {
48
- if (alg === 'EdDSA' || alg === 'Ed25519') {
49
- return crypto.verify(null, Buffer.from(token), publicKey, Buffer.from(signature, 'base64url'));
50
- }
51
-
52
- const verify = crypto.createVerify(alg);
53
- verify.update(token);
54
- verify.end();
55
- return verify.verify(publicKey, signature, 'base64url');
56
- };
57
-
58
- // Pattern: Allow List - rejects algorithms that the runtime cannot safely verify.
59
- const isSupportedVerificationAlgorithm = algorithm => {
60
- if (algorithm === 'EdDSA' || algorithm === 'Ed25519') {
61
- return true;
62
- }
63
- return typeof algorithm === 'string' && supportedVerificationHashes.has(algorithm.toLowerCase());
64
- };
25
+ const encodeJwtPart = value => Buffer.from(JSON.stringify(value)).toString('base64url');
26
+ const decodeJwtPart = value => JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
65
27
 
66
- // Pattern: Type Guard - decoded JWT parts must honor their documented object contract.
67
28
  const isDecodedJwtObject = value => {
68
29
  return typeof value === 'object' && value !== null && !Array.isArray(value);
69
30
  };
70
31
 
71
- const isNonEmptyString = value => {
72
- return typeof value === 'string' && value.trim().length > 0;
73
- };
32
+ const isNonEmptyString = value => typeof value === 'string' && value.trim().length > 0;
74
33
 
75
- const audienceIsNonEmptyStringArray = audience => {
34
+ const isNonEmptyAudience = audience => {
76
35
  return (
77
- Array.isArray(audience) &&
78
- audience.length > 0 &&
79
- audience.every(audienceValue => isNonEmptyString(audienceValue))
36
+ isNonEmptyString(audience) ||
37
+ (Array.isArray(audience) && audience.length > 0 && audience.every(isNonEmptyString))
80
38
  );
81
39
  };
82
40
 
83
- const isNonEmptyAudience = audience => {
84
- return isNonEmptyString(audience) || audienceIsNonEmptyStringArray(audience);
85
- };
86
-
87
41
  const normalizeSeconds = value => {
88
42
  if (!Number.isFinite(value)) {
89
43
  return null;
@@ -91,72 +45,39 @@ const normalizeSeconds = value => {
91
45
  return value > 1000000000000 ? Math.floor(value / 1000) : Math.floor(value);
92
46
  };
93
47
 
94
- /**
95
- * Creates Url safe jwt
96
- * @param headerObject
97
- * @param payloadObject
98
- * @param privateKey
99
- * @return {string|null}
100
- */
101
- const createSignedJwtFromObject = (headerObject, payloadObject, privateKey) => {
102
- if (!privateKey) {
48
+ function createSignedJwtFromObject(payloadObject, signingJwk) {
49
+ const privateKey = getJwtSigningKey(signingJwk);
50
+ if (!privateKey || !isDecodedJwtObject(payloadObject)) {
103
51
  return null;
104
52
  }
105
-
106
- const header = { ...headerObject };
107
- header.alg = header.alg || 'EdDSA';
108
- header.typ = 'JWT';
109
-
110
- const payload = _normalizePayload(payloadObject);
111
-
112
- const headerBase64UrlSafe = _encode(header);
113
- const payloadBase64UrlSafe = _encode(payload);
114
- const token = headerBase64UrlSafe + '.' + payloadBase64UrlSafe;
115
-
116
- const signature = _sign(token, header.alg, privateKey);
117
-
118
- return token + '.' + signature;
119
- };
53
+ const header = { alg: 'EdDSA', typ: 'JWT', kid: signingJwk.kid };
54
+ const token = `${encodeJwtPart(header)}.${encodeJwtPart(normalizePayload(payloadObject))}`;
55
+ const signature = sign(null, Buffer.from(token), privateKey).toString('base64url');
56
+ return `${token}.${signature}`;
57
+ }
120
58
 
121
59
  function createServiceJwt({
122
60
  issuer,
123
61
  audience,
124
- privateKey,
62
+ signingJwk,
125
63
  subject = issuer,
126
64
  issuedAt = Math.floor(Date.now() / 1000),
127
65
  expiresInSeconds = 60,
128
- algorithm = 'EdDSA',
129
66
  claims = {},
130
67
  } = {}) {
131
- if (
132
- !isNonEmptyString(issuer) ||
133
- !isNonEmptyAudience(audience) ||
134
- !isNonEmptyString(subject) ||
135
- !isNonEmptyString(privateKey) ||
136
- !Number.isInteger(expiresInSeconds) ||
137
- expiresInSeconds <= 0
138
- ) {
68
+ if (!isNonEmptyString(issuer) || !isNonEmptyAudience(audience) || !isNonEmptyString(subject)) {
69
+ return null;
70
+ }
71
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds <= 0) {
139
72
  return null;
140
73
  }
141
-
142
74
  const iat = normalizeSeconds(issuedAt);
143
- if (!Number.isInteger(iat)) {
75
+ if (!Number.isInteger(iat) || !isDecodedJwtObject(claims)) {
144
76
  return null;
145
77
  }
146
-
147
- // Service-to-service tokens use registered JWT claims so receivers can
148
- // validate sender, intended audience, subject, and lifetime consistently.
149
78
  return createSignedJwtFromObject(
150
- { alg: algorithm, typ: 'JWT' },
151
- {
152
- ...claims,
153
- iss: issuer,
154
- aud: audience,
155
- sub: subject,
156
- iat,
157
- exp: iat + expiresInSeconds,
158
- },
159
- privateKey,
79
+ { ...claims, iss: issuer, aud: audience, sub: subject, iat, exp: iat + expiresInSeconds },
80
+ signingJwk,
160
81
  );
161
82
  }
162
83
 
@@ -165,81 +86,73 @@ function createServiceAuthorizationHeader(options = {}) {
165
86
  return token ? `Bearer ${token}` : null;
166
87
  }
167
88
 
168
- /**
169
- * Verify signature of jwt
170
- * @param jwt
171
- * @param publicKey
172
- * @return {boolean}
173
- */
174
- // Pattern: Fail-Closed Verification - malformed token metadata returns false before crypto use.
175
- const verifyJwtSignature = (jwt, publicKey) => {
89
+ function hasValidJwtHeader(header) {
90
+ return (
91
+ isDecodedJwtObject(header) &&
92
+ header.alg === 'EdDSA' &&
93
+ header.typ === 'JWT' &&
94
+ isNonEmptyString(header.kid)
95
+ );
96
+ }
97
+
98
+ function decodeCanonicalSignature(signature) {
99
+ if (typeof signature !== 'string' || !/^[A-Za-z0-9_-]+$/.test(signature)) {
100
+ return null;
101
+ }
102
+ const decoded = Buffer.from(signature, 'base64url');
103
+ return decoded.length === 64 && decoded.toString('base64url') === signature ? decoded : null;
104
+ }
105
+
106
+ function verifyJwtSignature(jwt, verificationJwks) {
176
107
  try {
177
- if (!jwt || !publicKey) {
178
- return false;
179
- }
180
- const splitJWT = jwt.split('.');
181
- if (splitJWT.length !== 3) {
108
+ if (typeof jwt !== 'string') {
182
109
  return false;
183
110
  }
184
-
185
- const [header, payload, signature] = splitJWT;
186
- const token = header + '.' + payload;
187
- const headerObject = _decode(header);
188
- if (!isDecodedJwtObject(headerObject) || !isSupportedVerificationAlgorithm(headerObject.alg)) {
111
+ const parts = jwt.split('.');
112
+ if (parts.length !== 3) {
189
113
  return false;
190
114
  }
191
-
192
- return _verify(token, signature, headerObject.alg, publicKey);
115
+ const header = decodeJwtPart(parts[0]);
116
+ const signature = decodeCanonicalSignature(parts[2]);
117
+ const publicKey = hasValidJwtHeader(header)
118
+ ? getJwtVerificationKey(verificationJwks, header.kid)
119
+ : null;
120
+ const signedData = Buffer.from(`${parts[0]}.${parts[1]}`);
121
+ return Boolean(publicKey && signature && verify(null, signedData, publicKey, signature));
193
122
  } catch (error) {
194
123
  if (!(error instanceof SyntaxError)) {
195
124
  throw error;
196
125
  }
197
126
  return false;
198
127
  }
199
- };
128
+ }
200
129
 
201
- /**
202
- * Returns header and payload object for jwt.
203
- * @param jwt
204
- * @return {{payload: *, header: *}|null}
205
- */
206
- // Pattern: Boundary Validation - returns only decoded header and payload objects.
207
- const getHeaderPayloadFromJwt = jwt => {
130
+ function getHeaderPayloadFromJwt(jwt) {
208
131
  try {
209
- if (typeof jwt !== 'string') {
210
- return null;
211
- }
212
- const splitJWT = jwt.split('.');
213
- if (splitJWT.length !== 3) {
214
- return null;
215
- }
216
-
217
- const headerObject = _decode(splitJWT[0]);
218
- const payloadObject = _decode(splitJWT[1]);
219
- if (!isDecodedJwtObject(headerObject) || !isDecodedJwtObject(payloadObject)) {
132
+ if (typeof jwt !== 'string' || jwt.split('.').length !== 3) {
220
133
  return null;
221
134
  }
222
-
223
- return { header: headerObject, payload: payloadObject };
135
+ const [headerPart, payloadPart] = jwt.split('.');
136
+ const header = decodeJwtPart(headerPart);
137
+ const payload = decodeJwtPart(payloadPart);
138
+ return isDecodedJwtObject(header) && isDecodedJwtObject(payload) ? { header, payload } : null;
224
139
  } catch (error) {
225
140
  if (!(error instanceof SyntaxError)) {
226
141
  throw error;
227
142
  }
228
143
  return null;
229
144
  }
230
- };
145
+ }
231
146
 
232
147
  module.exports = {
233
148
  createSignedJwtFromObject,
234
149
  createServiceJwt,
235
150
  createServiceAuthorizationHeader,
236
- verifyJwtSignature,
237
151
  getHeaderPayloadFromJwt,
238
- _normalizePayload,
239
- _encode,
240
- _decode,
241
- _sign,
242
- _verify,
152
+ verifyJwtSignature,
153
+ _normalizePayload: normalizePayload,
154
+ _encode: encodeJwtPart,
155
+ _decode: decodeJwtPart,
243
156
  _isNonEmptyAudience: isNonEmptyAudience,
244
157
  _normalizeSeconds: normalizeSeconds,
245
158
  };
@@ -11,22 +11,12 @@ const ARGON2_PASSES = 2;
11
11
  const ARGON2_PARALLELISM = 1;
12
12
  const PASSWORD_SALT_BYTES = 16;
13
13
  const PASSWORD_TAG_BYTES = 32;
14
- const MINIMUM_PEPPER_BYTES = 32;
14
+ const PASSWORD_KEY_BYTES = 32;
15
+ const PASSWORD_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
16
+ const passwordKeyringSecrets = new WeakMap();
15
17
  const PASSWORD_HASH_PATTERN =
16
18
  /^\$argon2id\$v=19\$m=19456,t=2,p=1\$([A-Za-z0-9+/]{22})\$([A-Za-z0-9+/]{43})$/;
17
19
 
18
- // Pattern: Configuration Guard - converts only a sufficiently strong string pepper to bytes.
19
- function createPepperBuffer(pepper) {
20
- if (typeof pepper !== 'string') {
21
- throw new TypeError('Password pepper must be a string.');
22
- }
23
- const pepperBuffer = Buffer.from(pepper, 'utf8');
24
- if (pepperBuffer.length < MINIMUM_PEPPER_BYTES) {
25
- throw new RangeError('Password pepper must be at least 32 bytes.');
26
- }
27
- return pepperBuffer;
28
- }
29
-
30
20
  // Pattern: Canonicalization - converts well-formed NFC password text to stable UTF-8 bytes.
31
21
  function createPasswordBuffer(password) {
32
22
  if (typeof password !== 'string' || !password.isWellFormed()) {
@@ -88,26 +78,72 @@ function serializePasswordHash(salt, tag) {
88
78
  return `$${ARGON2_ALGORITHM}$v=${ARGON2_VERSION}$${parameters}$${encodePhcBase64(salt)}$${encodePhcBase64(tag)}`;
89
79
  }
90
80
 
91
- // Pattern: Credential Factory - creates an independently salted Argon2id password hash.
92
- async function createPasswordHash(password, pepper) {
93
- const pepperBuffer = createPepperBuffer(pepper);
94
- const salt = randomBytes(PASSWORD_SALT_BYTES);
95
- const tag = await derivePasswordTag(password, salt, pepperBuffer);
96
- return serializePasswordHash(salt, tag);
81
+ // Pattern: Strict Configuration Parser - creates an opaque versioned password keyring.
82
+ function parsePasswordHashKeyring(activeKeyId, serializedKeyring) {
83
+ if (!PASSWORD_KEY_ID_PATTERN.test(activeKeyId) || typeof serializedKeyring !== 'string') {
84
+ throw new Error('Invalid password hash keyring configuration.');
85
+ }
86
+ if (!serializedKeyring || /\s/.test(serializedKeyring)) {
87
+ throw new Error('Invalid password hash keyring configuration.');
88
+ }
89
+ const entries = serializedKeyring.split(',').map(parsePasswordKeyEntry);
90
+ const keyMap = new Map(entries);
91
+ if (keyMap.size !== entries.length || !keyMap.has(activeKeyId)) {
92
+ throw new Error('Invalid password hash keyring configuration.');
93
+ }
94
+ const keyring = Object.freeze({ activeKeyId, keyIds: Object.freeze([...keyMap.keys()]) });
95
+ passwordKeyringSecrets.set(keyring, keyMap);
96
+ return keyring;
97
97
  }
98
98
 
99
- // Pattern: Constant-Time Verification - derives and compares a strict Argon2id credential tag.
100
- async function verifyPassword(password, savedHash, pepper) {
101
- const pepperBuffer = createPepperBuffer(pepper);
102
- if (typeof password !== 'string' || !password.isWellFormed()) {
103
- return false;
99
+ function parsePasswordKeyEntry(entry) {
100
+ const separatorIndex = entry.indexOf(':');
101
+ if (separatorIndex <= 0 || separatorIndex !== entry.lastIndexOf(':')) {
102
+ throw new Error('Invalid password hash keyring configuration.');
103
+ }
104
+ const keyId = entry.slice(0, separatorIndex);
105
+ const encodedKey = entry.slice(separatorIndex + 1);
106
+ const key = Buffer.from(encodedKey, 'base64');
107
+ if (!PASSWORD_KEY_ID_PATTERN.test(keyId) || !isCanonicalPasswordKey(key, encodedKey)) {
108
+ throw new Error('Invalid password hash keyring configuration.');
104
109
  }
105
- const parsedHash = parsePasswordHash(savedHash);
106
- if (!parsedHash) {
107
- return false;
110
+ return [keyId, key];
111
+ }
112
+
113
+ function isCanonicalPasswordKey(key, encodedKey) {
114
+ return key.length === PASSWORD_KEY_BYTES && key.toString('base64') === encodedKey;
115
+ }
116
+
117
+ function getPasswordKeyringSecrets(keyring) {
118
+ const keys = passwordKeyringSecrets.get(keyring);
119
+ if (!keys) {
120
+ throw new TypeError('A parsed password hash keyring is required.');
121
+ }
122
+ return keys;
123
+ }
124
+
125
+ async function createPasswordCredential(password, keyring) {
126
+ const keys = getPasswordKeyringSecrets(keyring);
127
+ const pepper = keys.get(keyring.activeKeyId);
128
+ const salt = randomBytes(PASSWORD_SALT_BYTES);
129
+ const tag = await derivePasswordTag(password, salt, pepper);
130
+ return Object.freeze({ hash: serializePasswordHash(salt, tag), hashKeyId: keyring.activeKeyId });
131
+ }
132
+
133
+ async function verifyPasswordCredential(password, credential, keyring) {
134
+ const keys = getPasswordKeyringSecrets(keyring);
135
+ const pepper = keys.get(credential?.hashKeyId);
136
+ const parsedHash = parsePasswordHash(credential?.hash);
137
+ if (!pepper || !parsedHash || typeof password !== 'string' || !password.isWellFormed()) {
138
+ return { isValid: false, needsRehash: false };
108
139
  }
109
- const candidateTag = await derivePasswordTag(password, parsedHash.salt, pepperBuffer);
110
- return timingSafeEqual(candidateTag, parsedHash.tag);
140
+ const candidateTag = await derivePasswordTag(password, parsedHash.salt, pepper);
141
+ const isValid = timingSafeEqual(candidateTag, parsedHash.tag);
142
+ return { isValid, needsRehash: isValid && credential.hashKeyId !== keyring.activeKeyId };
111
143
  }
112
144
 
113
- module.exports = { createPasswordHash, verifyPassword };
145
+ module.exports = {
146
+ createPasswordCredential,
147
+ parsePasswordHashKeyring,
148
+ verifyPasswordCredential,
149
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carecard/auth-util",
3
- "version": "3.25.0",
3
+ "version": "3.27.0",
4
4
  "repository": "https://github.com/CareCard-ca/pkg-auth-util.git",
5
5
  "description": "Auth utility functions",
6
6
  "main": "index.js",
@@ -50,8 +50,8 @@
50
50
  "typescript": "6.0.3"
51
51
  },
52
52
  "dependencies": {
53
- "@carecard/common-util": "3.25.0",
54
- "@carecard/validate": "3.25.0",
53
+ "@carecard/common-util": "3.27.0",
54
+ "@carecard/validate": "3.27.0",
55
55
  "@types/express": "5.0.6"
56
56
  },
57
57
  "overrides": {
package/readme.md CHANGED
@@ -25,9 +25,8 @@ Non-negotiable code organization rule: Functions with the same or equivalent beh
25
25
 
26
26
  ## Features
27
27
 
28
- - **JWT Utilities**: Create, verify, and parse JSON Web Tokens with support for EdDSA (Ed25519) and RSA.
29
- - **Password Utilities**: Asynchronous Argon2id password hashing through Node.js `crypto`, with a unique random salt and application pepper.
30
- - **Key Generation**: Generate Ed25519 and RSA key pairs for JWT signing.
28
+ - **JWT Utilities**: Create and verify EdDSA JWTs through strict Ed25519 JWK and JWKS configuration.
29
+ - **Password Utilities**: Rotate versioned Argon2id password peppers while preserving a strict saved-credential format.
31
30
  - **Crypto Utilities**: Low-level cryptographic primitives for signing, verification, and hashing.
32
31
  - **String Utilities**: Base64 and Base64UrlSafe encoding/decoding, and custom string parsing.
33
32
 
@@ -46,18 +45,17 @@ const {
46
45
  jwtCreateSignedToken,
47
46
  jwtGetHeaderPayload,
48
47
  jwtVerifySignedToken,
48
+ parseJwtSigningJwk,
49
+ parseJwtVerificationJwks,
49
50
  } = require('@carecard/auth-util');
50
51
 
51
- const header = { alg: 'EdDSA', typ: 'JWT' };
52
52
  const payload = { sub: '1234567890', name: 'John Doe' };
53
- const privateKey = '...'; // Your private PEM key
53
+ const signingJwk = parseJwtSigningJwk(process.env.JWT_SIGNING_JWK);
54
+ const verificationJwks = parseJwtVerificationJwks(process.env.JWT_VERIFICATION_JWKS);
54
55
 
55
- // Create a signed JWT
56
- const token = jwtCreateSignedToken(header, payload, privateKey);
56
+ const token = jwtCreateSignedToken(payload, signingJwk);
57
57
 
58
- // Verify a JWT signature
59
- const publicKey = '...'; // Your public PEM key
60
- const isValid = jwtVerifySignedToken(token, publicKey);
58
+ const isValid = jwtVerifySignedToken(token, verificationJwks);
61
59
 
62
60
  // Get header and payload from a JWT
63
61
  const { header: decodedHeader, payload: decodedPayload } = jwtGetHeaderPayload(token);
@@ -74,47 +72,51 @@ const {
74
72
  const token = jwtCreateServiceToken({
75
73
  issuer: 'ms-institutions',
76
74
  audience: 'ms-auth',
77
- privateKey: institutionsPrivateKey,
75
+ signingJwk: institutionsSigningJwk,
78
76
  });
79
77
 
80
78
  const authorization = jwtCreateServiceAuthorizationHeader({
81
79
  issuer: 'ms-institutions',
82
80
  audience: 'ms-auth',
83
- privateKey: institutionsPrivateKey,
81
+ signingJwk: institutionsSigningJwk,
84
82
  });
85
83
  ```
86
84
 
87
85
  ### Password Utilities
88
86
 
89
87
  ```javascript
90
- const { createPasswordHash, verifyPassword } = require('@carecard/auth-util');
88
+ const {
89
+ createPasswordCredential,
90
+ parsePasswordHashKeyring,
91
+ verifyPasswordCredential,
92
+ } = require('@carecard/auth-util');
91
93
 
92
94
  const password = 'correct horse battery staple';
93
- const pepper = process.env.MS_AUTH_HASH_KEY; // At least 32 UTF-8 bytes.
94
-
95
- // Create a strict Argon2id PHC credential with a unique random salt.
96
- const savedHash = await createPasswordHash(password, pepper);
97
-
98
- // Verify through the package boundary. Malformed and legacy credentials return false.
99
- const isCorrect = await verifyPassword(password, savedHash, pepper);
95
+ const keyring = parsePasswordHashKeyring(
96
+ process.env.MS_AUTH_PASSWORD_HASH_ACTIVE_KEY_ID,
97
+ process.env.MS_AUTH_PASSWORD_HASH_KEYRING,
98
+ );
99
+
100
+ const credential = await createPasswordCredential(password, keyring);
101
+
102
+ const result = await verifyPasswordCredential(password, credential, keyring);
103
+ if (result.isValid && result.needsRehash) {
104
+ const replacement = await createPasswordCredential(password, keyring);
105
+ // Persist replacement.hash and replacement.hashKeyId atomically.
106
+ }
100
107
  ```
101
108
 
102
- Both functions preserve password spaces and normalize well-formed Unicode to
103
- NFC. Password-policy checks belong to `@carecard/validate`; this package hashes
104
- and compares the supplied value without enforcing password length or
105
- composition. Keep the pepper outside persisted credentials and logs.
106
-
107
- ### Key Generation
109
+ Keyring entries use `key-id:canonical-base64-32-byte-key`, separated by commas.
110
+ Credential rows persist both the PHC hash and its `hashKeyId`; pepper keys stay
111
+ outside persisted credentials and logs. Passwords retain spaces and normalize
112
+ well-formed Unicode to NFC. `@carecard/validate` owns password policy.
108
113
 
109
- ```javascript
110
- const { generateKeyPair } = require('@carecard/auth-util');
111
-
112
- // Generate Ed25519 keys (default)
113
- const { publicKey, privateKey } = generateKeyPair();
114
-
115
- // Generate RSA keys
116
- const rsaKeys = generateKeyPair('rsa');
117
- ```
114
+ JWT signing configuration is one strict private Ed25519 JWK. Verification
115
+ configuration is an RFC 7517 JWKS containing one or more public Ed25519 JWKs.
116
+ Every key uses `alg: "EdDSA"`, `use: "sig"`, one exact `key_ops` operation, and
117
+ an RFC 7638 SHA-256 thumbprint `kid`. Add replacement public keys before
118
+ switching signers, and retain retiring public keys for the maximum token
119
+ lifetime plus clock skew.
118
120
 
119
121
  ### String Utilities (`stringUtilAuth`)
120
122
 
@@ -159,9 +161,9 @@ npm run test:types
159
161
 
160
162
  The package is organized into several modules:
161
163
 
162
- - `jwtUtilAuth`: Manages the JWT lifecycle.
163
- - `pwdUtilAuth`: Implements asynchronous Argon2id password hashing and verification behind the direct package exports.
164
- - `keyGen`: Utility for generating cryptographic keys.
164
+ - `jwkUtilAuth`: Strictly parses private signing JWKs and public verification JWKS values.
165
+ - `jwtUtilAuth`: Creates fixed-header Ed25519 JWTs and verifies signatures by `kid`.
166
+ - `pwdUtilAuth`: Creates and verifies versioned Argon2id password credentials.
165
167
  - `cryptoUtilAuth`: Core cryptographic operations using Node.js `crypto` module.
166
168
  - `stringUtilAuth`: String manipulation and format conversions.
167
169
 
package/lib/keyGen.js DELETED
@@ -1,24 +0,0 @@
1
- const { generateKeyPairSync } = require('node:crypto');
2
-
3
- const generateKeyPair = (algorithm = 'ed25519') => {
4
- const options = {
5
- publicKeyEncoding: {
6
- type: 'spki',
7
- format: 'pem',
8
- },
9
- privateKeyEncoding: {
10
- type: 'pkcs8',
11
- format: 'pem',
12
- },
13
- };
14
-
15
- if (algorithm === 'rsa') {
16
- options.modulusLength = 2048;
17
- }
18
-
19
- return generateKeyPairSync(algorithm, options);
20
- };
21
-
22
- module.exports = {
23
- generateKeyPair,
24
- };