@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.
- package/index.js +85 -57
- 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
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 =
|
|
403
|
-
|
|
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
|
-
//
|
|
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
|
|
622
|
+
const digest = createHmac(hashAlgorithm, signatureSecret)
|
|
615
623
|
.update(data)
|
|
616
|
-
.digest(options.symmetricSignatureEncoding)
|
|
617
|
-
|
|
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
|
-
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
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-
|
|
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": "
|
|
55
|
+
"nanoid": "6.0.1"
|
|
57
56
|
}
|
|
58
57
|
}
|