@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/seal.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated symmetric encryption for values that must be read back.
|
|
3
|
+
*
|
|
4
|
+
* AES-GCM with a fresh random IV per call, wrapped in a versioned envelope so a
|
|
5
|
+
* future algorithm change never requires guessing the format of stored data. This
|
|
6
|
+
* is the third option beside plaintext and irreversible hashes.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import { CryptoError } from "./errors.js";
|
|
13
|
+
/**
|
|
14
|
+
* Imports raw base64url key material as an AES-GCM key.
|
|
15
|
+
*
|
|
16
|
+
* The key is imported as non-extractable, so a leaked reference cannot be turned
|
|
17
|
+
* back into bytes. Generate material with `randomToken({ bytes: 32 })`.
|
|
18
|
+
*
|
|
19
|
+
* @param raw Base64url-encoded key of 16, 24, or 32 bytes.
|
|
20
|
+
* @returns The key, or why the material was rejected.
|
|
21
|
+
* @example
|
|
22
|
+
* let key = await importKey(env.SEAL_KEY);
|
|
23
|
+
*/
|
|
24
|
+
export declare function importKey(raw: string): Promise<Result<CryptoKey, CryptoError>>;
|
|
25
|
+
/**
|
|
26
|
+
* Encrypts a string into a self-describing envelope.
|
|
27
|
+
*
|
|
28
|
+
* The IV is random per call, so sealing the same plaintext twice yields
|
|
29
|
+
* different envelopes; hash a value with `sha256` before storing it for lookup.
|
|
30
|
+
*
|
|
31
|
+
* @param key AES-GCM key from `importKey`.
|
|
32
|
+
* @param plaintext Value to encrypt.
|
|
33
|
+
* @returns Envelope shaped `v1.<iv>.<ciphertext>`, or why encryption failed.
|
|
34
|
+
* @example
|
|
35
|
+
* let sealed = await seal(key, refreshToken);
|
|
36
|
+
*/
|
|
37
|
+
export declare function seal(key: CryptoKey, plaintext: string): Promise<Result<string, CryptoError>>;
|
|
38
|
+
/**
|
|
39
|
+
* Decrypts an envelope produced by `seal`.
|
|
40
|
+
*
|
|
41
|
+
* A wrong key and a tampered ciphertext raise the same `DecryptionError`,
|
|
42
|
+
* revealing only that decryption failed and nothing about which part changed.
|
|
43
|
+
*
|
|
44
|
+
* @param key AES-GCM key from `importKey`.
|
|
45
|
+
* @param sealed Envelope shaped `v1.<iv>.<ciphertext>`.
|
|
46
|
+
* @returns The original plaintext, or why it could not be recovered.
|
|
47
|
+
* @example
|
|
48
|
+
* let opened = await open(key, stored.sealedToken);
|
|
49
|
+
*/
|
|
50
|
+
export declare function open(key: CryptoKey, sealed: string): Promise<Result<string, CryptoError>>;
|
package/dist/seal.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated symmetric encryption for values that must be read back.
|
|
3
|
+
*
|
|
4
|
+
* AES-GCM with a fresh random IV per call, wrapped in a versioned envelope so a
|
|
5
|
+
* future algorithm change never requires guessing the format of stored data. This
|
|
6
|
+
* is the third option beside plaintext and irreversible hashes.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
12
|
+
import { Base64Url } from "./encoding.js";
|
|
13
|
+
import { CryptoError, DecryptionError, InvalidEnvelopeError, InvalidKeyError } from "./errors.js";
|
|
14
|
+
import { toBytes, toText } from "./lib/bytes.js";
|
|
15
|
+
import { randomBytes } from "./random.js";
|
|
16
|
+
/** Cipher used by the `v1` envelope. */
|
|
17
|
+
const SEAL_ALGORITHM = "AES-GCM";
|
|
18
|
+
/** Version tag written by `seal` and required by `open`. */
|
|
19
|
+
const SEAL_VERSION = "v1";
|
|
20
|
+
/** IV length in bytes; 96 bits is the size AES-GCM is specified for. */
|
|
21
|
+
const SEAL_IV_BYTES = 12;
|
|
22
|
+
/** Separator between envelope fields, outside the base64url alphabet. */
|
|
23
|
+
const SEAL_SEPARATOR = ".";
|
|
24
|
+
/** Number of fields in the envelope: version, IV, ciphertext. */
|
|
25
|
+
const SEAL_FIELDS = 3;
|
|
26
|
+
/** AES key sizes WebCrypto accepts, in bytes. */
|
|
27
|
+
const SEAL_KEY_BYTES = [16, 24, 32];
|
|
28
|
+
/**
|
|
29
|
+
* Imports raw base64url key material as an AES-GCM key.
|
|
30
|
+
*
|
|
31
|
+
* The key is imported as non-extractable, so a leaked reference cannot be turned
|
|
32
|
+
* back into bytes. Generate material with `randomToken({ bytes: 32 })`.
|
|
33
|
+
*
|
|
34
|
+
* @param raw Base64url-encoded key of 16, 24, or 32 bytes.
|
|
35
|
+
* @returns The key, or why the material was rejected.
|
|
36
|
+
* @example
|
|
37
|
+
* let key = await importKey(env.SEAL_KEY);
|
|
38
|
+
*/
|
|
39
|
+
export async function importKey(raw) {
|
|
40
|
+
let material = Base64Url.decode(raw);
|
|
41
|
+
if (isFailure(material))
|
|
42
|
+
return material;
|
|
43
|
+
if (!SEAL_KEY_BYTES.includes(material.data.length)) {
|
|
44
|
+
return failure(new InvalidKeyError("expected 16, 24, or 32 bytes of AES key material"));
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
let key = await crypto.subtle.importKey("raw", material.data, SEAL_ALGORITHM, false, [
|
|
48
|
+
"encrypt",
|
|
49
|
+
"decrypt",
|
|
50
|
+
]);
|
|
51
|
+
return success(key);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return failure(new InvalidKeyError("the runtime rejected the key material"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Encrypts a string into a self-describing envelope.
|
|
59
|
+
*
|
|
60
|
+
* The IV is random per call, so sealing the same plaintext twice yields
|
|
61
|
+
* different envelopes; hash a value with `sha256` before storing it for lookup.
|
|
62
|
+
*
|
|
63
|
+
* @param key AES-GCM key from `importKey`.
|
|
64
|
+
* @param plaintext Value to encrypt.
|
|
65
|
+
* @returns Envelope shaped `v1.<iv>.<ciphertext>`, or why encryption failed.
|
|
66
|
+
* @example
|
|
67
|
+
* let sealed = await seal(key, refreshToken);
|
|
68
|
+
*/
|
|
69
|
+
export async function seal(key, plaintext) {
|
|
70
|
+
let iv = randomBytes(SEAL_IV_BYTES);
|
|
71
|
+
try {
|
|
72
|
+
let ciphertext = await crypto.subtle.encrypt({ name: SEAL_ALGORITHM, iv }, key, toBytes(plaintext));
|
|
73
|
+
let fields = [SEAL_VERSION, Base64Url.encode(iv), Base64Url.encode(new Uint8Array(ciphertext))];
|
|
74
|
+
return success(fields.join(SEAL_SEPARATOR));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return failure(new CryptoError("Encryption failed"));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Decrypts an envelope produced by `seal`.
|
|
82
|
+
*
|
|
83
|
+
* A wrong key and a tampered ciphertext raise the same `DecryptionError`,
|
|
84
|
+
* revealing only that decryption failed and nothing about which part changed.
|
|
85
|
+
*
|
|
86
|
+
* @param key AES-GCM key from `importKey`.
|
|
87
|
+
* @param sealed Envelope shaped `v1.<iv>.<ciphertext>`.
|
|
88
|
+
* @returns The original plaintext, or why it could not be recovered.
|
|
89
|
+
* @example
|
|
90
|
+
* let opened = await open(key, stored.sealedToken);
|
|
91
|
+
*/
|
|
92
|
+
export async function open(key, sealed) {
|
|
93
|
+
let fields = sealed.split(SEAL_SEPARATOR);
|
|
94
|
+
if (fields.length !== SEAL_FIELDS)
|
|
95
|
+
return failure(new InvalidEnvelopeError("unexpected format"));
|
|
96
|
+
let [version = "", encodedIv = "", encodedCiphertext = ""] = fields;
|
|
97
|
+
if (version !== SEAL_VERSION)
|
|
98
|
+
return failure(new InvalidEnvelopeError("unsupported version"));
|
|
99
|
+
let iv = Base64Url.decode(encodedIv);
|
|
100
|
+
if (isFailure(iv) || iv.data.length !== SEAL_IV_BYTES) {
|
|
101
|
+
return failure(new InvalidEnvelopeError("unreadable initialization vector"));
|
|
102
|
+
}
|
|
103
|
+
let ciphertext = Base64Url.decode(encodedCiphertext);
|
|
104
|
+
if (isFailure(ciphertext) || ciphertext.data.length === 0) {
|
|
105
|
+
return failure(new InvalidEnvelopeError("unreadable ciphertext"));
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
let plaintext = await crypto.subtle.decrypt({ name: SEAL_ALGORITHM, iv: iv.data }, key, ciphertext.data);
|
|
109
|
+
return success(toText(new Uint8Array(plaintext)));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return failure(new DecryptionError());
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time byte comparison for secrets, signatures, and one-time codes.
|
|
3
|
+
*
|
|
4
|
+
* Comparing sensitive values with `===` or a loop that returns early leaks how
|
|
5
|
+
* many leading bytes matched, which is enough to forge a signature one byte at a
|
|
6
|
+
* time; this exists so no module in the repository needs `node:crypto` for it.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { BinaryLike } from "./lib/bytes.js";
|
|
12
|
+
/**
|
|
13
|
+
* Compares two values byte for byte without an early exit.
|
|
14
|
+
*
|
|
15
|
+
* The running time depends only on the length of the inputs; length leaks but
|
|
16
|
+
* byte content stays protected. Strings compare as their UTF-8 bytes.
|
|
17
|
+
*
|
|
18
|
+
* @param left First value, typically the expected one.
|
|
19
|
+
* @param right Second value, typically the one supplied by a caller.
|
|
20
|
+
* @returns Whether both values contain exactly the same bytes.
|
|
21
|
+
* @example
|
|
22
|
+
* if (!timingSafeEqual(expectedSignature, providedSignature)) return reject();
|
|
23
|
+
*/
|
|
24
|
+
export declare function timingSafeEqual(left: BinaryLike, right: BinaryLike): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time byte comparison for secrets, signatures, and one-time codes.
|
|
3
|
+
*
|
|
4
|
+
* Comparing sensitive values with `===` or a loop that returns early leaks how
|
|
5
|
+
* many leading bytes matched, which is enough to forge a signature one byte at a
|
|
6
|
+
* time; this exists so no module in the repository needs `node:crypto` for it.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { toBytes } from "./lib/bytes.js";
|
|
12
|
+
/**
|
|
13
|
+
* Compares two values byte for byte without an early exit.
|
|
14
|
+
*
|
|
15
|
+
* The running time depends only on the length of the inputs; length leaks but
|
|
16
|
+
* byte content stays protected. Strings compare as their UTF-8 bytes.
|
|
17
|
+
*
|
|
18
|
+
* @param left First value, typically the expected one.
|
|
19
|
+
* @param right Second value, typically the one supplied by a caller.
|
|
20
|
+
* @returns Whether both values contain exactly the same bytes.
|
|
21
|
+
* @example
|
|
22
|
+
* if (!timingSafeEqual(expectedSignature, providedSignature)) return reject();
|
|
23
|
+
*/
|
|
24
|
+
export function timingSafeEqual(left, right) {
|
|
25
|
+
let a = toBytes(left);
|
|
26
|
+
let b = toBytes(right);
|
|
27
|
+
if (a.length === 0 || b.length === 0)
|
|
28
|
+
return a.length === b.length;
|
|
29
|
+
let mismatch = a.length ^ b.length;
|
|
30
|
+
for (let index = 0; index < a.length; index++) {
|
|
31
|
+
mismatch |= (a[index] ?? 0) ^ (b[index % b.length] ?? 0);
|
|
32
|
+
}
|
|
33
|
+
return mismatch === 0;
|
|
34
|
+
}
|
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-based one-time passwords, RFC 6238 over the package's HMAC primitive.
|
|
3
|
+
*
|
|
4
|
+
* Second-factor enrollment and verification need three things that are easy to
|
|
5
|
+
* get subtly wrong: a base32 shared secret, the dynamic truncation of a MAC over
|
|
6
|
+
* the current time step, and a drift window that still compares in constant time.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import { CryptoError } from "./errors.js";
|
|
13
|
+
/** Hash functions RFC 6238 allows for TOTP. */
|
|
14
|
+
declare const SUPPORTED_ALGORITHMS: readonly ["SHA-1", "SHA-256", "SHA-512"];
|
|
15
|
+
/**
|
|
16
|
+
* Types for the `totp` operations.
|
|
17
|
+
*/
|
|
18
|
+
export declare namespace totp {
|
|
19
|
+
/** Hash function inside the HMAC a code is derived from. */
|
|
20
|
+
type Algorithm = (typeof SUPPORTED_ALGORITHMS)[number];
|
|
21
|
+
/** Shared secret size. */
|
|
22
|
+
interface SecretOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Secret size in bytes.
|
|
25
|
+
* @default 20
|
|
26
|
+
*/
|
|
27
|
+
bytes?: number;
|
|
28
|
+
}
|
|
29
|
+
/** Parameters that must agree between the generator and the verifier. */
|
|
30
|
+
interface CodeOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Point in time to generate for, as a `Date` or epoch milliseconds.
|
|
33
|
+
* @default Date.now()
|
|
34
|
+
*/
|
|
35
|
+
at?: Date | number;
|
|
36
|
+
/**
|
|
37
|
+
* Time step in seconds.
|
|
38
|
+
* @default 30
|
|
39
|
+
*/
|
|
40
|
+
step?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Digits in the code.
|
|
43
|
+
* @default 6
|
|
44
|
+
*/
|
|
45
|
+
digits?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Hash function to key.
|
|
48
|
+
* @default "SHA-1"
|
|
49
|
+
*/
|
|
50
|
+
algorithm?: Algorithm;
|
|
51
|
+
}
|
|
52
|
+
/** Verification parameters, including how much clock drift to accept. */
|
|
53
|
+
interface VerifyOptions extends CodeOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Steps accepted on either side of the current one.
|
|
56
|
+
* @default 1
|
|
57
|
+
*/
|
|
58
|
+
window?: number;
|
|
59
|
+
}
|
|
60
|
+
/** Enrollment URI fields shown by an authenticator app. */
|
|
61
|
+
interface UriOptions {
|
|
62
|
+
/** Service name shown as the account issuer. */
|
|
63
|
+
issuer: string;
|
|
64
|
+
/** Account identifier, usually an email address or username. */
|
|
65
|
+
account: string;
|
|
66
|
+
/**
|
|
67
|
+
* Digits in the code.
|
|
68
|
+
* @default 6
|
|
69
|
+
*/
|
|
70
|
+
digits?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Time step in seconds.
|
|
73
|
+
* @default 30
|
|
74
|
+
*/
|
|
75
|
+
step?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Hash function to key.
|
|
78
|
+
* @default "SHA-1"
|
|
79
|
+
*/
|
|
80
|
+
algorithm?: Algorithm;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Generates a random base32 shared secret ready for enrollment.
|
|
85
|
+
*
|
|
86
|
+
* The result is unpadded uppercase base32 because that is the only encoding
|
|
87
|
+
* authenticator apps accept in a QR code or a typed setup key.
|
|
88
|
+
*
|
|
89
|
+
* @param options Secret size.
|
|
90
|
+
* @returns Base32 secret to store for the account and show once during enrollment.
|
|
91
|
+
* @throws {RangeError} If `options.bytes` is not an integer the runtime can fill.
|
|
92
|
+
* @example
|
|
93
|
+
* let secret = totp.generateSecret(); // "JBSWY3DPEHPK3PXP..."
|
|
94
|
+
*/
|
|
95
|
+
declare function generateSecret(options?: totp.SecretOptions): string;
|
|
96
|
+
/**
|
|
97
|
+
* Generates the code for a secret at a point in time.
|
|
98
|
+
*
|
|
99
|
+
* @param secret Base32 shared secret.
|
|
100
|
+
* @param options Time, step, digits, and hash function.
|
|
101
|
+
* @returns The current code, or why it could not be derived.
|
|
102
|
+
* @example
|
|
103
|
+
* let code = await totp.code(secret, { at: new Date("2026-01-01T00:00:00Z") });
|
|
104
|
+
*/
|
|
105
|
+
declare function generateCode(secret: string, options?: totp.CodeOptions): Promise<Result<string, CryptoError>>;
|
|
106
|
+
/**
|
|
107
|
+
* Checks a submitted code against the current step and the drift window.
|
|
108
|
+
*
|
|
109
|
+
* Every step in the window is compared in constant time even after a match,
|
|
110
|
+
* so timing never reveals which step matched.
|
|
111
|
+
*
|
|
112
|
+
* @param secret Base32 shared secret.
|
|
113
|
+
* @param code Code submitted by the user.
|
|
114
|
+
* @param options Time, step, digits, hash function, and drift window.
|
|
115
|
+
* @returns Whether the code is valid, or why it could not be checked.
|
|
116
|
+
* @example
|
|
117
|
+
* let ok = await totp.verify(secret, form.code, { window: 1 });
|
|
118
|
+
*/
|
|
119
|
+
declare function verifyCode(secret: string, code: string, options?: totp.VerifyOptions): Promise<Result<boolean, CryptoError>>;
|
|
120
|
+
/**
|
|
121
|
+
* Builds the `otpauth://` URI an authenticator app scans during enrollment.
|
|
122
|
+
*
|
|
123
|
+
* The issuer appears both in the label and as a query parameter, which is what
|
|
124
|
+
* apps need to group and name the entry consistently.
|
|
125
|
+
*
|
|
126
|
+
* @param secret Base32 shared secret.
|
|
127
|
+
* @param options Issuer, account, and the parameters the app must mirror.
|
|
128
|
+
* @returns URI to render as a QR code or offer as a manual setup link.
|
|
129
|
+
* @example
|
|
130
|
+
* totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
131
|
+
*/
|
|
132
|
+
declare function buildUri(secret: string, options: totp.UriOptions): string;
|
|
133
|
+
/**
|
|
134
|
+
* RFC 6238 one-time passwords: secrets, codes, verification, and enrollment URIs.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* let secret = totp.generateSecret();
|
|
138
|
+
* let uri = totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
139
|
+
* let ok = await totp.verify(secret, submittedCode, { window: 1 });
|
|
140
|
+
*/
|
|
141
|
+
export declare const totp: {
|
|
142
|
+
generateSecret: typeof generateSecret;
|
|
143
|
+
code: typeof generateCode;
|
|
144
|
+
verify: typeof verifyCode;
|
|
145
|
+
uri: typeof buildUri;
|
|
146
|
+
};
|
|
147
|
+
export {};
|
package/dist/totp.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-based one-time passwords, RFC 6238 over the package's HMAC primitive.
|
|
3
|
+
*
|
|
4
|
+
* Second-factor enrollment and verification need three things that are easy to
|
|
5
|
+
* get subtly wrong: a base32 shared secret, the dynamic truncation of a MAC over
|
|
6
|
+
* the current time step, and a drift window that still compares in constant time.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
12
|
+
import { CryptoError } from "./errors.js";
|
|
13
|
+
import { hmac } from "./hmac.js";
|
|
14
|
+
import { decode as decodeBase32, encode as encodeBase32 } from "./lib/base32.js";
|
|
15
|
+
import { randomBytes } from "./random.js";
|
|
16
|
+
import { timingSafeEqual } from "./timing-safe-equal.js";
|
|
17
|
+
/** Hash functions RFC 6238 allows for TOTP. */
|
|
18
|
+
const SUPPORTED_ALGORITHMS = ["SHA-1", "SHA-256", "SHA-512"];
|
|
19
|
+
/** Default time step in seconds, as authenticator apps assume. */
|
|
20
|
+
const DEFAULT_STEP_SECONDS = 30;
|
|
21
|
+
/** Default number of digits in a generated code. */
|
|
22
|
+
const DEFAULT_DIGITS = 6;
|
|
23
|
+
/** Default hash, kept at SHA-1 because that is what enrollment apps support. */
|
|
24
|
+
const DEFAULT_ALGORITHM = "SHA-1";
|
|
25
|
+
/** Default number of steps accepted on either side of the current one. */
|
|
26
|
+
const DEFAULT_WINDOW = 1;
|
|
27
|
+
/** Default shared secret size in bytes, the 160 bits RFC 4226 recommends. */
|
|
28
|
+
const DEFAULT_SECRET_BYTES = 20;
|
|
29
|
+
/** Dynamic truncation keeps 31 bits, so ten digits is the most it can express. */
|
|
30
|
+
const MAX_DIGITS = 10;
|
|
31
|
+
/** Width of the big-endian counter the MAC is computed over. */
|
|
32
|
+
const COUNTER_BYTES = 8;
|
|
33
|
+
/** Milliseconds per second, for turning a timestamp into a step counter. */
|
|
34
|
+
const MS_PER_SECOND = 1000;
|
|
35
|
+
/** A submitted code must be digits only, so a formatted string never matches. */
|
|
36
|
+
const DIGITS_PATTERN = /^\d+$/;
|
|
37
|
+
/**
|
|
38
|
+
* Applies defaults and rejects parameters that cannot produce a valid code.
|
|
39
|
+
*
|
|
40
|
+
* Bad parameters are a caller mistake, so resolution surfaces a `Failure`
|
|
41
|
+
* naming the problem for the caller to fix.
|
|
42
|
+
*
|
|
43
|
+
* @param options Caller-supplied parameters.
|
|
44
|
+
* @returns Resolved parameters, or the reason they are unusable.
|
|
45
|
+
*/
|
|
46
|
+
function resolve(options) {
|
|
47
|
+
let digits = options.digits ?? DEFAULT_DIGITS;
|
|
48
|
+
if (!Number.isInteger(digits) || digits < 1 || digits > MAX_DIGITS) {
|
|
49
|
+
return failure(new CryptoError(`TOTP digits must be an integer between 1 and ${MAX_DIGITS}`));
|
|
50
|
+
}
|
|
51
|
+
let step = options.step ?? DEFAULT_STEP_SECONDS;
|
|
52
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
53
|
+
return failure(new CryptoError("TOTP step must be a positive whole number of seconds"));
|
|
54
|
+
}
|
|
55
|
+
let algorithm = options.algorithm ?? DEFAULT_ALGORITHM;
|
|
56
|
+
if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
|
|
57
|
+
return failure(new CryptoError("TOTP algorithm must be SHA-1, SHA-256, or SHA-512"));
|
|
58
|
+
}
|
|
59
|
+
return success({ digits, step, algorithm });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Converts a moment in time into the RFC 6238 step counter.
|
|
63
|
+
*
|
|
64
|
+
* @param at `Date`, epoch milliseconds, or nothing for the current time.
|
|
65
|
+
* @param step Time step in seconds.
|
|
66
|
+
* @returns Number of whole steps elapsed since the Unix epoch.
|
|
67
|
+
*/
|
|
68
|
+
function counterFor(at, step) {
|
|
69
|
+
let milliseconds = at instanceof Date ? at.getTime() : (at ?? Date.now());
|
|
70
|
+
return Math.floor(milliseconds / MS_PER_SECOND / step);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Encodes a step counter as the 8-byte big-endian block the MAC covers.
|
|
74
|
+
*
|
|
75
|
+
* @param counter Step counter, which must not be negative.
|
|
76
|
+
* @returns Eight bytes in network order.
|
|
77
|
+
*/
|
|
78
|
+
function counterBlock(counter) {
|
|
79
|
+
let block = new Uint8Array(COUNTER_BYTES);
|
|
80
|
+
new DataView(block.buffer).setBigUint64(0, BigInt(counter));
|
|
81
|
+
return block;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Derives the code for one specific step counter, applying RFC 4226 dynamic
|
|
85
|
+
* truncation: the top bit of the selected MAC window is cleared so the
|
|
86
|
+
* value stays positive before reducing modulo `10 ** digits`.
|
|
87
|
+
*
|
|
88
|
+
* @param key Decoded shared secret.
|
|
89
|
+
* @param counter Step counter to derive for.
|
|
90
|
+
* @param parameters Resolved digits and hash function.
|
|
91
|
+
* @returns The code as a zero-padded string, or why it could not be derived.
|
|
92
|
+
*/
|
|
93
|
+
async function codeFor(key, counter, parameters) {
|
|
94
|
+
let mac = await hmac.sign(key, counterBlock(counter), { hash: parameters.algorithm });
|
|
95
|
+
if (isFailure(mac))
|
|
96
|
+
return mac;
|
|
97
|
+
let bytes = mac.data;
|
|
98
|
+
let offset = (bytes[bytes.length - 1] ?? 0) & 0x0f;
|
|
99
|
+
let value = (((bytes[offset] ?? 0) & 0x7f) << 24) |
|
|
100
|
+
((bytes[offset + 1] ?? 0) << 16) |
|
|
101
|
+
((bytes[offset + 2] ?? 0) << 8) |
|
|
102
|
+
(bytes[offset + 3] ?? 0);
|
|
103
|
+
return success(String(value % 10 ** parameters.digits).padStart(parameters.digits, "0"));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Generates a random base32 shared secret ready for enrollment.
|
|
107
|
+
*
|
|
108
|
+
* The result is unpadded uppercase base32 because that is the only encoding
|
|
109
|
+
* authenticator apps accept in a QR code or a typed setup key.
|
|
110
|
+
*
|
|
111
|
+
* @param options Secret size.
|
|
112
|
+
* @returns Base32 secret to store for the account and show once during enrollment.
|
|
113
|
+
* @throws {RangeError} If `options.bytes` is not an integer the runtime can fill.
|
|
114
|
+
* @example
|
|
115
|
+
* let secret = totp.generateSecret(); // "JBSWY3DPEHPK3PXP..."
|
|
116
|
+
*/
|
|
117
|
+
function generateSecret(options = {}) {
|
|
118
|
+
return encodeBase32(randomBytes(options.bytes ?? DEFAULT_SECRET_BYTES));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Generates the code for a secret at a point in time.
|
|
122
|
+
*
|
|
123
|
+
* @param secret Base32 shared secret.
|
|
124
|
+
* @param options Time, step, digits, and hash function.
|
|
125
|
+
* @returns The current code, or why it could not be derived.
|
|
126
|
+
* @example
|
|
127
|
+
* let code = await totp.code(secret, { at: new Date("2026-01-01T00:00:00Z") });
|
|
128
|
+
*/
|
|
129
|
+
async function generateCode(secret, options = {}) {
|
|
130
|
+
let parameters = resolve(options);
|
|
131
|
+
if (isFailure(parameters))
|
|
132
|
+
return parameters;
|
|
133
|
+
let key = decodeBase32(secret);
|
|
134
|
+
if (isFailure(key))
|
|
135
|
+
return key;
|
|
136
|
+
return codeFor(key.data, counterFor(options.at, parameters.data.step), parameters.data);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Checks a submitted code against the current step and the drift window.
|
|
140
|
+
*
|
|
141
|
+
* Every step in the window is compared in constant time even after a match,
|
|
142
|
+
* so timing never reveals which step matched.
|
|
143
|
+
*
|
|
144
|
+
* @param secret Base32 shared secret.
|
|
145
|
+
* @param code Code submitted by the user.
|
|
146
|
+
* @param options Time, step, digits, hash function, and drift window.
|
|
147
|
+
* @returns Whether the code is valid, or why it could not be checked.
|
|
148
|
+
* @example
|
|
149
|
+
* let ok = await totp.verify(secret, form.code, { window: 1 });
|
|
150
|
+
*/
|
|
151
|
+
async function verifyCode(secret, code, options = {}) {
|
|
152
|
+
let parameters = resolve(options);
|
|
153
|
+
if (isFailure(parameters))
|
|
154
|
+
return parameters;
|
|
155
|
+
let window = options.window ?? DEFAULT_WINDOW;
|
|
156
|
+
if (!Number.isInteger(window) || window < 0) {
|
|
157
|
+
return failure(new CryptoError("TOTP window must be a non-negative whole number of steps"));
|
|
158
|
+
}
|
|
159
|
+
let key = decodeBase32(secret);
|
|
160
|
+
if (isFailure(key))
|
|
161
|
+
return key;
|
|
162
|
+
if (!DIGITS_PATTERN.test(code) || code.length !== parameters.data.digits)
|
|
163
|
+
return success(false);
|
|
164
|
+
let counter = counterFor(options.at, parameters.data.step);
|
|
165
|
+
let matched = false;
|
|
166
|
+
for (let offset = -window; offset <= window; offset++) {
|
|
167
|
+
if (counter + offset < 0)
|
|
168
|
+
continue;
|
|
169
|
+
let expected = await codeFor(key.data, counter + offset, parameters.data);
|
|
170
|
+
if (isFailure(expected))
|
|
171
|
+
return expected;
|
|
172
|
+
if (timingSafeEqual(expected.data, code))
|
|
173
|
+
matched = true;
|
|
174
|
+
}
|
|
175
|
+
return success(matched);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Builds the `otpauth://` URI an authenticator app scans during enrollment.
|
|
179
|
+
*
|
|
180
|
+
* The issuer appears both in the label and as a query parameter, which is what
|
|
181
|
+
* apps need to group and name the entry consistently.
|
|
182
|
+
*
|
|
183
|
+
* @param secret Base32 shared secret.
|
|
184
|
+
* @param options Issuer, account, and the parameters the app must mirror.
|
|
185
|
+
* @returns URI to render as a QR code or offer as a manual setup link.
|
|
186
|
+
* @example
|
|
187
|
+
* totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
188
|
+
*/
|
|
189
|
+
function buildUri(secret, options) {
|
|
190
|
+
let label = `${encodeURIComponent(options.issuer)}:${encodeURIComponent(options.account)}`;
|
|
191
|
+
let query = new URLSearchParams({
|
|
192
|
+
secret,
|
|
193
|
+
issuer: options.issuer,
|
|
194
|
+
algorithm: (options.algorithm ?? DEFAULT_ALGORITHM).replace("-", ""),
|
|
195
|
+
digits: String(options.digits ?? DEFAULT_DIGITS),
|
|
196
|
+
period: String(options.step ?? DEFAULT_STEP_SECONDS),
|
|
197
|
+
});
|
|
198
|
+
return `otpauth://totp/${label}?${query.toString().replaceAll("+", "%20")}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* RFC 6238 one-time passwords: secrets, codes, verification, and enrollment URIs.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* let secret = totp.generateSecret();
|
|
205
|
+
* let uri = totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
|
|
206
|
+
* let ok = await totp.verify(secret, submittedCode, { window: 1 });
|
|
207
|
+
*/
|
|
208
|
+
export const totp = {
|
|
209
|
+
generateSecret,
|
|
210
|
+
code: generateCode,
|
|
211
|
+
verify: verifyCode,
|
|
212
|
+
uri: buildUri,
|
|
213
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdxc/crypto",
|
|
3
|
+
"version": "0.0.0-pre.1",
|
|
4
|
+
"description": "WebCrypto primitives: hashing, HMAC, passwords, TOTP, AES-GCM",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sdxc/result": "0.0.0-pre.1"
|
|
12
|
+
},
|
|
13
|
+
"gitHead": "bbc1db99f3fcc3c903251a70582c6b0cf8e808cb",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
20
|
+
"directory": "packages/crypto"
|
|
21
|
+
}
|
|
22
|
+
}
|