@1auth/crypto 0.0.0-beta.2 → 0.0.0-rc.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.
Files changed (2) hide show
  1. package/index.js +85 -57
  2. package/package.json +2 -3
package/index.js CHANGED
@@ -41,7 +41,7 @@ const defaults = {
41
41
  secretArgon2Algorithm: "argon2id",
42
42
  secretArgon2Version: 19,
43
43
  secretArgon2Parallelism: 1, // OWASP: 1 (matches)
44
- secretArgon2MemoryCost: 15, // 2^15 KiB = 32 MiB (exceeds OWASP minimum of 19 MiB)
44
+ secretArgon2MemoryCost: 15, // log2 exponent: 2^15 KiB = 32 MiB (exceeds OWASP minimum of 19 MiB)
45
45
  secretArgon2TimeCost: 3, // OWASP: 2 (we use 3 for better security)
46
46
  secretArgon2NonceLength: 16,
47
47
  secretArgon2HashLength: 64,
@@ -93,24 +93,13 @@ export default (opt = {}) => {
93
93
  options.digestChecksumHashAlgorithm ??= options.defaultHashAlgorithm;
94
94
  options.digestChecksumEncoding ??= options.defaultEncoding;
95
95
 
96
- // Secrets
97
- Object.assign(argon2Options, {
98
- algorithm: options.secretArgon2Algorithm,
99
- version: options.secretArgon2Version, // argon2id
100
- parallelism: options.secretArgon2Parallelism, // argon2id
101
- memoryCost: options.secretArgon2MemoryCost, // argon2id
102
- timeCost: options.secretArgon2TimeCost, // argon2id
103
- nonceLength: options.secretArgon2NonceLength, // argon2id
104
- hashLength: options.secretArgon2HashLength, // argon2id
105
- });
106
-
107
- // Lengths
108
- symmetricEncryptionEncodingLengths.iv = randomIV().toString(
109
- options.symmetricEncryptionEncoding,
110
- ).length;
96
+ // Encoded lengths for parsing ciphertext packets (IV = 12 bytes, authTag = 16 bytes)
97
+ const encodedLength = (byteLength) =>
98
+ Buffer.alloc(byteLength).toString(options.symmetricEncryptionEncoding)
99
+ .length;
100
+ symmetricEncryptionEncodingLengths.iv = encodedLength(12);
111
101
  symmetricEncryptionEncodingLengths.ivAndAuthTag =
112
- symmetricEncryptionEncodingLengths.iv +
113
- randomBytes(16).toString(options.symmetricEncryptionEncoding).length;
102
+ symmetricEncryptionEncodingLengths.iv + encodedLength(authTagLength);
114
103
  };
115
104
 
116
105
  export const makeOptionsBuffer = (
@@ -142,9 +131,7 @@ export const charactersAlpha = charactersAlphaUpper + charactersAlphaLower;
142
131
  export const charactersAlphaNumeric = charactersAlpha + charactersNumeric;
143
132
  export const charactersDistinguishable = "CDEHKMPRTUWXY012458";
144
133
 
145
- const randomCharactersCache = {
146
- charactersAlphaNumeric: customAlphabet(charactersAlphaNumeric),
147
- };
134
+ const randomCharactersCache = {};
148
135
  export const randomCharacters = (
149
136
  length,
150
137
  characters = charactersAlphaNumeric,
@@ -201,6 +188,10 @@ export const createSaltedValue = (value, { checksumSalt } = {}) => {
201
188
  const newValue = value + checksumSalt;
202
189
  return newValue;
203
190
  };
191
+ // Deterministic encryption using a fixed IV (checksumPepper) to enable
192
+ // privacy-compliant digest lookups. Rotating the pepper invalidates all
193
+ // existing digests, supporting GDPR right-to-erasure workflows.
194
+ // The ciphertexts are never stored directly - only their hashes are persisted.
204
195
  export const createPepperedValue = (
205
196
  value,
206
197
  { checksumPepper, encryptionKey } = {},
@@ -284,18 +275,24 @@ export const createSeasonedDigest = (
284
275
  };
285
276
 
286
277
  // *** Hashing *** //
287
- const argon2Options = {
288
- algorithm: "argon2id",
289
- version: 19,
290
- parallelism: 1, // Default 1
291
- memoryCost: 15, // memory 2^memoryCost // Default 2 ** 12 = 4MB
292
- timeCost: 3, // Default 3
293
- nonceLength: 16,
294
- hashLength: 64, // hashLength: 128 // Default 32
295
-
296
- secret: undefined, // pepper
297
- associatedData: undefined, // sub
278
+ // `memoryCost` is a log2 exponent, NOT KiB: memory = 2 ** memoryCost KiB.
279
+ // Passing KiB (e.g. 2 ** 15) silently asked for 2 ** 32768 KiB = Infinity, which
280
+ // surfaced as an opaque failure deep inside node's argon2 binding. 31 caps it at
281
+ // 2 TiB, far above anything real, so anything larger is a units mistake.
282
+ export const assertMemoryCost = (memoryCost) => {
283
+ if (!Number.isInteger(memoryCost) || memoryCost < 3 || memoryCost > 31) {
284
+ throw new RangeError(
285
+ `memoryCost must be a log2 exponent between 3 and 31, received ${memoryCost}`,
286
+ { cause: { memoryCost } },
287
+ );
288
+ }
298
289
  };
290
+
291
+ // NOTE: the PHC string spec defines `m=` as memory in KiB, but we write the
292
+ // exponent. Every stored hash encodes it this way, so correcting it is a format
293
+ // migration (decode both forms, rehash on next verify), not an edit.
294
+ // ponytail: non-standard `m=`, only ever read back by this library. Fix it when
295
+ // a hash has to be verified by something that is not @1auth/crypto.
299
296
  export const encodeArgon2 = ({
300
297
  algorithm,
301
298
  version,
@@ -357,6 +354,7 @@ export const createArgon2 = (
357
354
  parallelism ??= options.secretArgon2Parallelism;
358
355
  nonceLength ??= options.secretArgon2NonceLength;
359
356
  hashLength ??= options.secretArgon2HashLength;
357
+ assertMemoryCost(memoryCost);
360
358
 
361
359
  const nonce = randomBytes(nonceLength);
362
360
  const hash = argon2Sync(algorithm, {
@@ -387,6 +385,9 @@ export const verifyArgon2 = (derivedKey, message) => {
387
385
  hash,
388
386
  hashLength,
389
387
  } = decodeArgon2(derivedKey);
388
+ // Params come off a stored string; a malformed `m=` would otherwise reach
389
+ // argon2Sync as Infinity. authn treats a throw here as "credential invalid".
390
+ assertMemoryCost(memoryCost);
390
391
 
391
392
  const verifyHash = argon2Sync(algorithm, {
392
393
  message,
@@ -399,16 +400,18 @@ export const verifyArgon2 = (derivedKey, message) => {
399
400
  return timingSafeEqual(hash, verifyHash);
400
401
  };
401
402
 
402
- export const createSecretHash = async (value, options) => {
403
- return createArgon2(value, options);
404
- };
405
-
406
- export const verifySecretHash = async (hash, value) => {
407
- return verifyArgon2(hash, value);
408
- };
403
+ export const createSecretHash = createArgon2;
404
+ export const verifySecretHash = verifyArgon2;
409
405
 
410
406
  // *** Symmetric Encryption *** //
411
407
  const authTagLength = 16;
408
+ // 16 is already node's default for both supported AEAD ciphers, so this is
409
+ // belt-and-braces against a future cipher whose default differs.
410
+ // Stryker disable next-line ObjectLiteral: byte-identical output either way
411
+ const cipherOptions = { authTagLength };
412
+ // The subject is bound into the ciphertext as associated data. Buffer.from
413
+ // decodes utf8 by default, which is what every stored packet was written with.
414
+ const associatedData = (sub) => Buffer.from(sub);
412
415
 
413
416
  export const symmetricRandomEncryptionKey = () => {
414
417
  return randomBytes(32); // 256 bits
@@ -438,6 +441,9 @@ export const symmetricGenerateEncryptionKey = (
438
441
  export const symmetricEncryptFields = (
439
442
  values,
440
443
  { encryptedKey, encryptionKey, signatureSecret, sub },
444
+ // naming a field the values do not carry is a no-op: `values[key] &&= ...`
445
+ // never assigns, so no default other than empty is observable
446
+ // Stryker disable next-line ArrayDeclaration
441
447
  fields = [],
442
448
  ) => {
443
449
  if (encryptedKey) {
@@ -469,6 +475,8 @@ export const symmetricEncrypt = (
469
475
  });
470
476
  }
471
477
  if (!encryptionKey || !data) return data;
478
+ // Stryker disable next-line StringLiteral: node's normalizeEncoding maps "" to
479
+ // utf8, so the two spellings are the same encoding to every Buffer API
472
480
  decoding ??= "utf8";
473
481
  encoding ??= options.symmetricEncryptionEncoding;
474
482
  iv ??= randomIV();
@@ -476,11 +484,9 @@ export const symmetricEncrypt = (
476
484
  options.symmetricEncryptionAlgorithm,
477
485
  encryptionKey,
478
486
  iv,
479
- {
480
- authTagLength,
481
- },
487
+ cipherOptions,
482
488
  );
483
- cipher.setAAD(sub);
489
+ cipher.setAAD(associatedData(sub));
484
490
  const encryptedData =
485
491
  cipher.update(data, decoding, encoding) + cipher.final(encoding);
486
492
  const authTag = cipher.getAuthTag();
@@ -488,13 +494,16 @@ export const symmetricEncrypt = (
488
494
  const encryptedDataPacket =
489
495
  iv.toString(encoding) + authTag.toString(encoding) + encryptedData;
490
496
 
491
- // add signature to end
497
+ // Encrypt-then-MAC: HMAC signature wraps the AEAD ciphertext so that
498
+ // signature secrets can be rotated independently without re-encryption.
492
499
  return symmetricSignatureSign(encryptedDataPacket, { signatureSecret });
493
500
  };
494
501
 
495
502
  export const symmetricDecryptFields = (
496
503
  encryptedValues,
497
504
  { encryptedKey, encryptionKey, signatureSecret, sub },
505
+ // see symmetricEncryptFields
506
+ // Stryker disable next-line ArrayDeclaration
498
507
  fields = [],
499
508
  ) => {
500
509
  if (encryptedKey) {
@@ -545,6 +554,7 @@ export const symmetricDecrypt = (
545
554
  if (!encryptionKey || !signedEncryptedDataPacket)
546
555
  return signedEncryptedDataPacket;
547
556
  decoding ??= options.symmetricEncryptionEncoding;
557
+ // Stryker disable next-line StringLiteral: see symmetricEncrypt's decoding
548
558
  encoding ??= "utf8";
549
559
 
550
560
  // remove signature when successful
@@ -582,11 +592,9 @@ export const symmetricDecrypt = (
582
592
  options.symmetricEncryptionAlgorithm,
583
593
  encryptionKey,
584
594
  iv,
585
- {
586
- authTagLength,
587
- },
595
+ cipherOptions,
588
596
  );
589
- decipher.setAAD(sub);
597
+ decipher.setAAD(associatedData(sub));
590
598
 
591
599
  decipher.setAuthTag(authTag);
592
600
  const data =
@@ -611,10 +619,14 @@ export const symmetricSignatureSign = (
611
619
  ) => {
612
620
  signatureSecret ??= options.symmetricSignatureSecret;
613
621
  hashAlgorithm ??= options.symmetricSignatureHashAlgorithm;
614
- const signature = createHmac(hashAlgorithm, signatureSecret)
622
+ const digest = createHmac(hashAlgorithm, signatureSecret)
615
623
  .update(data)
616
- .digest(options.symmetricSignatureEncoding)
617
- .replace(/=+$/, "");
624
+ .digest(options.symmetricSignatureEncoding);
625
+ // base64 uses `=` only as trailing padding, so an unanchored match can never
626
+ // find one mid-string; the anchor documents the intent and still holds if the
627
+ // encoding ever changes.
628
+ // Stryker disable next-line Regex: equivalent for every padded encoding
629
+ const signature = digest.replace(/=+$/, "");
618
630
 
619
631
  const signedData = `${data}.${signature}`;
620
632
  return signedData;
@@ -625,11 +637,12 @@ export const symmetricSignatureVerify = (
625
637
  { hashAlgorithm, signatureSecret } = {},
626
638
  ) => {
627
639
  if (typeof signedData !== "string") return false;
628
- let lastIndexOf = signedData.lastIndexOf(".");
629
- // Test for unsigned
630
- if (lastIndexOf < 0) {
631
- lastIndexOf = signedData.length;
632
- }
640
+ const lastIndexOf = signedData.lastIndexOf(".");
641
+ // Reject unsigned data
642
+ // Stryker disable next-line ConditionalExpression: without this guard the
643
+ // comparison below still fails for unsigned data, but only by accident of
644
+ // substring(0, -1); the explicit rejection is the contract.
645
+ if (lastIndexOf < 0) return false;
633
646
  const data = signedData.substring(0, lastIndexOf);
634
647
  const signedDataExpected = symmetricSignatureSign(data, {
635
648
  hashAlgorithm,
@@ -649,7 +662,7 @@ export const symmetricRotation = (
649
662
  return data;
650
663
  },
651
664
  ) => {
652
- if (oldOptions.sub !== newOptions.sub)
665
+ if (newOptions && oldOptions.sub !== newOptions.sub)
653
666
  throw new Error("Mismatching `sub`", {
654
667
  cause: { sub: oldOptions.sub },
655
668
  });
@@ -742,6 +755,21 @@ export const verifyAsymmetricSignature = async (
742
755
 
743
756
  export const nowInSeconds = () => Math.floor(Date.now() / 1000);
744
757
 
758
+ // *** Argument guards *** //
759
+ // `cause` is for developer debugging only, never expose to end users.
760
+ // Lives here because every package already depends on @1auth/crypto.
761
+ export const assertSub = (sub, cause) => {
762
+ if (!sub || typeof sub !== "string") {
763
+ throw new Error("401 Unauthorized", { cause: { sub, ...cause } });
764
+ }
765
+ };
766
+
767
+ export const assertId = (id, cause) => {
768
+ if (!id || typeof id !== "string") {
769
+ throw new Error("404 Not Found", { cause: { id, ...cause } });
770
+ }
771
+ };
772
+
745
773
  export const safeEqual = (input, expected) => {
746
774
  const bufferInput = Buffer.from(input);
747
775
  const bufferExpected = Buffer.from(expected);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1auth/crypto",
3
- "version": "0.0.0-beta.2",
3
+ "version": "0.0.0-rc.0",
4
4
  "description": "Cryptographic utilities for encryption, hashing, and signing with modern algorithms",
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,8 +51,7 @@
51
51
  "url": "https://github.com/willfarrell/1auth/issues"
52
52
  },
53
53
  "homepage": "https://github.com/willfarrell/1auth",
54
- "gitHead": "7a6c0fbb8ab71d6a2171e678697de9f237568431",
55
54
  "dependencies": {
56
- "nanoid": "5.1.6"
55
+ "nanoid": "6.0.1"
57
56
  }
58
57
  }