@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/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
+ }