@exortek/apikey 1.1.3 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @exortek/apikey
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d370002: - `rehashApiKey` checks the presented secret against the stored hash before replacing it (anyone knowing a key id could overwrite its hash).
8
+ - `verifyApiKey` compares the secret before reporting `revoked` / `expired`, and accepts only the canonical spelling of a key (lowercase / I / O variants no longer verify).
9
+
10
+ ### Patch Changes
11
+
12
+ - 9953042: - Duration strings whose unit is the name of an `Object.prototype` member (for example `'1constructor'`) now throw instead of resolving to `NaN`.
13
+ - 9953042: - When a record is stored again under the same id with a different owner (`userId` / `email`), the previous owner no longer lists it, and per-owner bulk revocation (`revokeAllForUser`, `revokeByEmail`) no longer reaches it.
14
+
3
15
  ## 1.1.3
4
16
 
5
17
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -107,11 +107,13 @@ function parseDuration(input) {
107
107
  }
108
108
  const value = Number(match[1]);
109
109
  const unit = (match[2] || 'ms').toLowerCase();
110
- const multiplier = UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)];
111
- if (multiplier === undefined) {
110
+ // Own keys only: a plain lookup would resolve `constructor` (and every
111
+ // other Object.prototype member) to a function, turning '1constructor'
112
+ // into NaN instead of an error.
113
+ if (!Object.hasOwn(UNIT_MS, unit)) {
112
114
  throw new Error(`parseDuration: unknown time unit ${JSON.stringify(unit)}. Supported: ${SUPPORTED_UNITS}.`);
113
115
  }
114
- return Math.round(value * multiplier);
116
+ return Math.round(value * UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)]);
115
117
  }
116
118
 
117
119
  /**
@@ -887,6 +889,12 @@ function candidateHashesFor(rawSecret, peppers) {
887
889
  } catch {
888
890
  return { candidateHashes: [] };
889
891
  }
892
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
893
+ // many accepted spellings. A key must have exactly one: anything keyed on
894
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
895
+ if (encode(secretBytes) !== rawSecret) {
896
+ return { candidateHashes: [] };
897
+ }
890
898
  if (!peppers || peppers.length === 0) {
891
899
  return { candidateHashes: [hashSecret(secretBytes, null)] };
892
900
  }
@@ -1130,19 +1138,15 @@ async function verifyApiKey(rawKey, options) {
1130
1138
  if (!record) {
1131
1139
  return { valid: false, reason: 'not_found' };
1132
1140
  }
1133
- const now = _now(options.now);
1134
- if (record.revokedAt) {
1135
- return { valid: false, reason: 'revoked' };
1136
- }
1137
- if (record.expiresAt && record.expiresAt <= now) {
1138
- return { valid: false, reason: 'expired' };
1139
- }
1140
1141
  if (parsed.prefix !== record.prefix) {
1141
1142
  // Someone submitted a key whose id belongs to a different prefix —
1142
1143
  // treat as bad, not merely 'prefix_mismatch', because the id half
1143
1144
  // was correct: the attacker guessed the id and swapped the prefix.
1144
1145
  return { valid: false, reason: 'prefix_mismatch' };
1145
1146
  }
1147
+ // The secret is checked before lifecycle state: the id is not secret (it
1148
+ // is in every masked key), so answering 'revoked' / 'expired' first
1149
+ // would tell anyone holding an id the key's status without its secret.
1146
1150
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
1147
1151
  let matched = false;
1148
1152
  let matchedIndex = -1;
@@ -1156,6 +1160,13 @@ async function verifyApiKey(rawKey, options) {
1156
1160
  if (!matched) {
1157
1161
  return { valid: false, reason: 'bad_secret' };
1158
1162
  }
1163
+ const now = _now(options.now);
1164
+ if (record.revokedAt) {
1165
+ return { valid: false, reason: 'revoked' };
1166
+ }
1167
+ if (record.expiresAt && record.expiresAt <= now) {
1168
+ return { valid: false, reason: 'expired' };
1169
+ }
1159
1170
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
1160
1171
  return { valid: false, reason: 'missing_scope' };
1161
1172
  }
@@ -1280,6 +1291,17 @@ async function rehashApiKey(rawKey, options) {
1280
1291
  if (!parsed) {
1281
1292
  return false;
1282
1293
  }
1294
+ // Prove the caller holds the real secret before replacing its hash —
1295
+ // otherwise anyone who knows a key id (it is in every masked key) could
1296
+ // overwrite that key's hash with one for a secret of their choosing.
1297
+ const record = await options.store.getById(parsed.id);
1298
+ if (!record) {
1299
+ return false;
1300
+ }
1301
+ const { candidateHashes: existing } = candidateHashesFor(parsed.secret, peppers);
1302
+ if (!existing.some(candidate => hashesMatch(candidate, record.hash))) {
1303
+ return false;
1304
+ }
1283
1305
  const { candidateHashes } = candidateHashesFor(parsed.secret, [peppers[0]]);
1284
1306
  const newHash = candidateHashes[0];
1285
1307
  const updated = await options.store.update(parsed.id, { hash: newHash, pepperVersion: 0 });
package/dist/index.mjs CHANGED
@@ -105,11 +105,13 @@ function parseDuration(input) {
105
105
  }
106
106
  const value = Number(match[1]);
107
107
  const unit = (match[2] || 'ms').toLowerCase();
108
- const multiplier = UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)];
109
- if (multiplier === undefined) {
108
+ // Own keys only: a plain lookup would resolve `constructor` (and every
109
+ // other Object.prototype member) to a function, turning '1constructor'
110
+ // into NaN instead of an error.
111
+ if (!Object.hasOwn(UNIT_MS, unit)) {
110
112
  throw new Error(`parseDuration: unknown time unit ${JSON.stringify(unit)}. Supported: ${SUPPORTED_UNITS}.`);
111
113
  }
112
- return Math.round(value * multiplier);
114
+ return Math.round(value * UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)]);
113
115
  }
114
116
 
115
117
  /**
@@ -885,6 +887,12 @@ function candidateHashesFor(rawSecret, peppers) {
885
887
  } catch {
886
888
  return { candidateHashes: [] };
887
889
  }
890
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
891
+ // many accepted spellings. A key must have exactly one: anything keyed on
892
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
893
+ if (encode(secretBytes) !== rawSecret) {
894
+ return { candidateHashes: [] };
895
+ }
888
896
  if (!peppers || peppers.length === 0) {
889
897
  return { candidateHashes: [hashSecret(secretBytes, null)] };
890
898
  }
@@ -1128,19 +1136,15 @@ async function verifyApiKey(rawKey, options) {
1128
1136
  if (!record) {
1129
1137
  return { valid: false, reason: 'not_found' };
1130
1138
  }
1131
- const now = _now(options.now);
1132
- if (record.revokedAt) {
1133
- return { valid: false, reason: 'revoked' };
1134
- }
1135
- if (record.expiresAt && record.expiresAt <= now) {
1136
- return { valid: false, reason: 'expired' };
1137
- }
1138
1139
  if (parsed.prefix !== record.prefix) {
1139
1140
  // Someone submitted a key whose id belongs to a different prefix —
1140
1141
  // treat as bad, not merely 'prefix_mismatch', because the id half
1141
1142
  // was correct: the attacker guessed the id and swapped the prefix.
1142
1143
  return { valid: false, reason: 'prefix_mismatch' };
1143
1144
  }
1145
+ // The secret is checked before lifecycle state: the id is not secret (it
1146
+ // is in every masked key), so answering 'revoked' / 'expired' first
1147
+ // would tell anyone holding an id the key's status without its secret.
1144
1148
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
1145
1149
  let matched = false;
1146
1150
  let matchedIndex = -1;
@@ -1154,6 +1158,13 @@ async function verifyApiKey(rawKey, options) {
1154
1158
  if (!matched) {
1155
1159
  return { valid: false, reason: 'bad_secret' };
1156
1160
  }
1161
+ const now = _now(options.now);
1162
+ if (record.revokedAt) {
1163
+ return { valid: false, reason: 'revoked' };
1164
+ }
1165
+ if (record.expiresAt && record.expiresAt <= now) {
1166
+ return { valid: false, reason: 'expired' };
1167
+ }
1157
1168
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
1158
1169
  return { valid: false, reason: 'missing_scope' };
1159
1170
  }
@@ -1278,6 +1289,17 @@ async function rehashApiKey(rawKey, options) {
1278
1289
  if (!parsed) {
1279
1290
  return false;
1280
1291
  }
1292
+ // Prove the caller holds the real secret before replacing its hash —
1293
+ // otherwise anyone who knows a key id (it is in every masked key) could
1294
+ // overwrite that key's hash with one for a secret of their choosing.
1295
+ const record = await options.store.getById(parsed.id);
1296
+ if (!record) {
1297
+ return false;
1298
+ }
1299
+ const { candidateHashes: existing } = candidateHashesFor(parsed.secret, peppers);
1300
+ if (!existing.some(candidate => hashesMatch(candidate, record.hash))) {
1301
+ return false;
1302
+ }
1281
1303
  const { candidateHashes } = candidateHashesFor(parsed.secret, [peppers[0]]);
1282
1304
  const newHash = candidateHashes[0];
1283
1305
  const updated = await options.store.update(parsed.id, { hash: newHash, pepperVersion: 0 });
@@ -389,7 +389,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
389
389
  * @param {Buffer | Uint8Array} bytes
390
390
  * @returns {string}
391
391
  */
392
- function encode(bytes) {
392
+ function encode$1(bytes) {
393
393
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
394
394
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
395
395
  }
@@ -453,6 +453,39 @@ for (const [alias, canonical] of [
453
453
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
454
454
  }
455
455
 
456
+ /**
457
+ * Encode bytes as a Crockford base32 string.
458
+ *
459
+ * @param {Buffer | Uint8Array} bytes
460
+ * @returns {string}
461
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
462
+ */
463
+ function encode(bytes) {
464
+ if (!(bytes instanceof Uint8Array)) {
465
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
466
+ }
467
+ if (bytes.length === 0) {
468
+ return '';
469
+ }
470
+
471
+ let out = '';
472
+ let bits = 0;
473
+ let value = 0;
474
+ for (let i = 0; i < bytes.length; i++) {
475
+ value = (value << 8) | bytes[i];
476
+ bits += 8;
477
+ while (bits >= 5) {
478
+ bits -= 5;
479
+ out += ALPHABET[(value >>> bits) & 0x1f];
480
+ value &= (1 << bits) - 1;
481
+ }
482
+ }
483
+ if (bits > 0) {
484
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
485
+ }
486
+ return out;
487
+ }
488
+
456
489
  /**
457
490
  * Decode a Crockford base32 string into a Buffer.
458
491
  *
@@ -612,7 +645,7 @@ function hashSecret(secretBytes, pepper) {
612
645
  const digest = pepper
613
646
  ? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
614
647
  : node_crypto.createHash('sha256').update(secretBytes).digest();
615
- return encode(digest);
648
+ return encode$1(digest);
616
649
  }
617
650
 
618
651
  /**
@@ -704,6 +737,12 @@ function candidateHashesFor(rawSecret, peppers) {
704
737
  } catch {
705
738
  return { candidateHashes: [] };
706
739
  }
740
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
741
+ // many accepted spellings. A key must have exactly one: anything keyed on
742
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
743
+ if (encode(secretBytes) !== rawSecret) {
744
+ return { candidateHashes: [] };
745
+ }
707
746
  if (!peppers || peppers.length === 0) {
708
747
  return { candidateHashes: [hashSecret(secretBytes, null)] };
709
748
  }
@@ -774,19 +813,15 @@ async function verifyApiKey(rawKey, options) {
774
813
  if (!record) {
775
814
  return { valid: false, reason: 'not_found' };
776
815
  }
777
- const now = _now(options.now);
778
- if (record.revokedAt) {
779
- return { valid: false, reason: 'revoked' };
780
- }
781
- if (record.expiresAt && record.expiresAt <= now) {
782
- return { valid: false, reason: 'expired' };
783
- }
784
816
  if (parsed.prefix !== record.prefix) {
785
817
  // Someone submitted a key whose id belongs to a different prefix —
786
818
  // treat as bad, not merely 'prefix_mismatch', because the id half
787
819
  // was correct: the attacker guessed the id and swapped the prefix.
788
820
  return { valid: false, reason: 'prefix_mismatch' };
789
821
  }
822
+ // The secret is checked before lifecycle state: the id is not secret (it
823
+ // is in every masked key), so answering 'revoked' / 'expired' first
824
+ // would tell anyone holding an id the key's status without its secret.
790
825
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
791
826
  let matched = false;
792
827
  let matchedIndex = -1;
@@ -800,6 +835,13 @@ async function verifyApiKey(rawKey, options) {
800
835
  if (!matched) {
801
836
  return { valid: false, reason: 'bad_secret' };
802
837
  }
838
+ const now = _now(options.now);
839
+ if (record.revokedAt) {
840
+ return { valid: false, reason: 'revoked' };
841
+ }
842
+ if (record.expiresAt && record.expiresAt <= now) {
843
+ return { valid: false, reason: 'expired' };
844
+ }
803
845
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
804
846
  return { valid: false, reason: 'missing_scope' };
805
847
  }
@@ -387,7 +387,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
387
387
  * @param {Buffer | Uint8Array} bytes
388
388
  * @returns {string}
389
389
  */
390
- function encode(bytes) {
390
+ function encode$1(bytes) {
391
391
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
392
392
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
393
393
  }
@@ -451,6 +451,39 @@ for (const [alias, canonical] of [
451
451
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
452
452
  }
453
453
 
454
+ /**
455
+ * Encode bytes as a Crockford base32 string.
456
+ *
457
+ * @param {Buffer | Uint8Array} bytes
458
+ * @returns {string}
459
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
460
+ */
461
+ function encode(bytes) {
462
+ if (!(bytes instanceof Uint8Array)) {
463
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
464
+ }
465
+ if (bytes.length === 0) {
466
+ return '';
467
+ }
468
+
469
+ let out = '';
470
+ let bits = 0;
471
+ let value = 0;
472
+ for (let i = 0; i < bytes.length; i++) {
473
+ value = (value << 8) | bytes[i];
474
+ bits += 8;
475
+ while (bits >= 5) {
476
+ bits -= 5;
477
+ out += ALPHABET[(value >>> bits) & 0x1f];
478
+ value &= (1 << bits) - 1;
479
+ }
480
+ }
481
+ if (bits > 0) {
482
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
483
+ }
484
+ return out;
485
+ }
486
+
454
487
  /**
455
488
  * Decode a Crockford base32 string into a Buffer.
456
489
  *
@@ -610,7 +643,7 @@ function hashSecret(secretBytes, pepper) {
610
643
  const digest = pepper
611
644
  ? createHmac('sha256', pepper).update(secretBytes).digest()
612
645
  : createHash('sha256').update(secretBytes).digest();
613
- return encode(digest);
646
+ return encode$1(digest);
614
647
  }
615
648
 
616
649
  /**
@@ -702,6 +735,12 @@ function candidateHashesFor(rawSecret, peppers) {
702
735
  } catch {
703
736
  return { candidateHashes: [] };
704
737
  }
738
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
739
+ // many accepted spellings. A key must have exactly one: anything keyed on
740
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
741
+ if (encode(secretBytes) !== rawSecret) {
742
+ return { candidateHashes: [] };
743
+ }
705
744
  if (!peppers || peppers.length === 0) {
706
745
  return { candidateHashes: [hashSecret(secretBytes, null)] };
707
746
  }
@@ -772,19 +811,15 @@ async function verifyApiKey(rawKey, options) {
772
811
  if (!record) {
773
812
  return { valid: false, reason: 'not_found' };
774
813
  }
775
- const now = _now(options.now);
776
- if (record.revokedAt) {
777
- return { valid: false, reason: 'revoked' };
778
- }
779
- if (record.expiresAt && record.expiresAt <= now) {
780
- return { valid: false, reason: 'expired' };
781
- }
782
814
  if (parsed.prefix !== record.prefix) {
783
815
  // Someone submitted a key whose id belongs to a different prefix —
784
816
  // treat as bad, not merely 'prefix_mismatch', because the id half
785
817
  // was correct: the attacker guessed the id and swapped the prefix.
786
818
  return { valid: false, reason: 'prefix_mismatch' };
787
819
  }
820
+ // The secret is checked before lifecycle state: the id is not secret (it
821
+ // is in every masked key), so answering 'revoked' / 'expired' first
822
+ // would tell anyone holding an id the key's status without its secret.
788
823
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
789
824
  let matched = false;
790
825
  let matchedIndex = -1;
@@ -798,6 +833,13 @@ async function verifyApiKey(rawKey, options) {
798
833
  if (!matched) {
799
834
  return { valid: false, reason: 'bad_secret' };
800
835
  }
836
+ const now = _now(options.now);
837
+ if (record.revokedAt) {
838
+ return { valid: false, reason: 'revoked' };
839
+ }
840
+ if (record.expiresAt && record.expiresAt <= now) {
841
+ return { valid: false, reason: 'expired' };
842
+ }
801
843
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
802
844
  return { valid: false, reason: 'missing_scope' };
803
845
  }
@@ -450,7 +450,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
450
450
  * @param {Buffer | Uint8Array} bytes
451
451
  * @returns {string}
452
452
  */
453
- function encode(bytes) {
453
+ function encode$1(bytes) {
454
454
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
455
455
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
456
456
  }
@@ -514,6 +514,39 @@ for (const [alias, canonical] of [
514
514
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
515
515
  }
516
516
 
517
+ /**
518
+ * Encode bytes as a Crockford base32 string.
519
+ *
520
+ * @param {Buffer | Uint8Array} bytes
521
+ * @returns {string}
522
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
523
+ */
524
+ function encode(bytes) {
525
+ if (!(bytes instanceof Uint8Array)) {
526
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
527
+ }
528
+ if (bytes.length === 0) {
529
+ return '';
530
+ }
531
+
532
+ let out = '';
533
+ let bits = 0;
534
+ let value = 0;
535
+ for (let i = 0; i < bytes.length; i++) {
536
+ value = (value << 8) | bytes[i];
537
+ bits += 8;
538
+ while (bits >= 5) {
539
+ bits -= 5;
540
+ out += ALPHABET[(value >>> bits) & 0x1f];
541
+ value &= (1 << bits) - 1;
542
+ }
543
+ }
544
+ if (bits > 0) {
545
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
546
+ }
547
+ return out;
548
+ }
549
+
517
550
  /**
518
551
  * Decode a Crockford base32 string into a Buffer.
519
552
  *
@@ -673,7 +706,7 @@ function hashSecret(secretBytes, pepper) {
673
706
  const digest = pepper
674
707
  ? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
675
708
  : node_crypto.createHash('sha256').update(secretBytes).digest();
676
- return encode(digest);
709
+ return encode$1(digest);
677
710
  }
678
711
 
679
712
  /**
@@ -765,6 +798,12 @@ function candidateHashesFor(rawSecret, peppers) {
765
798
  } catch {
766
799
  return { candidateHashes: [] };
767
800
  }
801
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
802
+ // many accepted spellings. A key must have exactly one: anything keyed on
803
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
804
+ if (encode(secretBytes) !== rawSecret) {
805
+ return { candidateHashes: [] };
806
+ }
768
807
  if (!peppers || peppers.length === 0) {
769
808
  return { candidateHashes: [hashSecret(secretBytes, null)] };
770
809
  }
@@ -835,19 +874,15 @@ async function verifyApiKey(rawKey, options) {
835
874
  if (!record) {
836
875
  return { valid: false, reason: 'not_found' };
837
876
  }
838
- const now = _now(options.now);
839
- if (record.revokedAt) {
840
- return { valid: false, reason: 'revoked' };
841
- }
842
- if (record.expiresAt && record.expiresAt <= now) {
843
- return { valid: false, reason: 'expired' };
844
- }
845
877
  if (parsed.prefix !== record.prefix) {
846
878
  // Someone submitted a key whose id belongs to a different prefix —
847
879
  // treat as bad, not merely 'prefix_mismatch', because the id half
848
880
  // was correct: the attacker guessed the id and swapped the prefix.
849
881
  return { valid: false, reason: 'prefix_mismatch' };
850
882
  }
883
+ // The secret is checked before lifecycle state: the id is not secret (it
884
+ // is in every masked key), so answering 'revoked' / 'expired' first
885
+ // would tell anyone holding an id the key's status without its secret.
851
886
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
852
887
  let matched = false;
853
888
  let matchedIndex = -1;
@@ -861,6 +896,13 @@ async function verifyApiKey(rawKey, options) {
861
896
  if (!matched) {
862
897
  return { valid: false, reason: 'bad_secret' };
863
898
  }
899
+ const now = _now(options.now);
900
+ if (record.revokedAt) {
901
+ return { valid: false, reason: 'revoked' };
902
+ }
903
+ if (record.expiresAt && record.expiresAt <= now) {
904
+ return { valid: false, reason: 'expired' };
905
+ }
864
906
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
865
907
  return { valid: false, reason: 'missing_scope' };
866
908
  }
@@ -448,7 +448,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
448
448
  * @param {Buffer | Uint8Array} bytes
449
449
  * @returns {string}
450
450
  */
451
- function encode(bytes) {
451
+ function encode$1(bytes) {
452
452
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
453
453
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
454
454
  }
@@ -512,6 +512,39 @@ for (const [alias, canonical] of [
512
512
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
513
513
  }
514
514
 
515
+ /**
516
+ * Encode bytes as a Crockford base32 string.
517
+ *
518
+ * @param {Buffer | Uint8Array} bytes
519
+ * @returns {string}
520
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
521
+ */
522
+ function encode(bytes) {
523
+ if (!(bytes instanceof Uint8Array)) {
524
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
525
+ }
526
+ if (bytes.length === 0) {
527
+ return '';
528
+ }
529
+
530
+ let out = '';
531
+ let bits = 0;
532
+ let value = 0;
533
+ for (let i = 0; i < bytes.length; i++) {
534
+ value = (value << 8) | bytes[i];
535
+ bits += 8;
536
+ while (bits >= 5) {
537
+ bits -= 5;
538
+ out += ALPHABET[(value >>> bits) & 0x1f];
539
+ value &= (1 << bits) - 1;
540
+ }
541
+ }
542
+ if (bits > 0) {
543
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
544
+ }
545
+ return out;
546
+ }
547
+
515
548
  /**
516
549
  * Decode a Crockford base32 string into a Buffer.
517
550
  *
@@ -671,7 +704,7 @@ function hashSecret(secretBytes, pepper) {
671
704
  const digest = pepper
672
705
  ? createHmac('sha256', pepper).update(secretBytes).digest()
673
706
  : createHash('sha256').update(secretBytes).digest();
674
- return encode(digest);
707
+ return encode$1(digest);
675
708
  }
676
709
 
677
710
  /**
@@ -763,6 +796,12 @@ function candidateHashesFor(rawSecret, peppers) {
763
796
  } catch {
764
797
  return { candidateHashes: [] };
765
798
  }
799
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
800
+ // many accepted spellings. A key must have exactly one: anything keyed on
801
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
802
+ if (encode(secretBytes) !== rawSecret) {
803
+ return { candidateHashes: [] };
804
+ }
766
805
  if (!peppers || peppers.length === 0) {
767
806
  return { candidateHashes: [hashSecret(secretBytes, null)] };
768
807
  }
@@ -833,19 +872,15 @@ async function verifyApiKey(rawKey, options) {
833
872
  if (!record) {
834
873
  return { valid: false, reason: 'not_found' };
835
874
  }
836
- const now = _now(options.now);
837
- if (record.revokedAt) {
838
- return { valid: false, reason: 'revoked' };
839
- }
840
- if (record.expiresAt && record.expiresAt <= now) {
841
- return { valid: false, reason: 'expired' };
842
- }
843
875
  if (parsed.prefix !== record.prefix) {
844
876
  // Someone submitted a key whose id belongs to a different prefix —
845
877
  // treat as bad, not merely 'prefix_mismatch', because the id half
846
878
  // was correct: the attacker guessed the id and swapped the prefix.
847
879
  return { valid: false, reason: 'prefix_mismatch' };
848
880
  }
881
+ // The secret is checked before lifecycle state: the id is not secret (it
882
+ // is in every masked key), so answering 'revoked' / 'expired' first
883
+ // would tell anyone holding an id the key's status without its secret.
849
884
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
850
885
  let matched = false;
851
886
  let matchedIndex = -1;
@@ -859,6 +894,13 @@ async function verifyApiKey(rawKey, options) {
859
894
  if (!matched) {
860
895
  return { valid: false, reason: 'bad_secret' };
861
896
  }
897
+ const now = _now(options.now);
898
+ if (record.revokedAt) {
899
+ return { valid: false, reason: 'revoked' };
900
+ }
901
+ if (record.expiresAt && record.expiresAt <= now) {
902
+ return { valid: false, reason: 'expired' };
903
+ }
862
904
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
863
905
  return { valid: false, reason: 'missing_scope' };
864
906
  }
package/dist/stores.cjs CHANGED
@@ -169,6 +169,8 @@ function detectDialect(client) {
169
169
  * smembers: (key: string) => Promise<string[]>,
170
170
  * setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
171
171
  * setPlain: (key: string, value: string) => Promise<void>,
172
+ * setKeepTTL: (key: string, value: string) => Promise<void>,
173
+ * setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
172
174
  * parseRecord: (raw: string|object|null) => object|null,
173
175
  * }}
174
176
  */
@@ -238,6 +240,29 @@ function createRedisHelpers(client) {
238
240
  await client.set(key, value);
239
241
  },
240
242
 
243
+ // Overwrite a value but keep whatever expiry the key already has
244
+ // (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
245
+ // update to an expiring record would otherwise make it immortal.
246
+ async setKeepTTL(key, value) {
247
+ if (dialect === 'ioredis') {
248
+ await client.set(key, value, 'KEEPTTL');
249
+ } else {
250
+ await client.set(key, value, { KEEPTTL: true });
251
+ }
252
+ },
253
+
254
+ // Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
255
+ // created the key, false when it already existed — a cross-process
256
+ // compare-and-set for "only the first caller wins".
257
+ async setIfAbsent(key, value, ttlMs) {
258
+ const px = Math.max(1, Math.ceil(ttlMs));
259
+ const reply =
260
+ dialect === 'ioredis'
261
+ ? await client.set(key, value, 'PX', px, 'NX')
262
+ : await client.set(key, value, { PX: px, NX: true });
263
+ return reply === 'OK';
264
+ },
265
+
241
266
  parseRecord(raw) {
242
267
  if (!raw) {
243
268
  return null;
@@ -333,8 +358,15 @@ function createMemoryRecordStore(config) {
333
358
  byIndex,
334
359
 
335
360
  async put(record) {
336
- byId.set(record[idField], copy(record));
337
- addIndex(record[indexField], record[idField]);
361
+ const id = record[idField];
362
+ const previous = byId.get(id);
363
+ // Overwriting a record under a different owner must drop the old
364
+ // owner's index entry, or the old owner keeps listing it.
365
+ if (previous && previous[indexField] !== record[indexField]) {
366
+ removeIndex(previous[indexField], id);
367
+ }
368
+ byId.set(id, copy(record));
369
+ addIndex(record[indexField], id);
338
370
  },
339
371
 
340
372
  async getById(id) {
@@ -399,7 +431,7 @@ function createMemoryRecordStore(config) {
399
431
  * @returns {{
400
432
  * put: (record: object, ttlMs?: number) => Promise<void>,
401
433
  * getById: (id: string) => Promise<object|null>,
402
- * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
434
+ * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
403
435
  * listByIndex: (indexValue: string) => Promise<object[]>,
404
436
  * readRecord: (id: string) => Promise<object|null>,
405
437
  * writeRecord: (record: object, ttlMs?: number) => Promise<void>,
@@ -464,6 +496,16 @@ function createRedisRecordStore(client, config) {
464
496
  }
465
497
  }
466
498
 
499
+ // An update with no explicit TTL keeps the key's remaining expiry
500
+ // instead of clearing it.
501
+ async function rewriteRecord(record, ttlMs) {
502
+ if (ttl && ttlMs === undefined) {
503
+ await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
504
+ return;
505
+ }
506
+ await writeRecord(record, ttlMs);
507
+ }
508
+
467
509
  async function writeTombstone(id, data, ttlMs) {
468
510
  const value = JSON.stringify(data);
469
511
  if (ttl && ttlMs !== undefined) {
@@ -500,7 +542,10 @@ function createRedisRecordStore(client, config) {
500
542
  const raw = rows[i * stride];
501
543
  const rawTomb = tombstones ? rows[i * stride + 1] : null;
502
544
  const record = helpers.parseRecord(raw);
503
- if (!record) {
545
+ // Gone, or re-put under another owner (`put` does not read the old
546
+ // record, so it cannot unlink the old index entry itself) — prune the
547
+ // stale entry and never hand the record to this owner.
548
+ if (!record || record[indexField] !== indexValue) {
504
549
  await indexRemove(indexValue, ids[i]);
505
550
  pairs.push([ids[i], null]);
506
551
  continue;
@@ -529,7 +574,7 @@ function createRedisRecordStore(client, config) {
529
574
  }
530
575
  const oldIndex = existing[indexField];
531
576
  const next = { ...existing, ...patch };
532
- await writeRecord(next, ttlMs);
577
+ await rewriteRecord(next, ttlMs);
533
578
  if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
534
579
  if (isString(oldIndex)) {
535
580
  await indexRemove(oldIndex, id);
package/dist/stores.mjs CHANGED
@@ -167,6 +167,8 @@ function detectDialect(client) {
167
167
  * smembers: (key: string) => Promise<string[]>,
168
168
  * setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
169
169
  * setPlain: (key: string, value: string) => Promise<void>,
170
+ * setKeepTTL: (key: string, value: string) => Promise<void>,
171
+ * setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
170
172
  * parseRecord: (raw: string|object|null) => object|null,
171
173
  * }}
172
174
  */
@@ -236,6 +238,29 @@ function createRedisHelpers(client) {
236
238
  await client.set(key, value);
237
239
  },
238
240
 
241
+ // Overwrite a value but keep whatever expiry the key already has
242
+ // (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
243
+ // update to an expiring record would otherwise make it immortal.
244
+ async setKeepTTL(key, value) {
245
+ if (dialect === 'ioredis') {
246
+ await client.set(key, value, 'KEEPTTL');
247
+ } else {
248
+ await client.set(key, value, { KEEPTTL: true });
249
+ }
250
+ },
251
+
252
+ // Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
253
+ // created the key, false when it already existed — a cross-process
254
+ // compare-and-set for "only the first caller wins".
255
+ async setIfAbsent(key, value, ttlMs) {
256
+ const px = Math.max(1, Math.ceil(ttlMs));
257
+ const reply =
258
+ dialect === 'ioredis'
259
+ ? await client.set(key, value, 'PX', px, 'NX')
260
+ : await client.set(key, value, { PX: px, NX: true });
261
+ return reply === 'OK';
262
+ },
263
+
239
264
  parseRecord(raw) {
240
265
  if (!raw) {
241
266
  return null;
@@ -331,8 +356,15 @@ function createMemoryRecordStore(config) {
331
356
  byIndex,
332
357
 
333
358
  async put(record) {
334
- byId.set(record[idField], copy(record));
335
- addIndex(record[indexField], record[idField]);
359
+ const id = record[idField];
360
+ const previous = byId.get(id);
361
+ // Overwriting a record under a different owner must drop the old
362
+ // owner's index entry, or the old owner keeps listing it.
363
+ if (previous && previous[indexField] !== record[indexField]) {
364
+ removeIndex(previous[indexField], id);
365
+ }
366
+ byId.set(id, copy(record));
367
+ addIndex(record[indexField], id);
336
368
  },
337
369
 
338
370
  async getById(id) {
@@ -397,7 +429,7 @@ function createMemoryRecordStore(config) {
397
429
  * @returns {{
398
430
  * put: (record: object, ttlMs?: number) => Promise<void>,
399
431
  * getById: (id: string) => Promise<object|null>,
400
- * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
432
+ * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
401
433
  * listByIndex: (indexValue: string) => Promise<object[]>,
402
434
  * readRecord: (id: string) => Promise<object|null>,
403
435
  * writeRecord: (record: object, ttlMs?: number) => Promise<void>,
@@ -462,6 +494,16 @@ function createRedisRecordStore(client, config) {
462
494
  }
463
495
  }
464
496
 
497
+ // An update with no explicit TTL keeps the key's remaining expiry
498
+ // instead of clearing it.
499
+ async function rewriteRecord(record, ttlMs) {
500
+ if (ttl && ttlMs === undefined) {
501
+ await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
502
+ return;
503
+ }
504
+ await writeRecord(record, ttlMs);
505
+ }
506
+
465
507
  async function writeTombstone(id, data, ttlMs) {
466
508
  const value = JSON.stringify(data);
467
509
  if (ttl && ttlMs !== undefined) {
@@ -498,7 +540,10 @@ function createRedisRecordStore(client, config) {
498
540
  const raw = rows[i * stride];
499
541
  const rawTomb = tombstones ? rows[i * stride + 1] : null;
500
542
  const record = helpers.parseRecord(raw);
501
- if (!record) {
543
+ // Gone, or re-put under another owner (`put` does not read the old
544
+ // record, so it cannot unlink the old index entry itself) — prune the
545
+ // stale entry and never hand the record to this owner.
546
+ if (!record || record[indexField] !== indexValue) {
502
547
  await indexRemove(indexValue, ids[i]);
503
548
  pairs.push([ids[i], null]);
504
549
  continue;
@@ -527,7 +572,7 @@ function createRedisRecordStore(client, config) {
527
572
  }
528
573
  const oldIndex = existing[indexField];
529
574
  const next = { ...existing, ...patch };
530
- await writeRecord(next, ttlMs);
575
+ await rewriteRecord(next, ttlMs);
531
576
  if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
532
577
  if (isString(oldIndex)) {
533
578
  await indexRemove(oldIndex, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exortek/apikey",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Stripe-style prefixed API keys for Node.js — 3-segment tokens (prefix_id_secret), HMAC-SHA256 storage hash with pepper rotation, scope allowlists with wildcard match, opt-in lastUsedAt tracking, memory + Redis stores, Express + Fastify middleware. Server-only. Zero non-@exortek runtime dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,