@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/encoding.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hex, base64, and base64url codecs used by every other module in this package.
|
|
3
|
+
*
|
|
4
|
+
* Encoding differences (padding, letter case, URL-safe alphabet) turn into
|
|
5
|
+
* interoperability bugs when each call site rewrites them, so all three codecs
|
|
6
|
+
* live here, each with one canonical output shape and a validating decoder.
|
|
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
|
+
import { toBytes, toText } from "./lib/bytes.js";
|
|
14
|
+
/** Lowercase hex digits; encoding always emits from this alphabet. */
|
|
15
|
+
const HEX_ALPHABET = "0123456789abcdef";
|
|
16
|
+
/** Hex strings must have an even length and only hex digits, either case. */
|
|
17
|
+
const HEX_PATTERN = /^(?:[0-9a-fA-F]{2})*$/;
|
|
18
|
+
/** Base64url payloads allow the URL-safe alphabet plus optional trailing padding. */
|
|
19
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*={0,2}$/;
|
|
20
|
+
/** Standard base64 payloads come in whole quartets, the last one padded to length. */
|
|
21
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
22
|
+
/** Bytes a base64 group carries; a length off this multiple leaves a short final group. */
|
|
23
|
+
const GROUP_BYTES = 3;
|
|
24
|
+
/** Characters a full base64 group spans; one character past a multiple holds no whole byte. */
|
|
25
|
+
const GROUP_CHARS = 4;
|
|
26
|
+
/** ASCII code of `=`, the character that fills a short final group out to four. */
|
|
27
|
+
const PADDING_CODE = 0x3d;
|
|
28
|
+
/** Code points a reverse lookup covers, which spans every base64 character. */
|
|
29
|
+
const ASCII_RANGE = 128;
|
|
30
|
+
/** Standard base64 alphabet (RFC 4648 §4); a character's index is the value it carries. */
|
|
31
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
32
|
+
/** URL-safe base64 alphabet (RFC 4648 §5); a character's index is the value it carries. */
|
|
33
|
+
const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
34
|
+
/**
|
|
35
|
+
* Builds an alphabet's reverse lookup: a string whose character at each ASCII code
|
|
36
|
+
* point carries the six-bit value that code point stands for.
|
|
37
|
+
*
|
|
38
|
+
* Keying by code point resolves a character in a single `charCodeAt`, which keeps
|
|
39
|
+
* decoding linear in the input length for multi-megabyte payloads.
|
|
40
|
+
*/
|
|
41
|
+
function toValueLookup(alphabet) {
|
|
42
|
+
let values = Array.from({ length: ASCII_RANGE }).fill("\0");
|
|
43
|
+
for (let value = 0; value < alphabet.length; value++) {
|
|
44
|
+
values[alphabet.charCodeAt(value)] = String.fromCharCode(value);
|
|
45
|
+
}
|
|
46
|
+
return values.join("");
|
|
47
|
+
}
|
|
48
|
+
/** Reverse lookup for the standard alphabet, keyed by ASCII code point. */
|
|
49
|
+
const BASE64_VALUES = toValueLookup(BASE64_ALPHABET);
|
|
50
|
+
/** Reverse lookup for the URL-safe alphabet, keyed by ASCII code point. */
|
|
51
|
+
const BASE64URL_VALUES = toValueLookup(BASE64URL_ALPHABET);
|
|
52
|
+
/**
|
|
53
|
+
* Encodes bytes over a base64 alphabet, one character per six bits of input.
|
|
54
|
+
*
|
|
55
|
+
* Characters land in a single buffer sized from the input length, so a
|
|
56
|
+
* multi-megabyte payload encodes in one pass and one allocation.
|
|
57
|
+
*
|
|
58
|
+
* @param bytes Payload to encode.
|
|
59
|
+
* @param alphabet Alphabet supplying a character for each six-bit value.
|
|
60
|
+
* @param padded Whether a short final group is filled out to four characters with `=`.
|
|
61
|
+
* @returns Encoded text.
|
|
62
|
+
*/
|
|
63
|
+
function encodeBase64(bytes, alphabet, padded) {
|
|
64
|
+
let remainder = bytes.length % GROUP_BYTES;
|
|
65
|
+
let tail = remainder === 0 ? 0 : padded ? GROUP_CHARS : remainder + 1;
|
|
66
|
+
let out = new Uint8Array(((bytes.length - remainder) / GROUP_BYTES) * GROUP_CHARS + tail);
|
|
67
|
+
let accumulator = 0;
|
|
68
|
+
let pending = 0;
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
for (let byte of bytes) {
|
|
71
|
+
accumulator = (accumulator << 8) | byte;
|
|
72
|
+
pending += 8;
|
|
73
|
+
while (pending >= 6) {
|
|
74
|
+
pending -= 6;
|
|
75
|
+
out[cursor++] = alphabet.charCodeAt((accumulator >> pending) & 0x3f);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (pending > 0)
|
|
79
|
+
out[cursor++] = alphabet.charCodeAt((accumulator << (6 - pending)) & 0x3f);
|
|
80
|
+
while (cursor < out.length)
|
|
81
|
+
out[cursor++] = PADDING_CODE;
|
|
82
|
+
return toText(out);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decodes base64 text written in the alphabet a reverse lookup describes.
|
|
86
|
+
*
|
|
87
|
+
* The bits a short final group leaves over must be zero, which maps the strings of
|
|
88
|
+
* each payload length one-to-one onto byte strings, so a text whose final character
|
|
89
|
+
* was altered fails the decode.
|
|
90
|
+
*
|
|
91
|
+
* @param text Text whose character set the caller has already validated.
|
|
92
|
+
* @param values Reverse lookup for the alphabet the text is written in.
|
|
93
|
+
* @param encoding Encoding name the error reports.
|
|
94
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the text is not the canonical encoding of any byte string.
|
|
95
|
+
*/
|
|
96
|
+
function decodeBase64(text, values, encoding) {
|
|
97
|
+
let end = text.length;
|
|
98
|
+
while (end > 0 && text.charCodeAt(end - 1) === PADDING_CODE)
|
|
99
|
+
end--;
|
|
100
|
+
let remainder = end % GROUP_CHARS;
|
|
101
|
+
if (remainder === 1)
|
|
102
|
+
return failure(new InvalidEncodingError(encoding));
|
|
103
|
+
let groups = (end - remainder) / GROUP_CHARS;
|
|
104
|
+
let bytes = new Uint8Array(groups * GROUP_BYTES + (remainder === 0 ? 0 : remainder - 1));
|
|
105
|
+
let accumulator = 0;
|
|
106
|
+
let pending = 0;
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
for (let index = 0; index < end; index++) {
|
|
109
|
+
accumulator = (accumulator << 6) | values.charCodeAt(text.charCodeAt(index));
|
|
110
|
+
pending += 6;
|
|
111
|
+
if (pending >= 8) {
|
|
112
|
+
pending -= 8;
|
|
113
|
+
bytes[cursor++] = (accumulator >> pending) & 0xff;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if ((accumulator & ((1 << pending) - 1)) !== 0) {
|
|
117
|
+
return failure(new InvalidEncodingError(encoding));
|
|
118
|
+
}
|
|
119
|
+
return success(bytes);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Lowercase hexadecimal encoding, the canonical text form for digests and MACs.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* Hex.encode(new Uint8Array([255, 0])); // "ff00"
|
|
126
|
+
*/
|
|
127
|
+
export class Hex {
|
|
128
|
+
/**
|
|
129
|
+
* Encodes bytes (or UTF-8 text) as lowercase hex.
|
|
130
|
+
*
|
|
131
|
+
* @param data Payload to encode.
|
|
132
|
+
* @returns Hex string, two characters per byte, never padded or uppercased.
|
|
133
|
+
* @example
|
|
134
|
+
* Hex.encode("hi"); // "6869"
|
|
135
|
+
*/
|
|
136
|
+
static encode(data) {
|
|
137
|
+
let bytes = toBytes(data);
|
|
138
|
+
let out = "";
|
|
139
|
+
for (let byte of bytes) {
|
|
140
|
+
out += HEX_ALPHABET.charAt(byte >> 4);
|
|
141
|
+
out += HEX_ALPHABET.charAt(byte & 0x0f);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Decodes a hex string, accepting either letter case.
|
|
147
|
+
*
|
|
148
|
+
* An odd length or a non-hex character rejects the whole input up front, so a
|
|
149
|
+
* truncated signature always fails verification against a prefix.
|
|
150
|
+
*
|
|
151
|
+
* @param text Hex string to decode.
|
|
152
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not hex.
|
|
153
|
+
* @example
|
|
154
|
+
* Hex.decode("ff00"); // success(Uint8Array [255, 0])
|
|
155
|
+
*/
|
|
156
|
+
static decode(text) {
|
|
157
|
+
if (!HEX_PATTERN.test(text))
|
|
158
|
+
return failure(new InvalidEncodingError("hex"));
|
|
159
|
+
let bytes = new Uint8Array(text.length / 2);
|
|
160
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
161
|
+
bytes[index] = Number.parseInt(text.slice(index * 2, index * 2 + 2), 16);
|
|
162
|
+
}
|
|
163
|
+
return success(bytes);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Unpadded base64url encoding, safe in URLs, headers, and file names.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* Base64Url.encode(new Uint8Array([251, 255])); // "-_8"
|
|
171
|
+
*/
|
|
172
|
+
export class Base64Url {
|
|
173
|
+
/**
|
|
174
|
+
* Encodes bytes (or UTF-8 text) as base64url without `=` padding.
|
|
175
|
+
*
|
|
176
|
+
* Padding is dropped because these values travel in URLs and query strings,
|
|
177
|
+
* where `=` needs escaping; `decode` accepts it back either way.
|
|
178
|
+
*
|
|
179
|
+
* @param data Payload to encode.
|
|
180
|
+
* @returns Base64url string using only `A-Z`, `a-z`, `0-9`, `-`, and `_`.
|
|
181
|
+
* @example
|
|
182
|
+
* Base64Url.encode("hi"); // "aGk"
|
|
183
|
+
*/
|
|
184
|
+
static encode(data) {
|
|
185
|
+
return encodeBase64(toBytes(data), BASE64URL_ALPHABET, false);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Decodes base64url text, tolerating present or absent `=` padding.
|
|
189
|
+
*
|
|
190
|
+
* The URL-safe alphabet is the whole accepted input set, and a short final
|
|
191
|
+
* group's leftover bits must be zero, so two accepted strings decode to the same
|
|
192
|
+
* bytes exactly when they differ only in trailing `=`.
|
|
193
|
+
*
|
|
194
|
+
* @param text Base64url string to decode.
|
|
195
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not canonical base64url.
|
|
196
|
+
* @example
|
|
197
|
+
* Base64Url.decode("aGk"); // success(bytes for "hi")
|
|
198
|
+
*/
|
|
199
|
+
static decode(text) {
|
|
200
|
+
if (!BASE64URL_PATTERN.test(text))
|
|
201
|
+
return failure(new InvalidEncodingError("base64url"));
|
|
202
|
+
return decodeBase64(text, BASE64URL_VALUES, "base64url");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Padded standard base64, the alphabet RFC 4648 §4 defines.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* Base64.encode(new Uint8Array([251, 255])); // "+/8="
|
|
210
|
+
*/
|
|
211
|
+
export class Base64 {
|
|
212
|
+
/**
|
|
213
|
+
* Encodes bytes (or UTF-8 text) as standard base64 with `=` padding.
|
|
214
|
+
*
|
|
215
|
+
* Text becomes its UTF-8 bytes first, so a payload outside Latin-1 encodes to
|
|
216
|
+
* the octets a peer decodes it back from, as the `user:password` credentials of
|
|
217
|
+
* HTTP Basic authentication require (RFC 7617 §2.1).
|
|
218
|
+
*
|
|
219
|
+
* @param data Payload to encode.
|
|
220
|
+
* @returns Base64 string over `A-Z`, `a-z`, `0-9`, `+`, `/`, padded to a multiple of four characters.
|
|
221
|
+
* @example
|
|
222
|
+
* Base64.encode("Aladdin:open sesame"); // "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
|
|
223
|
+
*/
|
|
224
|
+
static encode(data) {
|
|
225
|
+
return encodeBase64(toBytes(data), BASE64_ALPHABET, true);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Decodes standard base64 text carrying its full `=` padding.
|
|
229
|
+
*
|
|
230
|
+
* The standard alphabet with full padding is the whole accepted input set, and a
|
|
231
|
+
* short final group's leftover bits must be zero, so one byte string has exactly
|
|
232
|
+
* one accepted spelling.
|
|
233
|
+
*
|
|
234
|
+
* @param text Base64 string to decode.
|
|
235
|
+
* @returns Decoded bytes, or `InvalidEncodingError` when the input is not canonical padded base64.
|
|
236
|
+
* @example
|
|
237
|
+
* Base64.decode("aGk="); // success(bytes for "hi")
|
|
238
|
+
*/
|
|
239
|
+
static decode(text) {
|
|
240
|
+
if (!BASE64_PATTERN.test(text))
|
|
241
|
+
return failure(new InvalidEncodingError("base64"));
|
|
242
|
+
return decodeBase64(text, BASE64_VALUES, "base64");
|
|
243
|
+
}
|
|
244
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error values returned by every failing operation in this package.
|
|
3
|
+
*
|
|
4
|
+
* All of them extend `CryptoError`, so one `instanceof` check covers the whole
|
|
5
|
+
* package while the subclasses let callers branch on the cause. Messages carry
|
|
6
|
+
* only the shape of the problem, never secrets, hashes, or ciphertext.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Base class for every error this package returns inside a `Result`.
|
|
13
|
+
*
|
|
14
|
+
* Use it as the error type in signatures and as the `instanceof` check when
|
|
15
|
+
* callers handle every cause the same way.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* if (isFailure(result) && result.error instanceof CryptoError) reportFailure();
|
|
19
|
+
*/
|
|
20
|
+
export declare class CryptoError extends Error {
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A string could not be decoded with the encoding it was expected to use.
|
|
25
|
+
*
|
|
26
|
+
* The offending input is deliberately absent from the message, because the same
|
|
27
|
+
* error covers secrets such as TOTP seeds and sealed payloads.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* let bytes = Hex.decode("zz"); // failure(new InvalidEncodingError("hex"))
|
|
31
|
+
*/
|
|
32
|
+
export declare class InvalidEncodingError extends CryptoError {
|
|
33
|
+
name: string;
|
|
34
|
+
/**
|
|
35
|
+
* @param encoding Name of the expected encoding, such as "hex" or "base64url".
|
|
36
|
+
*/
|
|
37
|
+
constructor(encoding: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A stored password hash's format diverges from the one this package writes.
|
|
41
|
+
*
|
|
42
|
+
* Callers see this for values produced by a different hashing scheme, which is
|
|
43
|
+
* the signal to fall back to a compatibility path or to force a reset.
|
|
44
|
+
*/
|
|
45
|
+
export declare class MalformedHashError extends CryptoError {
|
|
46
|
+
name: string;
|
|
47
|
+
/**
|
|
48
|
+
* @param reason Fixed description of the structural problem, free of stored material.
|
|
49
|
+
*/
|
|
50
|
+
constructor(reason: string);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* An algorithm identifier is syntactically valid yet unsupported here.
|
|
54
|
+
*
|
|
55
|
+
* The identifier is sanitized before it reaches the message, so a hostile stored
|
|
56
|
+
* value cannot smuggle content into logs.
|
|
57
|
+
*/
|
|
58
|
+
export declare class UnsupportedAlgorithmError extends CryptoError {
|
|
59
|
+
name: string;
|
|
60
|
+
/**
|
|
61
|
+
* @param algorithm Algorithm identifier, sanitized down to a short tag.
|
|
62
|
+
*/
|
|
63
|
+
constructor(algorithm: string);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Raw key material could not be turned into a usable `CryptoKey`.
|
|
67
|
+
*
|
|
68
|
+
* Raised for wrong key sizes and for material the runtime rejects; the key bytes
|
|
69
|
+
* themselves never appear in the message.
|
|
70
|
+
*/
|
|
71
|
+
export declare class InvalidKeyError extends CryptoError {
|
|
72
|
+
name: string;
|
|
73
|
+
/**
|
|
74
|
+
* @param reason Fixed description of why the key was rejected.
|
|
75
|
+
*/
|
|
76
|
+
constructor(reason: string);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A sealed value's structure diverges from the versioned envelope format.
|
|
80
|
+
*
|
|
81
|
+
* Raised before decryption begins, so it flags a structural problem independent
|
|
82
|
+
* of whether the key or the authentication tag would have matched.
|
|
83
|
+
*/
|
|
84
|
+
export declare class InvalidEnvelopeError extends CryptoError {
|
|
85
|
+
name: string;
|
|
86
|
+
/**
|
|
87
|
+
* @param reason Fixed description of the envelope problem, free of ciphertext.
|
|
88
|
+
*/
|
|
89
|
+
constructor(reason: string);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Authenticated decryption failed for a well-formed envelope.
|
|
93
|
+
*
|
|
94
|
+
* The message is intentionally identical for a wrong key and for tampered
|
|
95
|
+
* ciphertext, so failures cannot be used as an oracle.
|
|
96
|
+
*/
|
|
97
|
+
export declare class DecryptionError extends CryptoError {
|
|
98
|
+
name: string;
|
|
99
|
+
constructor();
|
|
100
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error values returned by every failing operation in this package.
|
|
3
|
+
*
|
|
4
|
+
* All of them extend `CryptoError`, so one `instanceof` check covers the whole
|
|
5
|
+
* package while the subclasses let callers branch on the cause. Messages carry
|
|
6
|
+
* only the shape of the problem, never secrets, hashes, or ciphertext.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
/** Longest algorithm tag kept in an error message before it is truncated. */
|
|
12
|
+
const MAX_TAG_LENGTH = 32;
|
|
13
|
+
/**
|
|
14
|
+
* Reduces a value to a short `[A-Za-z0-9-]` tag safe to place in a message.
|
|
15
|
+
*
|
|
16
|
+
* Algorithm identifiers come from stored values, so this treats them as
|
|
17
|
+
* untrusted, dropping disallowed characters and truncating the result.
|
|
18
|
+
*/
|
|
19
|
+
function sanitizeTag(value) {
|
|
20
|
+
let tag = value.replaceAll(/[^A-Za-z0-9-]/g, "").slice(0, MAX_TAG_LENGTH);
|
|
21
|
+
return tag.length > 0 ? tag : "unknown";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Base class for every error this package returns inside a `Result`.
|
|
25
|
+
*
|
|
26
|
+
* Use it as the error type in signatures and as the `instanceof` check when
|
|
27
|
+
* callers handle every cause the same way.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* if (isFailure(result) && result.error instanceof CryptoError) reportFailure();
|
|
31
|
+
*/
|
|
32
|
+
export class CryptoError extends Error {
|
|
33
|
+
name = "CryptoError";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A string could not be decoded with the encoding it was expected to use.
|
|
37
|
+
*
|
|
38
|
+
* The offending input is deliberately absent from the message, because the same
|
|
39
|
+
* error covers secrets such as TOTP seeds and sealed payloads.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* let bytes = Hex.decode("zz"); // failure(new InvalidEncodingError("hex"))
|
|
43
|
+
*/
|
|
44
|
+
export class InvalidEncodingError extends CryptoError {
|
|
45
|
+
name = "InvalidEncodingError";
|
|
46
|
+
/**
|
|
47
|
+
* @param encoding Name of the expected encoding, such as "hex" or "base64url".
|
|
48
|
+
*/
|
|
49
|
+
constructor(encoding) {
|
|
50
|
+
super(`Invalid ${encoding} input`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A stored password hash's format diverges from the one this package writes.
|
|
55
|
+
*
|
|
56
|
+
* Callers see this for values produced by a different hashing scheme, which is
|
|
57
|
+
* the signal to fall back to a compatibility path or to force a reset.
|
|
58
|
+
*/
|
|
59
|
+
export class MalformedHashError extends CryptoError {
|
|
60
|
+
name = "MalformedHashError";
|
|
61
|
+
/**
|
|
62
|
+
* @param reason Fixed description of the structural problem, free of stored material.
|
|
63
|
+
*/
|
|
64
|
+
constructor(reason) {
|
|
65
|
+
super(`Malformed password hash: ${reason}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* An algorithm identifier is syntactically valid yet unsupported here.
|
|
70
|
+
*
|
|
71
|
+
* The identifier is sanitized before it reaches the message, so a hostile stored
|
|
72
|
+
* value cannot smuggle content into logs.
|
|
73
|
+
*/
|
|
74
|
+
export class UnsupportedAlgorithmError extends CryptoError {
|
|
75
|
+
name = "UnsupportedAlgorithmError";
|
|
76
|
+
/**
|
|
77
|
+
* @param algorithm Algorithm identifier, sanitized down to a short tag.
|
|
78
|
+
*/
|
|
79
|
+
constructor(algorithm) {
|
|
80
|
+
super(`Unsupported algorithm: ${sanitizeTag(algorithm)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Raw key material could not be turned into a usable `CryptoKey`.
|
|
85
|
+
*
|
|
86
|
+
* Raised for wrong key sizes and for material the runtime rejects; the key bytes
|
|
87
|
+
* themselves never appear in the message.
|
|
88
|
+
*/
|
|
89
|
+
export class InvalidKeyError extends CryptoError {
|
|
90
|
+
name = "InvalidKeyError";
|
|
91
|
+
/**
|
|
92
|
+
* @param reason Fixed description of why the key was rejected.
|
|
93
|
+
*/
|
|
94
|
+
constructor(reason) {
|
|
95
|
+
super(`Invalid key: ${reason}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A sealed value's structure diverges from the versioned envelope format.
|
|
100
|
+
*
|
|
101
|
+
* Raised before decryption begins, so it flags a structural problem independent
|
|
102
|
+
* of whether the key or the authentication tag would have matched.
|
|
103
|
+
*/
|
|
104
|
+
export class InvalidEnvelopeError extends CryptoError {
|
|
105
|
+
name = "InvalidEnvelopeError";
|
|
106
|
+
/**
|
|
107
|
+
* @param reason Fixed description of the envelope problem, free of ciphertext.
|
|
108
|
+
*/
|
|
109
|
+
constructor(reason) {
|
|
110
|
+
super(`Invalid sealed value: ${reason}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Authenticated decryption failed for a well-formed envelope.
|
|
115
|
+
*
|
|
116
|
+
* The message is intentionally identical for a wrong key and for tampered
|
|
117
|
+
* ciphertext, so failures cannot be used as an oracle.
|
|
118
|
+
*/
|
|
119
|
+
export class DecryptionError extends CryptoError {
|
|
120
|
+
name = "DecryptionError";
|
|
121
|
+
constructor() {
|
|
122
|
+
super("Decryption failed");
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-2 digests over text or binary payloads.
|
|
3
|
+
*
|
|
4
|
+
* These are the lookup hashes for values that must stay searchable, such as API
|
|
5
|
+
* keys stored as digests: the same input always produces the same bytes, so a row
|
|
6
|
+
* can be found by hashing the presented secret instead of storing it.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { BinaryLike, Bytes } from "./lib/bytes.js";
|
|
13
|
+
import { CryptoError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Hashes a payload with SHA-256.
|
|
16
|
+
*
|
|
17
|
+
* Unsalted and deterministic by design, which makes it right for lookups and
|
|
18
|
+
* fingerprints and wrong for passwords; use `password.hash` for those.
|
|
19
|
+
*
|
|
20
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
21
|
+
* @returns The 32 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
22
|
+
* @example
|
|
23
|
+
* let digest = await sha256(apiKey);
|
|
24
|
+
* if (isSuccess(digest)) lookupBy(Hex.encode(digest.data));
|
|
25
|
+
*/
|
|
26
|
+
export declare function sha256(data: BinaryLike): Promise<Result<Bytes, CryptoError>>;
|
|
27
|
+
/**
|
|
28
|
+
* Hashes a payload with SHA-384.
|
|
29
|
+
*
|
|
30
|
+
* The digest an OpenID Connect token hash claim needs when the ID token is signed
|
|
31
|
+
* with an `ES384`, `RS384`, or `PS384` algorithm (OpenID Connect Core §3.1.3.6).
|
|
32
|
+
*
|
|
33
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
34
|
+
* @returns The 48 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
35
|
+
* @example
|
|
36
|
+
* let digest = unwrap(await sha384(accessToken));
|
|
37
|
+
* let atHash = Base64Url.encode(digest.subarray(0, digest.length / 2));
|
|
38
|
+
*/
|
|
39
|
+
export declare function sha384(data: BinaryLike): Promise<Result<Bytes, CryptoError>>;
|
|
40
|
+
/**
|
|
41
|
+
* Hashes a payload with SHA-512.
|
|
42
|
+
*
|
|
43
|
+
* The digest an OpenID Connect token hash claim needs when the ID token is signed
|
|
44
|
+
* with an `ES512`, `RS512`, or `PS512` algorithm (OpenID Connect Core §3.1.3.6).
|
|
45
|
+
*
|
|
46
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
47
|
+
* @returns The 64 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
48
|
+
* @example
|
|
49
|
+
* let digest = unwrap(await sha512(accessToken));
|
|
50
|
+
* let atHash = Base64Url.encode(digest.subarray(0, digest.length / 2));
|
|
51
|
+
*/
|
|
52
|
+
export declare function sha512(data: BinaryLike): Promise<Result<Bytes, CryptoError>>;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-2 digests over text or binary payloads.
|
|
3
|
+
*
|
|
4
|
+
* These are the lookup hashes for values that must stay searchable, such as API
|
|
5
|
+
* keys stored as digests: the same input always produces the same bytes, so a row
|
|
6
|
+
* can be found by hashing the presented secret instead of storing it.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, success } from "@sdxc/result";
|
|
12
|
+
import { CryptoError } from "./errors.js";
|
|
13
|
+
import { toBytes } from "./lib/bytes.js";
|
|
14
|
+
/**
|
|
15
|
+
* Digests a payload with one WebCrypto hash, reporting a refusal as a value.
|
|
16
|
+
*
|
|
17
|
+
* Every exported digest routes through here, so the three of them agree on how
|
|
18
|
+
* text is encoded and on what a runtime failure looks like to a caller.
|
|
19
|
+
*
|
|
20
|
+
* @param algorithm Digest name as WebCrypto spells it.
|
|
21
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
22
|
+
* @returns The digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
23
|
+
*/
|
|
24
|
+
async function digest(algorithm, data) {
|
|
25
|
+
try {
|
|
26
|
+
let hashed = await crypto.subtle.digest(algorithm, toBytes(data));
|
|
27
|
+
return success(new Uint8Array(hashed));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return failure(new CryptoError(`${algorithm} digest failed`));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Hashes a payload with SHA-256.
|
|
35
|
+
*
|
|
36
|
+
* Unsalted and deterministic by design, which makes it right for lookups and
|
|
37
|
+
* fingerprints and wrong for passwords; use `password.hash` for those.
|
|
38
|
+
*
|
|
39
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
40
|
+
* @returns The 32 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
41
|
+
* @example
|
|
42
|
+
* let digest = await sha256(apiKey);
|
|
43
|
+
* if (isSuccess(digest)) lookupBy(Hex.encode(digest.data));
|
|
44
|
+
*/
|
|
45
|
+
export function sha256(data) {
|
|
46
|
+
return digest("SHA-256", data);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Hashes a payload with SHA-384.
|
|
50
|
+
*
|
|
51
|
+
* The digest an OpenID Connect token hash claim needs when the ID token is signed
|
|
52
|
+
* with an `ES384`, `RS384`, or `PS384` algorithm (OpenID Connect Core §3.1.3.6).
|
|
53
|
+
*
|
|
54
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
55
|
+
* @returns The 48 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
56
|
+
* @example
|
|
57
|
+
* let digest = unwrap(await sha384(accessToken));
|
|
58
|
+
* let atHash = Base64Url.encode(digest.subarray(0, digest.length / 2));
|
|
59
|
+
*/
|
|
60
|
+
export function sha384(data) {
|
|
61
|
+
return digest("SHA-384", data);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Hashes a payload with SHA-512.
|
|
65
|
+
*
|
|
66
|
+
* The digest an OpenID Connect token hash claim needs when the ID token is signed
|
|
67
|
+
* with an `ES512`, `RS512`, or `PS512` algorithm (OpenID Connect Core §3.1.3.6).
|
|
68
|
+
*
|
|
69
|
+
* @param data Text (read as UTF-8) or bytes to digest.
|
|
70
|
+
* @returns The 64 digest bytes, or a `CryptoError` if the runtime rejects the operation.
|
|
71
|
+
* @example
|
|
72
|
+
* let digest = unwrap(await sha512(accessToken));
|
|
73
|
+
* let atHash = Base64Url.encode(digest.subarray(0, digest.length / 2));
|
|
74
|
+
*/
|
|
75
|
+
export function sha512(data) {
|
|
76
|
+
return digest("SHA-512", data);
|
|
77
|
+
}
|
package/dist/hmac.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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 type { Result } from "@sdxc/result";
|
|
12
|
+
import type { BinaryLike, Bytes } from "./lib/bytes.js";
|
|
13
|
+
import { CryptoError } from "./errors.js";
|
|
14
|
+
/** Hash functions WebCrypto exposes for HMAC keys. */
|
|
15
|
+
declare const SUPPORTED_HASHES: readonly ["SHA-1", "SHA-256", "SHA-384", "SHA-512"];
|
|
16
|
+
/**
|
|
17
|
+
* Types for the `hmac` operations.
|
|
18
|
+
*/
|
|
19
|
+
export declare namespace hmac {
|
|
20
|
+
/** Hash function backing an HMAC key. */
|
|
21
|
+
type Hash = (typeof SUPPORTED_HASHES)[number];
|
|
22
|
+
/** Shared options for signing and verifying. */
|
|
23
|
+
interface Options {
|
|
24
|
+
/**
|
|
25
|
+
* Hash function to key.
|
|
26
|
+
* @default "SHA-256"
|
|
27
|
+
*/
|
|
28
|
+
hash?: Hash;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Signs a payload with a secret, returning the raw MAC bytes.
|
|
33
|
+
*
|
|
34
|
+
* @param secret Key material; text is read as UTF-8.
|
|
35
|
+
* @param payload Message to authenticate.
|
|
36
|
+
* @param options Hash selection.
|
|
37
|
+
* @returns MAC bytes, or a `CryptoError` when the hash is unsupported or the runtime refuses the key.
|
|
38
|
+
*/
|
|
39
|
+
declare function sign(secret: BinaryLike, payload: BinaryLike, options?: hmac.Options): Promise<Result<Bytes, CryptoError>>;
|
|
40
|
+
/**
|
|
41
|
+
* Recomputes the MAC for a payload and compares it in constant time. A
|
|
42
|
+
* signature given as a string decodes as hex; an undecodable string resolves
|
|
43
|
+
* to a mismatch, so a malformed header fails closed automatically.
|
|
44
|
+
*
|
|
45
|
+
* @param secret Key material; text is read as UTF-8.
|
|
46
|
+
* @param payload Message the signature is supposed to cover.
|
|
47
|
+
* @param signature MAC to check, as bytes or as a hex string.
|
|
48
|
+
* @param options Hash selection; must match the hash used to sign.
|
|
49
|
+
* @returns Whether the signature matches, or a `CryptoError` when the MAC could not be computed.
|
|
50
|
+
* @example
|
|
51
|
+
* let ok = await hmac.verify(secret, body, request.headers.get("x-signature") ?? "");
|
|
52
|
+
*/
|
|
53
|
+
declare function verify(secret: BinaryLike, payload: BinaryLike, signature: BinaryLike, options?: hmac.Options): Promise<Result<boolean, CryptoError>>;
|
|
54
|
+
/**
|
|
55
|
+
* Keyed message authentication with a constant-time verifier.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* let mac = await hmac.sign(secret, payload);
|
|
59
|
+
* let ok = await hmac.verify(secret, payload, Hex.encode(unwrap(mac)));
|
|
60
|
+
*/
|
|
61
|
+
export declare const hmac: {
|
|
62
|
+
sign: typeof sign;
|
|
63
|
+
verify: typeof verify;
|
|
64
|
+
};
|
|
65
|
+
export {};
|