@sigil-security/core 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/index.cjs +639 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +546 -0
- package/dist/index.d.ts +546 -0
- package/dist/index.js +575 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// src/web-crypto-provider.ts
|
|
2
|
+
var WebCryptoCryptoProvider = class {
|
|
3
|
+
/**
|
|
4
|
+
* Signs data with HMAC-SHA256 using WebCrypto.
|
|
5
|
+
* Returns full 256-bit (32-byte) MAC, NO truncation.
|
|
6
|
+
*/
|
|
7
|
+
async sign(key, data) {
|
|
8
|
+
return globalThis.crypto.subtle.sign("HMAC", key, data);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Verifies an HMAC-SHA256 signature using WebCrypto.
|
|
12
|
+
* Inherently constant-time via crypto.subtle.verify.
|
|
13
|
+
*/
|
|
14
|
+
async verify(key, signature, data) {
|
|
15
|
+
return globalThis.crypto.subtle.verify("HMAC", key, signature, data);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Derives an HMAC-SHA256 signing key from a master secret via HKDF-SHA256.
|
|
19
|
+
*
|
|
20
|
+
* HKDF (RFC 5869) with:
|
|
21
|
+
* - Hash: SHA-256
|
|
22
|
+
* - Salt: encoded string
|
|
23
|
+
* - Info: encoded string (includes domain separation)
|
|
24
|
+
* - Output: HMAC-SHA256 key, 256-bit, non-extractable
|
|
25
|
+
*/
|
|
26
|
+
async deriveKey(master, salt, info) {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const baseKey = await globalThis.crypto.subtle.importKey("raw", master, { name: "HKDF" }, false, [
|
|
29
|
+
"deriveKey"
|
|
30
|
+
]);
|
|
31
|
+
return globalThis.crypto.subtle.deriveKey(
|
|
32
|
+
{
|
|
33
|
+
name: "HKDF",
|
|
34
|
+
hash: "SHA-256",
|
|
35
|
+
salt: encoder.encode(salt),
|
|
36
|
+
info: encoder.encode(info)
|
|
37
|
+
},
|
|
38
|
+
baseKey,
|
|
39
|
+
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
|
40
|
+
false,
|
|
41
|
+
["sign", "verify"]
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Generates cryptographically secure random bytes via crypto.getRandomValues.
|
|
46
|
+
* NEVER uses Math.random.
|
|
47
|
+
*/
|
|
48
|
+
randomBytes(length) {
|
|
49
|
+
const buffer = new Uint8Array(length);
|
|
50
|
+
globalThis.crypto.getRandomValues(buffer);
|
|
51
|
+
return buffer;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Computes SHA-256 hash via WebCrypto.
|
|
55
|
+
* Returns full 256-bit (32-byte) digest.
|
|
56
|
+
*/
|
|
57
|
+
async hash(data) {
|
|
58
|
+
return globalThis.crypto.subtle.digest("SHA-256", data);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/key-derivation.ts
|
|
63
|
+
var HKDF_SALT = "sigil-v1";
|
|
64
|
+
var DOMAIN_INFO_PREFIX = {
|
|
65
|
+
csrf: "csrf-signing-key-",
|
|
66
|
+
oneshot: "oneshot-signing-key-",
|
|
67
|
+
internal: "internal-signing-key-"
|
|
68
|
+
};
|
|
69
|
+
async function deriveSigningKey(cryptoProvider, master, kid, domain) {
|
|
70
|
+
const info = `${DOMAIN_INFO_PREFIX[domain]}${String(kid)}`;
|
|
71
|
+
return cryptoProvider.deriveKey(master, HKDF_SALT, info);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/key-manager.ts
|
|
75
|
+
var MAX_KEYS = 3;
|
|
76
|
+
var KID_MIN = 0;
|
|
77
|
+
var KID_MAX = 255;
|
|
78
|
+
function validateKid(kid) {
|
|
79
|
+
if (!Number.isInteger(kid) || kid < KID_MIN || kid > KID_MAX) {
|
|
80
|
+
throw new RangeError(
|
|
81
|
+
`kid must be an integer in the range ${String(KID_MIN)}\u2013${String(KID_MAX)}, got ${String(kid)}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function createKeyring(cryptoProvider, masterSecret, initialKid, domain) {
|
|
86
|
+
validateKid(initialKid);
|
|
87
|
+
const cryptoKey = await deriveSigningKey(cryptoProvider, masterSecret, initialKid, domain);
|
|
88
|
+
const entry = {
|
|
89
|
+
kid: initialKid,
|
|
90
|
+
cryptoKey,
|
|
91
|
+
createdAt: Date.now()
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
keys: [entry],
|
|
95
|
+
activeKid: initialKid,
|
|
96
|
+
domain
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function rotateKey(keyring, cryptoProvider, masterSecret, newKid) {
|
|
100
|
+
validateKid(newKid);
|
|
101
|
+
const cryptoKey = await deriveSigningKey(
|
|
102
|
+
cryptoProvider,
|
|
103
|
+
masterSecret,
|
|
104
|
+
newKid,
|
|
105
|
+
keyring.domain
|
|
106
|
+
);
|
|
107
|
+
const entry = {
|
|
108
|
+
kid: newKid,
|
|
109
|
+
cryptoKey,
|
|
110
|
+
createdAt: Date.now()
|
|
111
|
+
};
|
|
112
|
+
const keys = [entry, ...keyring.keys].slice(0, MAX_KEYS);
|
|
113
|
+
return {
|
|
114
|
+
keys,
|
|
115
|
+
activeKid: newKid,
|
|
116
|
+
domain: keyring.domain
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function resolveKey(keyring, kid) {
|
|
120
|
+
return keyring.keys.find((k) => k.kid === kid);
|
|
121
|
+
}
|
|
122
|
+
function getActiveKey(keyring) {
|
|
123
|
+
return resolveKey(keyring, keyring.activeKid);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/types.ts
|
|
127
|
+
var KID_SIZE = 1;
|
|
128
|
+
var NONCE_SIZE = 16;
|
|
129
|
+
var TIMESTAMP_SIZE = 8;
|
|
130
|
+
var CONTEXT_SIZE = 32;
|
|
131
|
+
var MAC_SIZE = 32;
|
|
132
|
+
var ACTION_SIZE = 32;
|
|
133
|
+
var TOKEN_RAW_SIZE = KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE + MAC_SIZE;
|
|
134
|
+
var ONESHOT_RAW_SIZE = NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE + CONTEXT_SIZE + MAC_SIZE;
|
|
135
|
+
var TOKEN_OFFSETS = {
|
|
136
|
+
/** kid starts at byte 0 */
|
|
137
|
+
KID: 0,
|
|
138
|
+
/** nonce starts at byte 1 */
|
|
139
|
+
NONCE: KID_SIZE,
|
|
140
|
+
/** timestamp starts at byte 17 */
|
|
141
|
+
TIMESTAMP: KID_SIZE + NONCE_SIZE,
|
|
142
|
+
/** context starts at byte 25 */
|
|
143
|
+
CONTEXT: KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE,
|
|
144
|
+
/** mac starts at byte 57 */
|
|
145
|
+
MAC: KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE
|
|
146
|
+
};
|
|
147
|
+
var ONESHOT_OFFSETS = {
|
|
148
|
+
/** nonce starts at byte 0 */
|
|
149
|
+
NONCE: 0,
|
|
150
|
+
/** timestamp starts at byte 16 */
|
|
151
|
+
TIMESTAMP: NONCE_SIZE,
|
|
152
|
+
/** action starts at byte 24 */
|
|
153
|
+
ACTION: NONCE_SIZE + TIMESTAMP_SIZE,
|
|
154
|
+
/** context starts at byte 56 */
|
|
155
|
+
CONTEXT: NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE,
|
|
156
|
+
/** mac starts at byte 88 */
|
|
157
|
+
MAC: NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE + CONTEXT_SIZE
|
|
158
|
+
};
|
|
159
|
+
var DEFAULT_TOKEN_TTL_MS = 20 * 60 * 1e3;
|
|
160
|
+
var DEFAULT_GRACE_WINDOW_MS = 60 * 1e3;
|
|
161
|
+
var DEFAULT_ONESHOT_TTL_MS = 5 * 60 * 1e3;
|
|
162
|
+
var DEFAULT_NONCE_CACHE_MAX = 1e4;
|
|
163
|
+
var DEFAULT_NONCE_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
164
|
+
|
|
165
|
+
// src/encoding.ts
|
|
166
|
+
function toBase64Url(buffer) {
|
|
167
|
+
let binary = "";
|
|
168
|
+
for (const byte of buffer) {
|
|
169
|
+
binary += String.fromCharCode(byte);
|
|
170
|
+
}
|
|
171
|
+
const base64 = btoa(binary);
|
|
172
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
173
|
+
}
|
|
174
|
+
function fromBase64Url(encoded) {
|
|
175
|
+
let base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
176
|
+
const padLength = (4 - base64.length % 4) % 4;
|
|
177
|
+
base64 += "=".repeat(padLength);
|
|
178
|
+
const binary = atob(base64);
|
|
179
|
+
const bytes = new Uint8Array(binary.length);
|
|
180
|
+
for (let i = 0; i < binary.length; i++) {
|
|
181
|
+
bytes[i] = binary.charCodeAt(i);
|
|
182
|
+
}
|
|
183
|
+
return bytes;
|
|
184
|
+
}
|
|
185
|
+
function writeUint64BE(buffer, value, offset) {
|
|
186
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
187
|
+
const high = Math.floor(value / 4294967296);
|
|
188
|
+
const low = value % 4294967296;
|
|
189
|
+
view.setUint32(offset, high, false);
|
|
190
|
+
view.setUint32(offset + 4, low, false);
|
|
191
|
+
}
|
|
192
|
+
function readUint64BE(buffer, offset) {
|
|
193
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
194
|
+
const high = view.getUint32(offset, false);
|
|
195
|
+
const low = view.getUint32(offset + 4, false);
|
|
196
|
+
return high * 4294967296 + low;
|
|
197
|
+
}
|
|
198
|
+
function toArrayBuffer(uint8) {
|
|
199
|
+
const copy = uint8.slice();
|
|
200
|
+
return copy.buffer;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/context.ts
|
|
204
|
+
var ZERO_BYTE = new Uint8Array([0]);
|
|
205
|
+
async function computeContext(cryptoProvider, ...bindings) {
|
|
206
|
+
if (bindings.length === 0) {
|
|
207
|
+
return emptyContext(cryptoProvider);
|
|
208
|
+
}
|
|
209
|
+
const encoder = new TextEncoder();
|
|
210
|
+
const parts = [];
|
|
211
|
+
for (const binding of bindings) {
|
|
212
|
+
parts.push(`${String(binding.length)}:${binding}\0`);
|
|
213
|
+
}
|
|
214
|
+
const data = encoder.encode(parts.join(""));
|
|
215
|
+
const hash = await cryptoProvider.hash(data);
|
|
216
|
+
return new Uint8Array(hash);
|
|
217
|
+
}
|
|
218
|
+
async function emptyContext(cryptoProvider) {
|
|
219
|
+
const hash = await cryptoProvider.hash(ZERO_BYTE);
|
|
220
|
+
return new Uint8Array(hash);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/token.ts
|
|
224
|
+
async function generateToken(cryptoProvider, key, context, ttlMs = DEFAULT_TOKEN_TTL_MS, now = Date.now()) {
|
|
225
|
+
try {
|
|
226
|
+
const nonce = cryptoProvider.randomBytes(NONCE_SIZE);
|
|
227
|
+
const ts = now;
|
|
228
|
+
const ctx = context ?? await emptyContext(cryptoProvider);
|
|
229
|
+
const payloadSize = KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE;
|
|
230
|
+
const payload = new Uint8Array(payloadSize);
|
|
231
|
+
payload[0] = key.kid & 255;
|
|
232
|
+
payload.set(nonce, KID_SIZE);
|
|
233
|
+
writeUint64BE(payload, ts, KID_SIZE + NONCE_SIZE);
|
|
234
|
+
payload.set(ctx, KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE);
|
|
235
|
+
const macBuffer = await cryptoProvider.sign(key.cryptoKey, payload);
|
|
236
|
+
const mac = new Uint8Array(macBuffer);
|
|
237
|
+
const tokenRaw = new Uint8Array(TOKEN_RAW_SIZE);
|
|
238
|
+
tokenRaw.set(payload, 0);
|
|
239
|
+
tokenRaw.set(mac, payloadSize);
|
|
240
|
+
const token = toBase64Url(tokenRaw);
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
token,
|
|
244
|
+
expiresAt: ts + ttlMs
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
reason: "token_generation_failed"
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function parseToken(tokenString) {
|
|
254
|
+
let raw;
|
|
255
|
+
try {
|
|
256
|
+
raw = fromBase64Url(tokenString);
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (raw.length !== TOKEN_RAW_SIZE) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const kid = raw[TOKEN_OFFSETS.KID];
|
|
264
|
+
if (kid === void 0) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const nonce = raw.slice(TOKEN_OFFSETS.NONCE, TOKEN_OFFSETS.NONCE + NONCE_SIZE);
|
|
268
|
+
const timestamp = readUint64BE(raw, TOKEN_OFFSETS.TIMESTAMP);
|
|
269
|
+
const context = raw.slice(TOKEN_OFFSETS.CONTEXT, TOKEN_OFFSETS.CONTEXT + CONTEXT_SIZE);
|
|
270
|
+
const mac = raw.slice(TOKEN_OFFSETS.MAC, TOKEN_OFFSETS.MAC + MAC_SIZE);
|
|
271
|
+
return { kid, nonce, timestamp, context, mac };
|
|
272
|
+
}
|
|
273
|
+
function assemblePayload(parsed) {
|
|
274
|
+
const payload = new Uint8Array(KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE);
|
|
275
|
+
payload[0] = parsed.kid & 255;
|
|
276
|
+
payload.set(parsed.nonce, KID_SIZE);
|
|
277
|
+
writeUint64BE(payload, parsed.timestamp, KID_SIZE + NONCE_SIZE);
|
|
278
|
+
payload.set(parsed.context, KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE);
|
|
279
|
+
return payload;
|
|
280
|
+
}
|
|
281
|
+
function serializeToken(kid, nonce, ts, ctx, mac) {
|
|
282
|
+
const tokenRaw = new Uint8Array(TOKEN_RAW_SIZE);
|
|
283
|
+
tokenRaw[0] = kid & 255;
|
|
284
|
+
tokenRaw.set(nonce, KID_SIZE);
|
|
285
|
+
writeUint64BE(tokenRaw, ts, KID_SIZE + NONCE_SIZE);
|
|
286
|
+
tokenRaw.set(ctx, KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE);
|
|
287
|
+
tokenRaw.set(mac, KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE);
|
|
288
|
+
return toBase64Url(tokenRaw);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/validation.ts
|
|
292
|
+
var DUMMY_PAYLOAD = new Uint8Array(KID_SIZE + NONCE_SIZE + TIMESTAMP_SIZE + CONTEXT_SIZE);
|
|
293
|
+
var DUMMY_MAC = new ArrayBuffer(32);
|
|
294
|
+
async function validateToken(cryptoProvider, keyring, tokenString, expectedContext, ttlMs = DEFAULT_TOKEN_TTL_MS, graceWindowMs = DEFAULT_GRACE_WINDOW_MS, now = Date.now()) {
|
|
295
|
+
let valid = true;
|
|
296
|
+
let reason = "unknown";
|
|
297
|
+
const parsed = parseToken(tokenString);
|
|
298
|
+
const parseOk = parsed !== null;
|
|
299
|
+
valid &&= parseOk;
|
|
300
|
+
if (!parseOk) reason = "parse_failed";
|
|
301
|
+
const key = parseOk ? resolveKey(keyring, parsed.kid) : void 0;
|
|
302
|
+
const keyOk = key !== void 0;
|
|
303
|
+
valid &&= keyOk;
|
|
304
|
+
if (!keyOk) reason = "unknown_kid";
|
|
305
|
+
const ttlResult = parseOk ? validateTTL(parsed.timestamp, ttlMs, graceWindowMs, now) : { withinTTL: false, inGraceWindow: false };
|
|
306
|
+
const ttlOk = ttlResult.withinTTL || ttlResult.inGraceWindow;
|
|
307
|
+
valid &&= ttlOk;
|
|
308
|
+
if (!ttlOk) reason = "expired";
|
|
309
|
+
const macPayload = parseOk ? assemblePayload(parsed) : DUMMY_PAYLOAD;
|
|
310
|
+
const macSignature = parseOk ? toArrayBuffer(parsed.mac) : DUMMY_MAC;
|
|
311
|
+
const verifyKey = keyOk ? key.cryptoKey : keyring.keys[0]?.cryptoKey;
|
|
312
|
+
let macOk;
|
|
313
|
+
if (verifyKey !== void 0) {
|
|
314
|
+
const actualResult = await cryptoProvider.verify(verifyKey, macSignature, macPayload);
|
|
315
|
+
macOk = keyOk ? actualResult : false;
|
|
316
|
+
} else {
|
|
317
|
+
macOk = false;
|
|
318
|
+
}
|
|
319
|
+
valid &&= macOk;
|
|
320
|
+
if (!macOk) reason = "invalid_mac";
|
|
321
|
+
let contextOk = true;
|
|
322
|
+
if (expectedContext !== void 0) {
|
|
323
|
+
contextOk = parseOk ? constantTimeEqual(parsed.context, expectedContext) : false;
|
|
324
|
+
}
|
|
325
|
+
valid &&= contextOk;
|
|
326
|
+
if (!contextOk) reason = "context_mismatch";
|
|
327
|
+
return valid ? { valid: true } : { valid: false, reason };
|
|
328
|
+
}
|
|
329
|
+
function validateTTL(tokenTimestamp, ttlMs, graceWindowMs, now) {
|
|
330
|
+
const age = now - tokenTimestamp;
|
|
331
|
+
const withinTTL = age >= 0 && age <= ttlMs;
|
|
332
|
+
const inGraceWindow = !withinTTL && age > ttlMs && age <= ttlMs + graceWindowMs;
|
|
333
|
+
return { withinTTL, inGraceWindow };
|
|
334
|
+
}
|
|
335
|
+
function constantTimeEqual(a, b) {
|
|
336
|
+
const length = Math.max(a.length, b.length);
|
|
337
|
+
let result = a.length ^ b.length;
|
|
338
|
+
for (let i = 0; i < length; i++) {
|
|
339
|
+
result |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
340
|
+
}
|
|
341
|
+
return result === 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/one-shot-token.ts
|
|
345
|
+
var ONESHOT_PAYLOAD_SIZE = NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE + CONTEXT_SIZE;
|
|
346
|
+
var DUMMY_ONESHOT_PAYLOAD = new Uint8Array(ONESHOT_PAYLOAD_SIZE);
|
|
347
|
+
var DUMMY_MAC2 = new ArrayBuffer(32);
|
|
348
|
+
async function computeAction(cryptoProvider, action) {
|
|
349
|
+
const encoder = new TextEncoder();
|
|
350
|
+
const data = encoder.encode(action);
|
|
351
|
+
const hash = await cryptoProvider.hash(data);
|
|
352
|
+
return new Uint8Array(hash);
|
|
353
|
+
}
|
|
354
|
+
async function generateOneShotToken(cryptoProvider, key, action, context, ttlMs = DEFAULT_ONESHOT_TTL_MS, now = Date.now()) {
|
|
355
|
+
try {
|
|
356
|
+
const nonce = cryptoProvider.randomBytes(NONCE_SIZE);
|
|
357
|
+
const ts = now;
|
|
358
|
+
const actionHash = await computeAction(cryptoProvider, action);
|
|
359
|
+
const ctx = context ?? await emptyContext(cryptoProvider);
|
|
360
|
+
const payload = new Uint8Array(ONESHOT_PAYLOAD_SIZE);
|
|
361
|
+
payload.set(nonce, 0);
|
|
362
|
+
writeUint64BE(payload, ts, NONCE_SIZE);
|
|
363
|
+
payload.set(actionHash, NONCE_SIZE + TIMESTAMP_SIZE);
|
|
364
|
+
payload.set(ctx, NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE);
|
|
365
|
+
const macBuffer = await cryptoProvider.sign(key.cryptoKey, payload);
|
|
366
|
+
const mac = new Uint8Array(macBuffer);
|
|
367
|
+
const tokenRaw = new Uint8Array(ONESHOT_RAW_SIZE);
|
|
368
|
+
tokenRaw.set(payload, 0);
|
|
369
|
+
tokenRaw.set(mac, ONESHOT_PAYLOAD_SIZE);
|
|
370
|
+
const token = toBase64Url(tokenRaw);
|
|
371
|
+
return {
|
|
372
|
+
success: true,
|
|
373
|
+
token,
|
|
374
|
+
expiresAt: ts + ttlMs
|
|
375
|
+
};
|
|
376
|
+
} catch {
|
|
377
|
+
return {
|
|
378
|
+
success: false,
|
|
379
|
+
reason: "oneshot_generation_failed"
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function parseOneShotToken(tokenString) {
|
|
384
|
+
let raw;
|
|
385
|
+
try {
|
|
386
|
+
raw = fromBase64Url(tokenString);
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (raw.length !== ONESHOT_RAW_SIZE) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const nonce = raw.slice(ONESHOT_OFFSETS.NONCE, ONESHOT_OFFSETS.NONCE + NONCE_SIZE);
|
|
394
|
+
const timestamp = readUint64BE(raw, ONESHOT_OFFSETS.TIMESTAMP);
|
|
395
|
+
const action = raw.slice(ONESHOT_OFFSETS.ACTION, ONESHOT_OFFSETS.ACTION + ACTION_SIZE);
|
|
396
|
+
const context = raw.slice(ONESHOT_OFFSETS.CONTEXT, ONESHOT_OFFSETS.CONTEXT + CONTEXT_SIZE);
|
|
397
|
+
const mac = raw.slice(ONESHOT_OFFSETS.MAC, ONESHOT_OFFSETS.MAC + MAC_SIZE);
|
|
398
|
+
return { nonce, timestamp, action, context, mac };
|
|
399
|
+
}
|
|
400
|
+
async function validateOneShotToken(cryptoProvider, key, tokenString, expectedAction, nonceCache, expectedContext, ttlMs = DEFAULT_ONESHOT_TTL_MS, now = Date.now()) {
|
|
401
|
+
let valid = true;
|
|
402
|
+
let reason = "unknown";
|
|
403
|
+
const parsed = parseOneShotToken(tokenString);
|
|
404
|
+
const parseOk = parsed !== null;
|
|
405
|
+
valid &&= parseOk;
|
|
406
|
+
if (!parseOk) reason = "parse_failed";
|
|
407
|
+
const ttlResult = parseOk ? validateTTL(parsed.timestamp, ttlMs, 0, now) : { withinTTL: false, inGraceWindow: false };
|
|
408
|
+
const ttlOk = ttlResult.withinTTL;
|
|
409
|
+
valid &&= ttlOk;
|
|
410
|
+
if (!ttlOk) reason = "expired";
|
|
411
|
+
const expectedActionHash = await computeAction(cryptoProvider, expectedAction);
|
|
412
|
+
const actionOk = parseOk ? constantTimeEqual(parsed.action, expectedActionHash) : false;
|
|
413
|
+
valid &&= actionOk;
|
|
414
|
+
if (!actionOk) reason = "action_mismatch";
|
|
415
|
+
const macPayload = parseOk ? assembleOneShotPayload(parsed) : DUMMY_ONESHOT_PAYLOAD;
|
|
416
|
+
const macSignature = parseOk ? toArrayBuffer(parsed.mac) : DUMMY_MAC2;
|
|
417
|
+
const macOk = await cryptoProvider.verify(key.cryptoKey, macSignature, macPayload);
|
|
418
|
+
valid &&= macOk;
|
|
419
|
+
if (!macOk) reason = "invalid_mac";
|
|
420
|
+
let contextOk = true;
|
|
421
|
+
if (expectedContext !== void 0) {
|
|
422
|
+
contextOk = parseOk ? constantTimeEqual(parsed.context, expectedContext) : false;
|
|
423
|
+
}
|
|
424
|
+
valid &&= contextOk;
|
|
425
|
+
if (!contextOk) reason = "context_mismatch";
|
|
426
|
+
let nonceOk = true;
|
|
427
|
+
if (parseOk) {
|
|
428
|
+
const alreadyUsed = nonceCache.has(parsed.nonce);
|
|
429
|
+
if (alreadyUsed) {
|
|
430
|
+
nonceOk = false;
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
nonceOk = false;
|
|
434
|
+
}
|
|
435
|
+
valid &&= nonceOk;
|
|
436
|
+
if (!nonceOk) reason = "nonce_reused";
|
|
437
|
+
if (valid && parseOk) {
|
|
438
|
+
const consumed = nonceCache.markUsed(parsed.nonce);
|
|
439
|
+
if (!consumed) {
|
|
440
|
+
return { valid: false, reason: "nonce_reused" };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return valid ? { valid: true } : { valid: false, reason };
|
|
444
|
+
}
|
|
445
|
+
function assembleOneShotPayload(parsed) {
|
|
446
|
+
const payload = new Uint8Array(ONESHOT_PAYLOAD_SIZE);
|
|
447
|
+
payload.set(parsed.nonce, 0);
|
|
448
|
+
writeUint64BE(payload, parsed.timestamp, NONCE_SIZE);
|
|
449
|
+
payload.set(parsed.action, NONCE_SIZE + TIMESTAMP_SIZE);
|
|
450
|
+
payload.set(parsed.context, NONCE_SIZE + TIMESTAMP_SIZE + ACTION_SIZE);
|
|
451
|
+
return payload;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/nonce-cache.ts
|
|
455
|
+
function nonceToKey(nonce) {
|
|
456
|
+
let key = "";
|
|
457
|
+
for (const byte of nonce) {
|
|
458
|
+
key += (byte >>> 4).toString(16);
|
|
459
|
+
key += (byte & 15).toString(16);
|
|
460
|
+
}
|
|
461
|
+
return key;
|
|
462
|
+
}
|
|
463
|
+
function createNonceCache(config) {
|
|
464
|
+
const maxEntries = config?.maxEntries ?? DEFAULT_NONCE_CACHE_MAX;
|
|
465
|
+
const defaultTTLMs = config?.defaultTTLMs ?? DEFAULT_NONCE_CACHE_TTL_MS;
|
|
466
|
+
const cache = /* @__PURE__ */ new Map();
|
|
467
|
+
function evictExpired() {
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
for (const [key, entry] of cache) {
|
|
470
|
+
if (entry.expiresAt <= now) {
|
|
471
|
+
cache.delete(key);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function evictLRU() {
|
|
476
|
+
if (cache.size >= maxEntries) {
|
|
477
|
+
const firstKey = cache.keys().next().value;
|
|
478
|
+
if (firstKey !== void 0) {
|
|
479
|
+
cache.delete(firstKey);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
has(nonce) {
|
|
485
|
+
const key = nonceToKey(nonce);
|
|
486
|
+
const entry = cache.get(key);
|
|
487
|
+
if (entry === void 0) return false;
|
|
488
|
+
if (entry.expiresAt <= Date.now()) {
|
|
489
|
+
cache.delete(key);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
return true;
|
|
493
|
+
},
|
|
494
|
+
markUsed(nonce) {
|
|
495
|
+
const key = nonceToKey(nonce);
|
|
496
|
+
const entry = cache.get(key);
|
|
497
|
+
if (entry === void 0) {
|
|
498
|
+
evictExpired();
|
|
499
|
+
evictLRU();
|
|
500
|
+
cache.set(key, {
|
|
501
|
+
expiresAt: Date.now() + defaultTTLMs,
|
|
502
|
+
used: true
|
|
503
|
+
});
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (entry.expiresAt <= Date.now()) {
|
|
507
|
+
cache.delete(key);
|
|
508
|
+
evictLRU();
|
|
509
|
+
cache.set(key, {
|
|
510
|
+
expiresAt: Date.now() + defaultTTLMs,
|
|
511
|
+
used: true
|
|
512
|
+
});
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
if (!entry.used) {
|
|
516
|
+
entry.used = true;
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
},
|
|
521
|
+
add(nonce, ttlMs) {
|
|
522
|
+
evictExpired();
|
|
523
|
+
evictLRU();
|
|
524
|
+
const key = nonceToKey(nonce);
|
|
525
|
+
cache.set(key, {
|
|
526
|
+
expiresAt: Date.now() + ttlMs,
|
|
527
|
+
used: false
|
|
528
|
+
});
|
|
529
|
+
},
|
|
530
|
+
get size() {
|
|
531
|
+
return cache.size;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
export {
|
|
536
|
+
ACTION_SIZE,
|
|
537
|
+
CONTEXT_SIZE,
|
|
538
|
+
DEFAULT_GRACE_WINDOW_MS,
|
|
539
|
+
DEFAULT_NONCE_CACHE_MAX,
|
|
540
|
+
DEFAULT_NONCE_CACHE_TTL_MS,
|
|
541
|
+
DEFAULT_ONESHOT_TTL_MS,
|
|
542
|
+
DEFAULT_TOKEN_TTL_MS,
|
|
543
|
+
KID_SIZE,
|
|
544
|
+
MAC_SIZE,
|
|
545
|
+
NONCE_SIZE,
|
|
546
|
+
ONESHOT_OFFSETS,
|
|
547
|
+
ONESHOT_RAW_SIZE,
|
|
548
|
+
TIMESTAMP_SIZE,
|
|
549
|
+
TOKEN_OFFSETS,
|
|
550
|
+
TOKEN_RAW_SIZE,
|
|
551
|
+
WebCryptoCryptoProvider,
|
|
552
|
+
assemblePayload,
|
|
553
|
+
computeAction,
|
|
554
|
+
computeContext,
|
|
555
|
+
constantTimeEqual,
|
|
556
|
+
createKeyring,
|
|
557
|
+
createNonceCache,
|
|
558
|
+
deriveSigningKey,
|
|
559
|
+
emptyContext,
|
|
560
|
+
fromBase64Url,
|
|
561
|
+
generateOneShotToken,
|
|
562
|
+
generateToken,
|
|
563
|
+
getActiveKey,
|
|
564
|
+
parseOneShotToken,
|
|
565
|
+
parseToken,
|
|
566
|
+
resolveKey,
|
|
567
|
+
rotateKey,
|
|
568
|
+
serializeToken,
|
|
569
|
+
toArrayBuffer,
|
|
570
|
+
toBase64Url,
|
|
571
|
+
validateOneShotToken,
|
|
572
|
+
validateTTL,
|
|
573
|
+
validateToken
|
|
574
|
+
};
|
|
575
|
+
//# sourceMappingURL=index.js.map
|