@sdxc/crypto 0.0.0-pre.1
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/LICENSE.md +21 -0
- package/README.md +490 -0
- package/dist/encoding.d.ts +109 -0
- package/dist/encoding.js +244 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.js +124 -0
- package/dist/hash.d.ts +52 -0
- package/dist/hash.js +77 -0
- package/dist/hmac.d.ts +65 -0
- package/dist/hmac.js +75 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/lib/base32.d.ts +33 -0
- package/dist/lib/base32.js +70 -0
- package/dist/lib/bytes.d.ts +45 -0
- package/dist/lib/bytes.js +44 -0
- package/dist/password.d.ts +61 -0
- package/dist/password.js +163 -0
- package/dist/random.d.ts +52 -0
- package/dist/random.js +50 -0
- package/dist/seal.d.ts +50 -0
- package/dist/seal.js +114 -0
- package/dist/timing-safe-equal.d.ts +24 -0
- package/dist/timing-safe-equal.js +34 -0
- package/dist/totp.d.ts +147 -0
- package/dist/totp.js +213 -0
- package/package.json +22 -0
package/dist/hmac.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC signing and verification over WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Webhooks, signed URLs, and alert payloads all need a keyed MAC, and the risky
|
|
5
|
+
* half is the comparison: `verify` derives the expected MAC and checks it in
|
|
6
|
+
* constant time so no call site has to remember to avoid `===`.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
12
|
+
import { Hex } from "./encoding.js";
|
|
13
|
+
import { CryptoError, UnsupportedAlgorithmError } from "./errors.js";
|
|
14
|
+
import { toBytes } from "./lib/bytes.js";
|
|
15
|
+
import { timingSafeEqual } from "./timing-safe-equal.js";
|
|
16
|
+
/** Hash functions WebCrypto exposes for HMAC keys. */
|
|
17
|
+
const SUPPORTED_HASHES = ["SHA-1", "SHA-256", "SHA-384", "SHA-512"];
|
|
18
|
+
/** Hash used when a caller does not ask for one. */
|
|
19
|
+
const DEFAULT_HASH = "SHA-256";
|
|
20
|
+
/**
|
|
21
|
+
* Signs a payload with a secret, returning the raw MAC bytes.
|
|
22
|
+
*
|
|
23
|
+
* @param secret Key material; text is read as UTF-8.
|
|
24
|
+
* @param payload Message to authenticate.
|
|
25
|
+
* @param options Hash selection.
|
|
26
|
+
* @returns MAC bytes, or a `CryptoError` when the hash is unsupported or the runtime refuses the key.
|
|
27
|
+
*/
|
|
28
|
+
async function sign(secret, payload, options = {}) {
|
|
29
|
+
let hash = options.hash ?? DEFAULT_HASH;
|
|
30
|
+
if (!SUPPORTED_HASHES.includes(hash))
|
|
31
|
+
return failure(new UnsupportedAlgorithmError(hash));
|
|
32
|
+
try {
|
|
33
|
+
let key = await crypto.subtle.importKey("raw", toBytes(secret), { name: "HMAC", hash }, false, [
|
|
34
|
+
"sign",
|
|
35
|
+
]);
|
|
36
|
+
let signature = await crypto.subtle.sign("HMAC", key, toBytes(payload));
|
|
37
|
+
return success(new Uint8Array(signature));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return failure(new CryptoError("HMAC signing failed"));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Recomputes the MAC for a payload and compares it in constant time. A
|
|
45
|
+
* signature given as a string decodes as hex; an undecodable string resolves
|
|
46
|
+
* to a mismatch, so a malformed header fails closed automatically.
|
|
47
|
+
*
|
|
48
|
+
* @param secret Key material; text is read as UTF-8.
|
|
49
|
+
* @param payload Message the signature is supposed to cover.
|
|
50
|
+
* @param signature MAC to check, as bytes or as a hex string.
|
|
51
|
+
* @param options Hash selection; must match the hash used to sign.
|
|
52
|
+
* @returns Whether the signature matches, or a `CryptoError` when the MAC could not be computed.
|
|
53
|
+
* @example
|
|
54
|
+
* let ok = await hmac.verify(secret, body, request.headers.get("x-signature") ?? "");
|
|
55
|
+
*/
|
|
56
|
+
async function verify(secret, payload, signature, options = {}) {
|
|
57
|
+
let expected = await sign(secret, payload, options);
|
|
58
|
+
if (isFailure(expected))
|
|
59
|
+
return expected;
|
|
60
|
+
if (typeof signature === "string") {
|
|
61
|
+
let decoded = Hex.decode(signature);
|
|
62
|
+
if (isFailure(decoded))
|
|
63
|
+
return success(false);
|
|
64
|
+
return success(timingSafeEqual(expected.data, decoded.data));
|
|
65
|
+
}
|
|
66
|
+
return success(timingSafeEqual(expected.data, toBytes(signature)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Keyed message authentication with a constant-time verifier.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* let mac = await hmac.sign(secret, payload);
|
|
73
|
+
* let ok = await hmac.verify(secret, payload, Hex.encode(unwrap(mac)));
|
|
74
|
+
*/
|
|
75
|
+
export const hmac = { sign, verify };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebCrypto primitives with `Result`-based errors, portable to any runtime.
|
|
3
|
+
*
|
|
4
|
+
* Encoding, digests, HMAC, random tokens, password hashing, TOTP, and authenticated
|
|
5
|
+
* encryption live here once, so security-relevant details are decided in one place
|
|
6
|
+
* instead of being re-derived at every call site.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export type { BinaryLike, Bytes } from "./lib/bytes.js";
|
|
12
|
+
export { Base64, Base64Url, Hex } from "./encoding.js";
|
|
13
|
+
export { CryptoError, DecryptionError, InvalidEncodingError, InvalidEnvelopeError, InvalidKeyError, MalformedHashError, UnsupportedAlgorithmError, } from "./errors.js";
|
|
14
|
+
export { sha256, sha384, sha512 } from "./hash.js";
|
|
15
|
+
export { hmac } from "./hmac.js";
|
|
16
|
+
export { password } from "./password.js";
|
|
17
|
+
export { randomBytes, randomToken } from "./random.js";
|
|
18
|
+
export { importKey, open, seal } from "./seal.js";
|
|
19
|
+
export { timingSafeEqual } from "./timing-safe-equal.js";
|
|
20
|
+
export { totp } from "./totp.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebCrypto primitives with `Result`-based errors, portable to any runtime.
|
|
3
|
+
*
|
|
4
|
+
* Encoding, digests, HMAC, random tokens, password hashing, TOTP, and authenticated
|
|
5
|
+
* encryption live here once, so security-relevant details are decided in one place
|
|
6
|
+
* instead of being re-derived at every call site.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export { Base64, Base64Url, Hex } from "./encoding.js";
|
|
12
|
+
export { CryptoError, DecryptionError, InvalidEncodingError, InvalidEnvelopeError, InvalidKeyError, MalformedHashError, UnsupportedAlgorithmError, } from "./errors.js";
|
|
13
|
+
export { sha256, sha384, sha512 } from "./hash.js";
|
|
14
|
+
export { hmac } from "./hmac.js";
|
|
15
|
+
export { password } from "./password.js";
|
|
16
|
+
export { randomBytes, randomToken } from "./random.js";
|
|
17
|
+
export { importKey, open, seal } from "./seal.js";
|
|
18
|
+
export { timingSafeEqual } from "./timing-safe-equal.js";
|
|
19
|
+
export { totp } from "./totp.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4648 base32 codec, kept internal to serve TOTP secrets.
|
|
3
|
+
*
|
|
4
|
+
* Authenticator apps and `otpauth://` URIs only speak base32, so shared secrets
|
|
5
|
+
* need this alphabet even though the rest of the package standardizes on hex and
|
|
6
|
+
* base64url for its public encoding surface.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import { InvalidEncodingError } from "../errors.js";
|
|
13
|
+
import type { Bytes } from "./bytes.js";
|
|
14
|
+
/**
|
|
15
|
+
* Encodes bytes as unpadded uppercase base32.
|
|
16
|
+
*
|
|
17
|
+
* Padding is omitted because authenticator apps reject or mangle `=` inside the
|
|
18
|
+
* `secret` parameter of an enrollment URI.
|
|
19
|
+
*
|
|
20
|
+
* @param bytes Secret material to encode.
|
|
21
|
+
* @returns Base32 string over `A-Z` and `2-7`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function encode(bytes: Uint8Array): string;
|
|
24
|
+
/**
|
|
25
|
+
* Decodes base32 text, ignoring case, padding, and separating whitespace.
|
|
26
|
+
*
|
|
27
|
+
* Users retype secrets by hand and apps present them in spaced groups; any other
|
|
28
|
+
* character fails, so a typo never silently decodes differently.
|
|
29
|
+
*
|
|
30
|
+
* @param text Base32 string to decode.
|
|
31
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when a character is invalid.
|
|
32
|
+
*/
|
|
33
|
+
export declare function decode(text: string): Result<Bytes, InvalidEncodingError>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4648 base32 codec, kept internal to serve TOTP secrets.
|
|
3
|
+
*
|
|
4
|
+
* Authenticator apps and `otpauth://` URIs only speak base32, so shared secrets
|
|
5
|
+
* need this alphabet even though the rest of the package standardizes on hex and
|
|
6
|
+
* base64url for its public encoding surface.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, success } from "@sdxc/result";
|
|
12
|
+
import { InvalidEncodingError } from "../errors.js";
|
|
13
|
+
/** RFC 4648 base32 alphabet, uppercase and without the padding character. */
|
|
14
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
15
|
+
/** Bits contributed by one base32 character. */
|
|
16
|
+
const BITS_PER_CHAR = 5;
|
|
17
|
+
/** Bits in one byte, the width drained from the accumulator per output byte. */
|
|
18
|
+
const BITS_PER_BYTE = 8;
|
|
19
|
+
/**
|
|
20
|
+
* Encodes bytes as unpadded uppercase base32.
|
|
21
|
+
*
|
|
22
|
+
* Padding is omitted because authenticator apps reject or mangle `=` inside the
|
|
23
|
+
* `secret` parameter of an enrollment URI.
|
|
24
|
+
*
|
|
25
|
+
* @param bytes Secret material to encode.
|
|
26
|
+
* @returns Base32 string over `A-Z` and `2-7`.
|
|
27
|
+
*/
|
|
28
|
+
export function encode(bytes) {
|
|
29
|
+
let out = "";
|
|
30
|
+
let buffer = 0;
|
|
31
|
+
let bits = 0;
|
|
32
|
+
for (let byte of bytes) {
|
|
33
|
+
buffer = (buffer << BITS_PER_BYTE) | byte;
|
|
34
|
+
bits += BITS_PER_BYTE;
|
|
35
|
+
while (bits >= BITS_PER_CHAR) {
|
|
36
|
+
bits -= BITS_PER_CHAR;
|
|
37
|
+
out += BASE32_ALPHABET.charAt((buffer >> bits) & 0x1f);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (bits > 0)
|
|
41
|
+
out += BASE32_ALPHABET.charAt((buffer << (BITS_PER_CHAR - bits)) & 0x1f);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decodes base32 text, ignoring case, padding, and separating whitespace.
|
|
46
|
+
*
|
|
47
|
+
* Users retype secrets by hand and apps present them in spaced groups; any other
|
|
48
|
+
* character fails, so a typo never silently decodes differently.
|
|
49
|
+
*
|
|
50
|
+
* @param text Base32 string to decode.
|
|
51
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when a character is invalid.
|
|
52
|
+
*/
|
|
53
|
+
export function decode(text) {
|
|
54
|
+
let normalized = text.replaceAll(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
|
|
55
|
+
let bytes = [];
|
|
56
|
+
let buffer = 0;
|
|
57
|
+
let bits = 0;
|
|
58
|
+
for (let char of normalized) {
|
|
59
|
+
let value = BASE32_ALPHABET.indexOf(char);
|
|
60
|
+
if (value === -1)
|
|
61
|
+
return failure(new InvalidEncodingError("base32"));
|
|
62
|
+
buffer = (buffer << BITS_PER_CHAR) | value;
|
|
63
|
+
bits += BITS_PER_CHAR;
|
|
64
|
+
if (bits >= BITS_PER_BYTE) {
|
|
65
|
+
bits -= BITS_PER_BYTE;
|
|
66
|
+
bytes.push((buffer >> bits) & 0xff);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return success(new Uint8Array(bytes));
|
|
70
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte conversion helpers shared by every module in this package.
|
|
3
|
+
*
|
|
4
|
+
* One `TextEncoder`/`TextDecoder` pair is reused so string payloads always turn
|
|
5
|
+
* into the same UTF-8 bytes, and every public function can accept text or binary
|
|
6
|
+
* without each module re-deciding what "data" means.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Data accepted wherever this package takes "bytes": text is read as UTF-8.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* await sha256("hello");
|
|
16
|
+
* await sha256(new Uint8Array([1, 2, 3]));
|
|
17
|
+
*/
|
|
18
|
+
export type BinaryLike = string | Uint8Array | ArrayBuffer;
|
|
19
|
+
/**
|
|
20
|
+
* Byte buffers this package produces: views over a non-shared `ArrayBuffer`.
|
|
21
|
+
*
|
|
22
|
+
* WebCrypto refuses views backed by a `SharedArrayBuffer`, so every function that
|
|
23
|
+
* returns bytes returns them in a form that can be fed straight back in.
|
|
24
|
+
*/
|
|
25
|
+
export type Bytes = Uint8Array<ArrayBuffer>;
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes any accepted input into bytes WebCrypto will accept.
|
|
28
|
+
*
|
|
29
|
+
* An `ArrayBuffer` is wrapped directly, so callers must not mutate a buffer once
|
|
30
|
+
* handed over; only a `SharedArrayBuffer` view is copied.
|
|
31
|
+
*
|
|
32
|
+
* @param data Text or binary payload.
|
|
33
|
+
* @returns Bytes backing the payload.
|
|
34
|
+
*/
|
|
35
|
+
export declare function toBytes(data: BinaryLike): Bytes;
|
|
36
|
+
/**
|
|
37
|
+
* Decodes bytes as UTF-8 text, substituting the replacement character for invalid sequences.
|
|
38
|
+
*
|
|
39
|
+
* Authenticated decryption already proves the bytes are the ones that were
|
|
40
|
+
* sealed, so a lossy decode here means the plaintext held non-UTF-8 bytes.
|
|
41
|
+
*
|
|
42
|
+
* @param bytes Bytes to read as text.
|
|
43
|
+
* @returns Decoded string.
|
|
44
|
+
*/
|
|
45
|
+
export declare function toText(bytes: Uint8Array): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte conversion helpers shared by every module in this package.
|
|
3
|
+
*
|
|
4
|
+
* One `TextEncoder`/`TextDecoder` pair is reused so string payloads always turn
|
|
5
|
+
* into the same UTF-8 bytes, and every public function can accept text or binary
|
|
6
|
+
* without each module re-deciding what "data" means.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
/** Shared UTF-8 encoder, so string inputs hash and sign identically everywhere. */
|
|
12
|
+
const ENCODER = new TextEncoder();
|
|
13
|
+
/** Shared UTF-8 decoder used to read plaintext back out of byte buffers. */
|
|
14
|
+
const DECODER = new TextDecoder();
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes any accepted input into bytes WebCrypto will accept.
|
|
17
|
+
*
|
|
18
|
+
* An `ArrayBuffer` is wrapped directly, so callers must not mutate a buffer once
|
|
19
|
+
* handed over; only a `SharedArrayBuffer` view is copied.
|
|
20
|
+
*
|
|
21
|
+
* @param data Text or binary payload.
|
|
22
|
+
* @returns Bytes backing the payload.
|
|
23
|
+
*/
|
|
24
|
+
export function toBytes(data) {
|
|
25
|
+
if (typeof data === "string")
|
|
26
|
+
return ENCODER.encode(data);
|
|
27
|
+
if (data instanceof ArrayBuffer)
|
|
28
|
+
return new Uint8Array(data);
|
|
29
|
+
if (data.buffer instanceof ArrayBuffer)
|
|
30
|
+
return data;
|
|
31
|
+
return new Uint8Array(data);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decodes bytes as UTF-8 text, substituting the replacement character for invalid sequences.
|
|
35
|
+
*
|
|
36
|
+
* Authenticated decryption already proves the bytes are the ones that were
|
|
37
|
+
* sealed, so a lossy decode here means the plaintext held non-UTF-8 bytes.
|
|
38
|
+
*
|
|
39
|
+
* @param bytes Bytes to read as text.
|
|
40
|
+
* @returns Decoded string.
|
|
41
|
+
*/
|
|
42
|
+
export function toText(bytes) {
|
|
43
|
+
return DECODER.decode(bytes);
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing with PBKDF2-HMAC-SHA256 through WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Hashes are stored in a self-describing string that carries its own cost
|
|
5
|
+
* parameters, so the iteration count can be raised without a schema change:
|
|
6
|
+
* verification uses the parameters found in the stored value, and `needsRehash`
|
|
7
|
+
* reports when that value is behind current policy.
|
|
8
|
+
*
|
|
9
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
|
+
* @copyright Sergio Xalambrí 2026
|
|
11
|
+
*/
|
|
12
|
+
import type { Result } from "@sdxc/result";
|
|
13
|
+
import { CryptoError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Hashes a password with the current policy and a fresh random salt.
|
|
16
|
+
*
|
|
17
|
+
* @param secret Plaintext password.
|
|
18
|
+
* @returns Encoded hash such as `$pbkdf2-sha256$i=600000$<salt>$<key>`, or a `CryptoError`.
|
|
19
|
+
* @example
|
|
20
|
+
* let stored = await password.hash(form.password);
|
|
21
|
+
*/
|
|
22
|
+
declare function hash(secret: string): Promise<Result<string, CryptoError>>;
|
|
23
|
+
/**
|
|
24
|
+
* Checks a password against an encoded hash using the hash's own parameters.
|
|
25
|
+
*
|
|
26
|
+
* A wrong password is `success(false)`; only an unusable stored value or a runtime
|
|
27
|
+
* failure is a `Failure`, which keeps "wrong password" and "cannot check" apart.
|
|
28
|
+
*
|
|
29
|
+
* @param stored Encoded hash previously produced by `hash`.
|
|
30
|
+
* @param secret Plaintext password to check.
|
|
31
|
+
* @returns Whether the password matches, or why the check could not run.
|
|
32
|
+
* @example
|
|
33
|
+
* let ok = await password.verify(user.passwordHash, form.password);
|
|
34
|
+
*/
|
|
35
|
+
declare function verify(stored: string, secret: string): Promise<Result<boolean, CryptoError>>;
|
|
36
|
+
/**
|
|
37
|
+
* Reports whether a stored hash is behind current policy.
|
|
38
|
+
*
|
|
39
|
+
* True for a lower iteration count, a shorter salt or key, or a value this
|
|
40
|
+
* module cannot parse, matching how upgrade-on-login replaces foreign hashes.
|
|
41
|
+
*
|
|
42
|
+
* @param stored Encoded hash to inspect.
|
|
43
|
+
* @returns Whether the value should be replaced after the next successful login.
|
|
44
|
+
* @example
|
|
45
|
+
* if (isValid && password.needsRehash(user.passwordHash)) await rehash(form.password);
|
|
46
|
+
*/
|
|
47
|
+
declare function needsRehash(stored: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Password hashing, verification, and upgrade detection.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* let stored = unwrap(await password.hash("secret"));
|
|
53
|
+
* let ok = unwrap(await password.verify(stored, "secret")); // true
|
|
54
|
+
* password.needsRehash(stored); // false
|
|
55
|
+
*/
|
|
56
|
+
export declare const password: {
|
|
57
|
+
hash: typeof hash;
|
|
58
|
+
verify: typeof verify;
|
|
59
|
+
needsRehash: typeof needsRehash;
|
|
60
|
+
};
|
|
61
|
+
export {};
|
package/dist/password.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing with PBKDF2-HMAC-SHA256 through WebCrypto.
|
|
3
|
+
*
|
|
4
|
+
* Hashes are stored in a self-describing string that carries its own cost
|
|
5
|
+
* parameters, so the iteration count can be raised without a schema change:
|
|
6
|
+
* verification uses the parameters found in the stored value, and `needsRehash`
|
|
7
|
+
* reports when that value is behind current policy.
|
|
8
|
+
*
|
|
9
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
10
|
+
* @copyright Sergio Xalambrí 2026
|
|
11
|
+
*/
|
|
12
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
13
|
+
import { Base64Url } from "./encoding.js";
|
|
14
|
+
import { CryptoError, MalformedHashError, UnsupportedAlgorithmError } from "./errors.js";
|
|
15
|
+
import { toBytes } from "./lib/bytes.js";
|
|
16
|
+
import { randomBytes } from "./random.js";
|
|
17
|
+
import { timingSafeEqual } from "./timing-safe-equal.js";
|
|
18
|
+
/**
|
|
19
|
+
* Current iteration count for new hashes.
|
|
20
|
+
*
|
|
21
|
+
* PBKDF2 is not memory-hard, so this number carries the entire cost budget;
|
|
22
|
+
* raising it is the whole upgrade, since `needsRehash` reports the change.
|
|
23
|
+
*/
|
|
24
|
+
const PBKDF2_ITERATIONS = 600_000;
|
|
25
|
+
/** Salt length for new hashes, in bytes. */
|
|
26
|
+
const PBKDF2_SALT_BYTES = 16;
|
|
27
|
+
/** Derived key length for new hashes, in bytes. */
|
|
28
|
+
const PBKDF2_KEY_BYTES = 32;
|
|
29
|
+
/** Algorithm tag written into, and required by, the encoded format. */
|
|
30
|
+
const PBKDF2_ALGORITHM_ID = "pbkdf2-sha256";
|
|
31
|
+
/** Hash function inside PBKDF2, fixed by the algorithm tag. */
|
|
32
|
+
const PBKDF2_HASH = "SHA-256";
|
|
33
|
+
/** Upper bound on a stored iteration count, so a bad value cannot stall a request. */
|
|
34
|
+
const PBKDF2_MAX_ITERATIONS = 10_000_000;
|
|
35
|
+
/** Number of fields in the encoded format, counting the leading empty one. */
|
|
36
|
+
const ENCODED_FIELDS = 5;
|
|
37
|
+
/** Only supported parameter field: the iteration count. */
|
|
38
|
+
const ITERATIONS_PARAM = /^i=(\d+)$/;
|
|
39
|
+
/** Bits per byte, used to turn a key length into a `deriveBits` length. */
|
|
40
|
+
const BITS_PER_BYTE = 8;
|
|
41
|
+
/**
|
|
42
|
+
* Parses an encoded hash into its cost parameters and material.
|
|
43
|
+
*
|
|
44
|
+
* Anything not written by this module is a failure, which is the signal a caller
|
|
45
|
+
* needs to route the value to a legacy verifier instead of guessing.
|
|
46
|
+
*
|
|
47
|
+
* @param stored Encoded hash string.
|
|
48
|
+
* @returns Parsed parameters, or the reason the value is unusable.
|
|
49
|
+
*/
|
|
50
|
+
function parse(stored) {
|
|
51
|
+
let fields = stored.split("$");
|
|
52
|
+
if (fields.length !== ENCODED_FIELDS || fields[0] !== "") {
|
|
53
|
+
return failure(new MalformedHashError("unexpected field count"));
|
|
54
|
+
}
|
|
55
|
+
let [, algorithm = "", params = "", salt = "", key = ""] = fields;
|
|
56
|
+
if (algorithm !== PBKDF2_ALGORITHM_ID)
|
|
57
|
+
return failure(new UnsupportedAlgorithmError(algorithm));
|
|
58
|
+
let match = ITERATIONS_PARAM.exec(params);
|
|
59
|
+
if (!match?.[1])
|
|
60
|
+
return failure(new MalformedHashError("unreadable parameters"));
|
|
61
|
+
let iterations = Number.parseInt(match[1], 10);
|
|
62
|
+
if (iterations < 1 || iterations > PBKDF2_MAX_ITERATIONS) {
|
|
63
|
+
return failure(new MalformedHashError("iteration count out of range"));
|
|
64
|
+
}
|
|
65
|
+
let decodedSalt = Base64Url.decode(salt);
|
|
66
|
+
if (isFailure(decodedSalt) || decodedSalt.data.length === 0) {
|
|
67
|
+
return failure(new MalformedHashError("unreadable salt"));
|
|
68
|
+
}
|
|
69
|
+
let decodedKey = Base64Url.decode(key);
|
|
70
|
+
if (isFailure(decodedKey) || decodedKey.data.length === 0) {
|
|
71
|
+
return failure(new MalformedHashError("unreadable derived key"));
|
|
72
|
+
}
|
|
73
|
+
return success({ iterations, salt: decodedSalt.data, key: decodedKey.data });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runs PBKDF2-HMAC-SHA256 over a secret with the given cost parameters.
|
|
77
|
+
*
|
|
78
|
+
* @param secret Plaintext password.
|
|
79
|
+
* @param salt Salt to derive with.
|
|
80
|
+
* @param iterations Iteration count to apply.
|
|
81
|
+
* @param length Output length in bytes.
|
|
82
|
+
* @returns Derived bytes, or a `CryptoError` if the runtime refuses the derivation.
|
|
83
|
+
*/
|
|
84
|
+
async function derive(secret, salt, iterations, length) {
|
|
85
|
+
try {
|
|
86
|
+
let key = await crypto.subtle.importKey("raw", toBytes(secret), "PBKDF2", false, [
|
|
87
|
+
"deriveBits",
|
|
88
|
+
]);
|
|
89
|
+
let bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: PBKDF2_HASH }, key, length * BITS_PER_BYTE);
|
|
90
|
+
return success(new Uint8Array(bits));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return failure(new CryptoError("Password derivation failed"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Hashes a password with the current policy and a fresh random salt.
|
|
98
|
+
*
|
|
99
|
+
* @param secret Plaintext password.
|
|
100
|
+
* @returns Encoded hash such as `$pbkdf2-sha256$i=600000$<salt>$<key>`, or a `CryptoError`.
|
|
101
|
+
* @example
|
|
102
|
+
* let stored = await password.hash(form.password);
|
|
103
|
+
*/
|
|
104
|
+
async function hash(secret) {
|
|
105
|
+
let salt = randomBytes(PBKDF2_SALT_BYTES);
|
|
106
|
+
let derived = await derive(secret, salt, PBKDF2_ITERATIONS, PBKDF2_KEY_BYTES);
|
|
107
|
+
if (isFailure(derived))
|
|
108
|
+
return derived;
|
|
109
|
+
let encodedSalt = Base64Url.encode(salt);
|
|
110
|
+
let encodedKey = Base64Url.encode(derived.data);
|
|
111
|
+
return success(`$${PBKDF2_ALGORITHM_ID}$i=${PBKDF2_ITERATIONS}$${encodedSalt}$${encodedKey}`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Checks a password against an encoded hash using the hash's own parameters.
|
|
115
|
+
*
|
|
116
|
+
* A wrong password is `success(false)`; only an unusable stored value or a runtime
|
|
117
|
+
* failure is a `Failure`, which keeps "wrong password" and "cannot check" apart.
|
|
118
|
+
*
|
|
119
|
+
* @param stored Encoded hash previously produced by `hash`.
|
|
120
|
+
* @param secret Plaintext password to check.
|
|
121
|
+
* @returns Whether the password matches, or why the check could not run.
|
|
122
|
+
* @example
|
|
123
|
+
* let ok = await password.verify(user.passwordHash, form.password);
|
|
124
|
+
*/
|
|
125
|
+
async function verify(stored, secret) {
|
|
126
|
+
let parsed = parse(stored);
|
|
127
|
+
if (isFailure(parsed))
|
|
128
|
+
return parsed;
|
|
129
|
+
let derived = await derive(secret, parsed.data.salt, parsed.data.iterations, parsed.data.key.length);
|
|
130
|
+
if (isFailure(derived))
|
|
131
|
+
return derived;
|
|
132
|
+
return success(timingSafeEqual(derived.data, parsed.data.key));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Reports whether a stored hash is behind current policy.
|
|
136
|
+
*
|
|
137
|
+
* True for a lower iteration count, a shorter salt or key, or a value this
|
|
138
|
+
* module cannot parse, matching how upgrade-on-login replaces foreign hashes.
|
|
139
|
+
*
|
|
140
|
+
* @param stored Encoded hash to inspect.
|
|
141
|
+
* @returns Whether the value should be replaced after the next successful login.
|
|
142
|
+
* @example
|
|
143
|
+
* if (isValid && password.needsRehash(user.passwordHash)) await rehash(form.password);
|
|
144
|
+
*/
|
|
145
|
+
function needsRehash(stored) {
|
|
146
|
+
let parsed = parse(stored);
|
|
147
|
+
if (isFailure(parsed))
|
|
148
|
+
return true;
|
|
149
|
+
if (parsed.data.iterations < PBKDF2_ITERATIONS)
|
|
150
|
+
return true;
|
|
151
|
+
if (parsed.data.salt.length < PBKDF2_SALT_BYTES)
|
|
152
|
+
return true;
|
|
153
|
+
return parsed.data.key.length < PBKDF2_KEY_BYTES;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Password hashing, verification, and upgrade detection.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* let stored = unwrap(await password.hash("secret"));
|
|
160
|
+
* let ok = unwrap(await password.verify(stored, "secret")); // true
|
|
161
|
+
* password.needsRehash(stored); // false
|
|
162
|
+
*/
|
|
163
|
+
export const password = { hash, verify, needsRehash };
|
package/dist/random.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographically strong random bytes and the tokens built from them.
|
|
3
|
+
*
|
|
4
|
+
* `randomToken` is the shape most secrets in the repository need: enough entropy
|
|
5
|
+
* to be unguessable, base64url so it survives URLs and headers, and an optional
|
|
6
|
+
* prefix that makes a leaked key greppable and revocable by kind.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Bytes } from "./lib/bytes.js";
|
|
12
|
+
/**
|
|
13
|
+
* Fills a new buffer with cryptographically strong random bytes.
|
|
14
|
+
*
|
|
15
|
+
* @param size Number of bytes to generate; must be an integer in 0..65536.
|
|
16
|
+
* @returns A fresh buffer of exactly `size` random bytes.
|
|
17
|
+
* @throws {RangeError} If `size` is not an integer within the range the runtime can fill.
|
|
18
|
+
* @example
|
|
19
|
+
* let iv = randomBytes(12);
|
|
20
|
+
*/
|
|
21
|
+
export declare function randomBytes(size: number): Bytes;
|
|
22
|
+
/**
|
|
23
|
+
* Types for `randomToken`.
|
|
24
|
+
*/
|
|
25
|
+
export declare namespace randomToken {
|
|
26
|
+
/** Token shape and entropy. */
|
|
27
|
+
interface Options {
|
|
28
|
+
/**
|
|
29
|
+
* Bytes of entropy behind the token.
|
|
30
|
+
* @default 32
|
|
31
|
+
*/
|
|
32
|
+
bytes?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Prefix joined with `_`, so a leaked key is searchable and attributable.
|
|
35
|
+
* @example "sk"
|
|
36
|
+
*/
|
|
37
|
+
prefix?: string;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a URL-safe random token, optionally namespaced by a prefix.
|
|
42
|
+
*
|
|
43
|
+
* Unpadded base64url avoids escaping in a URL, header, or file name; the
|
|
44
|
+
* prefix lets a leaked token be recognized and revoked by kind.
|
|
45
|
+
*
|
|
46
|
+
* @param options Entropy and prefix.
|
|
47
|
+
* @returns The token, as `<prefix>_<random>` when a prefix is given.
|
|
48
|
+
* @throws {RangeError} If `options.bytes` is not an integer the runtime can fill.
|
|
49
|
+
* @example
|
|
50
|
+
* randomToken({ bytes: 32, prefix: "sk" }); // "sk_9f1...".
|
|
51
|
+
*/
|
|
52
|
+
export declare function randomToken(options?: randomToken.Options): string;
|
package/dist/random.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographically strong random bytes and the tokens built from them.
|
|
3
|
+
*
|
|
4
|
+
* `randomToken` is the shape most secrets in the repository need: enough entropy
|
|
5
|
+
* to be unguessable, base64url so it survives URLs and headers, and an optional
|
|
6
|
+
* prefix that makes a leaked key greppable and revocable by kind.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { Base64Url } from "./encoding.js";
|
|
12
|
+
/** Largest buffer `crypto.getRandomValues` fills in a single call. */
|
|
13
|
+
const MAX_RANDOM_BYTES = 65536;
|
|
14
|
+
/** Entropy used when a caller does not pick a size: 256 bits. */
|
|
15
|
+
const DEFAULT_TOKEN_BYTES = 32;
|
|
16
|
+
/** Character joining a token prefix to its random part. */
|
|
17
|
+
const PREFIX_SEPARATOR = "_";
|
|
18
|
+
/**
|
|
19
|
+
* Fills a new buffer with cryptographically strong random bytes.
|
|
20
|
+
*
|
|
21
|
+
* @param size Number of bytes to generate; must be an integer in 0..65536.
|
|
22
|
+
* @returns A fresh buffer of exactly `size` random bytes.
|
|
23
|
+
* @throws {RangeError} If `size` is not an integer within the range the runtime can fill.
|
|
24
|
+
* @example
|
|
25
|
+
* let iv = randomBytes(12);
|
|
26
|
+
*/
|
|
27
|
+
export function randomBytes(size) {
|
|
28
|
+
if (!Number.isInteger(size) || size < 0 || size > MAX_RANDOM_BYTES) {
|
|
29
|
+
throw new RangeError(`randomBytes size must be an integer between 0 and ${MAX_RANDOM_BYTES}`);
|
|
30
|
+
}
|
|
31
|
+
return crypto.getRandomValues(new Uint8Array(size));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generates a URL-safe random token, optionally namespaced by a prefix.
|
|
35
|
+
*
|
|
36
|
+
* Unpadded base64url avoids escaping in a URL, header, or file name; the
|
|
37
|
+
* prefix lets a leaked token be recognized and revoked by kind.
|
|
38
|
+
*
|
|
39
|
+
* @param options Entropy and prefix.
|
|
40
|
+
* @returns The token, as `<prefix>_<random>` when a prefix is given.
|
|
41
|
+
* @throws {RangeError} If `options.bytes` is not an integer the runtime can fill.
|
|
42
|
+
* @example
|
|
43
|
+
* randomToken({ bytes: 32, prefix: "sk" }); // "sk_9f1...".
|
|
44
|
+
*/
|
|
45
|
+
export function randomToken(options = {}) {
|
|
46
|
+
let token = Base64Url.encode(randomBytes(options.bytes ?? DEFAULT_TOKEN_BYTES));
|
|
47
|
+
if (!options.prefix)
|
|
48
|
+
return token;
|
|
49
|
+
return `${options.prefix}${PREFIX_SEPARATOR}${token}`;
|
|
50
|
+
}
|