feat(auth): keep issuer state in Postgres

The issuer kept everything behind one get/set/remove/scan interface, which
is what a library that must run on any provider's cache can offer. Three of
the things kept there could not actually be served by it.

An authorization code must be redeemable once and a refresh token spendable
once, and through get and set the check and the write are separate steps —
so two requests arriving together both read an unspent record, and both mint
a session. In the refresh case that also means the reuse which reveals a
stolen token is never recorded, because recording it is the write that the
second caller overwrites. Each now has a table and an interface of its own:
redeeming is one `delete ... returning`, spending is one
`update ... where time_used is null returning *`, so exactly one caller is
ever told it went first. This is the same argument the device grant already
made, applied to the two records that had it too.

Signing keys move for a different reason. Nothing races for them; they are
the one record whose loss ends every session at once, and a cache is a place
things may be evicted from. They are retired by setting a column rather than
deleted, so the tokens they signed stay verifiable until they expire.

Both credential tables store a hash and never the credential, as the device
grant does. An authorization code travels in a query string and so passes
through history, referrer headers and any log along the redirect; a refresh
token resumes a session outright.

What is left in the generic store is the rate-limit counters — written far
more often than read, meaningless within the hour, and allowed to be
approximate, since a lost increment costs one guess out of ten. Those move
to Postgres too, so the only key-value binding this deploys with is gone and
the control plane's state is one database. That was the point: nothing here
now depends on a primitive a self-hoster cannot run.

The generic scan also gained the separator on its prefix, so scanning `a`
cannot return what is under `ab` — subjects and email addresses are both
prefixes of longer subjects and email addresses.

Deploying this signs everyone out. The signing keys and refresh tokens are
in a store that is being left behind, so the issuer starts with a fresh key
set and every existing token stops verifying.
This commit is contained in:
Wanjohi
2026-09-05 13:56:39 +03:00
parent 349305d0cc
commit f25c9af545
26 changed files with 4241 additions and 185 deletions

View File

@@ -0,0 +1,75 @@
/**
* Where an authorization code lives between the redirect and the exchange.
*
* A code is handed to a browser in a URL and presented back within a minute,
* and it must be redeemable exactly once. That last part is the whole reason
* this is an interface: taking the record away and reading it have to be the
* same operation, because a get, a decision and a remove lets two exchanges
* arriving together both be served — and each of them mints a full session.
*
* @packageDocumentation
*/
import type { StorageAdapter } from './storage/storage.js';
import { Storage } from './storage/storage.js';
import { sha256hex } from './util.js';
/** What the code stands for, recorded when it is issued. */
export interface AuthorizationCodeRecord {
type: string;
properties: any;
subject: string;
clientID: string;
redirectURI: string;
ttl: { access: number; refresh: number };
pkce?: { challenge: string; method: 'S256' };
}
export interface CodeStore {
create(codeHash: string, record: AuthorizationCodeRecord, ttl: number): Promise<void>;
/**
* Take the record away and return it, or return null.
*
* Removal and reading are one operation on purpose. Two exchanges of the
* same code must not both be answered, and a caller cannot arrange that by
* reading first — so it is not offered a way to.
*/
consume(codeHash: string): Promise<AuthorizationCodeRecord | null>;
}
/**
* The hash a code is stored under.
*
* An authorization code is a bearer credential that travels in a query string,
* which means it lands in browser history, in referrer headers and in whatever
* logs the redirect passed through. What is kept here is enough to recognise
* one and not enough to present it.
*/
export function hashAuthorizationCode(code: string): Promise<string> {
return sha256hex(code);
}
/**
* A code store backed by the generic {@link StorageAdapter}.
*
* The default, and the behaviour every deployment had before `codeStore`
* existed — including its weakness: `get` and `remove` are two operations, so
* this cannot actually promise single use. It is kept because a store that
* only does get and set cannot do better, and an issuer that wants the promise
* passes one that can.
*/
export function StorageCodeStore(storage: StorageAdapter): CodeStore {
return {
async create(codeHash, record, ttl) {
await Storage.set(storage, ['oauth:code', codeHash], record, ttl);
},
async consume(codeHash) {
const key = ['oauth:code', codeHash];
const record = await Storage.get<AuthorizationCodeRecord>(storage, key);
if (!record) return null;
await Storage.remove(storage, key);
return record;
}
};
}

View File

@@ -13,6 +13,8 @@
* @packageDocumentation
*/
import { sha256hex } from './util.js';
/** How far a grant has got. Terminal in both directions once it leaves pending. */
export type DeviceGrantStatus = 'pending' | 'approved' | 'denied';
@@ -93,8 +95,7 @@ export interface DeviceStore {
* not enough to present it.
*/
export async function hashDeviceCode(deviceCode: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(deviceCode));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
return sha256hex(deviceCode);
}
/**

View File

@@ -205,7 +205,20 @@ import {
UnauthorizedClientError,
UnknownStateError
} from './error.js';
import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js';
import { encryptionKeys, signingKeys } from './keys.js';
import { type KeyStore, StorageKeyStore } from './key.js';
import {
type AuthorizationCodeRecord,
type CodeStore,
hashAuthorizationCode,
StorageCodeStore
} from './authorization-code.js';
import {
hashRefreshToken,
type RefreshRecord,
type RefreshStore,
StorageRefreshStore
} from './refresh.js';
import {
type DeviceGrant,
type DeviceGrantSubject,
@@ -397,6 +410,33 @@ export interface IssuerInput<
* store can make each transition a single operation.
*/
deviceStore?: DeviceStore;
/**
* Where the issuer's signing and encryption keys are kept.
*
* Defaults to the generic {@link storage} adapter, under the prefixes it
* has always used, so an issuer that does not set this keeps the keys it
* already had. Setting it moves the one piece of state here whose loss
* invalidates every session at once into somewhere a deployment controls.
*/
keyStore?: KeyStore;
/**
* Where authorization codes are kept between the redirect and the exchange.
*
* Defaults to the generic {@link storage} adapter, which cannot promise a
* code is redeemable only once — it reads and removes in two steps, so two
* exchanges arriving together are both answered, and each mints a session.
* A store that can delete and return in one operation closes that.
*/
codeStore?: CodeStore;
/**
* Where refresh tokens are kept.
*
* Defaults to the generic {@link storage} adapter, with the same weakness:
* reuse detection depends on recording when a token was first spent, and
* through get and set that record happens after the check rather than as
* part of it, so two refreshes arriving together both look like the first.
*/
refreshStore?: RefreshStore;
/**
* How hard a caller may guess at user codes before `/device` stops
* answering them.
@@ -599,10 +639,12 @@ export function issuer<
throw new Error(
'Store is not configured. Either set the `storage` option or set `OPENAUTH_STORAGE` environment variable.'
);
const allSigning = lazy(() =>
Promise.all([signingKeys(storage), legacySigningKeys(storage)]).then(([a, b]) => [...a, ...b])
);
const allEncryption = lazy(() => encryptionKeys(storage));
const keyStore = input.keyStore ?? StorageKeyStore(storage);
const codeStore = input.codeStore ?? StorageCodeStore(storage);
const refreshStore = input.refreshStore ?? StorageRefreshStore(storage);
const allSigning = lazy(() => signingKeys(keyStore));
const allEncryption = lazy(() => encryptionKeys(keyStore));
const signingKey = lazy(() => allSigning().then((all) => all[0]));
const encryptionKey = lazy(() => allEncryption().then((all) => all[0]));
@@ -684,9 +726,8 @@ export function issuer<
}
if (authorization.response_type === 'code') {
const code = crypto.randomUUID();
await Storage.set(
storage,
['oauth:code', code],
await codeStore.create(
await hashAuthorizationCode(code),
{
type,
properties,
@@ -762,11 +803,7 @@ export function issuer<
deleteCookie(ctx, key);
},
async invalidate(subject: string) {
// Resolve the scan in case modifications interfere with iteration
const keys = await Array.fromAsync(Storage.scan(this.storage, ['oauth:refresh', subject]));
for (const [key] of keys) {
await Storage.remove(this.storage, key);
}
await refreshStore.removeSubject(subject);
},
storage
};
@@ -939,14 +976,14 @@ export function issuer<
* Similar treatment should be given to any other values that may have race conditions,
* for example if a jti claim was added to the access token.
*/
const refreshValue = {
const refreshValue: RefreshRecord = {
...value,
nextToken: crypto.randomUUID()
};
delete refreshValue.timeUsed;
await Storage.set(
storage!,
['oauth:refresh', value.subject, refreshToken],
await refreshStore.create(
value.subject,
await hashRefreshToken(refreshToken),
refreshValue,
value.ttl.refresh
);
@@ -1077,19 +1114,17 @@ export function issuer<
},
400
);
const key = ['oauth:code', code.toString()];
const payload = await Storage.get<{
type: string;
properties: any;
clientID: string;
redirectURI: string;
subject: string;
ttl: {
access: number;
refresh: number;
};
pkce?: AuthorizationState['pkce'];
}>(storage, key);
// Taken away before anything is checked, and deliberately not
// after. A code is redeemable once, so the operation that
// decides which caller gets it has to be the one that removes
// it — checking first and removing at the end lets two
// exchanges of the same code both pass every check. It also
// means a code that fails a check below is spent rather than
// left to be tried again, which is what RFC 6749 §4.1.2 asks
// for.
const payload: AuthorizationCodeRecord | null = await codeStore.consume(
await hashAuthorizationCode(code.toString())
);
if (!payload) {
return c.json(
{
@@ -1140,7 +1175,6 @@ export function issuer<
}
}
const tokens = await generateTokens(c, payload);
await Storage.remove(storage, key);
return c.json({
access_token: tokens.access,
expires_in: tokens.expiresIn,
@@ -1161,20 +1195,19 @@ export function issuer<
const splits = refreshToken.toString().split(':');
const token = splits.pop()!;
const subject = splits.join(':');
const key = ['oauth:refresh', subject, token];
const payload = await Storage.get<{
type: string;
properties: any;
clientID: string;
subject: string;
ttl: {
access: number;
refresh: number;
};
nextToken: string;
timeUsed?: number;
}>(storage, key);
if (!payload) {
const at = Date.now();
// Spending the token and finding out whether it had already
// been spent are one operation. Split into a read and a write
// they are the race that reuse detection exists to catch: two
// refreshes arriving together both read an unspent token, both
// mint a session, and neither is ever reported.
const claim = await refreshStore.claim(
subject,
await hashRefreshToken(token),
at,
ttlRefreshReuse <= 0 ? 0 : ttlRefreshReuse + ttlRefreshRetention
);
if (claim.status === 'missing') {
return c.json(
{
error: 'invalid_grant',
@@ -1183,15 +1216,12 @@ export function issuer<
400
);
}
const generateRefreshToken = !payload.timeUsed;
if (ttlRefreshReuse <= 0) {
// no reuse interval, remove the refresh token immediately
await Storage.remove(storage, key);
} else if (!payload.timeUsed) {
payload.timeUsed = Date.now();
await Storage.set(storage, key, payload, ttlRefreshReuse + ttlRefreshRetention);
} else if (Date.now() > payload.timeUsed + ttlRefreshReuse * 1000) {
// token was reused past the allowed interval
// Reuse inside the window is tolerated so that a client that
// fired two refreshes at once gets the same answer twice
// instead of losing its session. Past it, the only explanation
// left is that someone else has the token, so every session the
// subject has goes.
if (claim.status === 'reused' && at > claim.timeUsed + ttlRefreshReuse * 1000) {
await auth.invalidate(subject);
return c.json(
{
@@ -1201,8 +1231,16 @@ export function issuer<
400
);
}
// The access token is dated from when the refresh token was
// first spent, not from now — so the second answer inside the
// reuse window is the same session, and not a quietly extended
// one.
const payload: RefreshRecord = {
...claim.record,
timeUsed: claim.status === 'fresh' ? at : claim.timeUsed
};
const tokens = await generateTokens(c, payload, {
generateRefreshToken
generateRefreshToken: claim.status === 'fresh'
});
return c.json({
access_token: tokens.access,

73
packages/auth/src/key.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Where the issuer's signing and encryption keys live.
*
* These are the only records here that are meant to outlive everything else:
* every token this issuer has ever minted is verifiable only for as long as
* the public half is still published, so losing this store invalidates every
* session at once. That is the whole reason it is an interface — a store that
* a deployment can point at its own database, rather than at whatever
* key-value service the runtime happened to offer.
*
* Writes are append-only and rare: a key is created when no unexpired one of
* its kind exists, and retired by being marked expired rather than removed, so
* tokens it signed stay verifiable until they age out on their own.
*
* @packageDocumentation
*/
import type { StorageAdapter } from './storage/storage.js';
import { Storage } from './storage/storage.js';
/** Which half of the issuer's key material a record belongs to. */
export type KeyKind = 'signing' | 'encryption';
/**
* A key pair as stored: PEM text rather than a live key object.
*
* Kept serialized because the store is a database and not a process — the
* import back into a usable key happens in {@link ./keys.js}, once per issuer
* instance.
*/
export interface StoredKey {
id: string;
publicKey: string;
privateKey: string;
alg: string;
/** Epoch ms. */
created: number;
/** Epoch ms, set when the key is retired. Absent while it is still in use. */
expired?: number;
}
export interface KeyStore {
/** Every key of a kind, expired ones included. Order does not matter. */
list(kind: KeyKind): Promise<StoredKey[]>;
create(kind: KeyKind, key: StoredKey): Promise<void>;
}
/** The storage prefix a kind's keys have always been written under. */
function prefix(kind: KeyKind): string {
return kind === 'signing' ? 'signing:key' : 'encryption:key';
}
/**
* A key store backed by the generic {@link StorageAdapter}.
*
* The default, and what every deployment used before `keyStore` existed — the
* keys are read and written under exactly the prefixes they always were, so an
* issuer that does not pass a store keeps finding the keys it already had.
*/
export function StorageKeyStore(storage: StorageAdapter): KeyStore {
return {
async list(kind) {
const results: StoredKey[] = [];
for await (const [, value] of Storage.scan<StoredKey>(storage, [prefix(kind)])) {
results.push(value);
}
return results;
},
async create(kind, key) {
await Storage.set(storage, [prefix(kind), key.id], key);
}
};
}

View File

@@ -9,19 +9,12 @@ import {
KeyLike
} from 'jose';
import { Storage, StorageAdapter } from './storage/storage.js';
import type { KeyKind, KeyStore, StoredKey } from './key.js';
const signingAlg = 'ES256';
const encryptionAlg = 'RSA-OAEP-512';
interface SerializedKeyPair {
id: string;
publicKey: string;
privateKey: string;
created: number;
alg: string;
expired?: number;
}
const alg: Record<KeyKind, string> = {
signing: 'ES256',
encryption: 'RSA-OAEP-512'
};
export interface KeyPair {
id: string;
@@ -33,104 +26,58 @@ export interface KeyPair {
jwk: JWK;
}
async function toKeyPair(kind: KeyKind, stored: StoredKey): Promise<KeyPair> {
// The algorithm is read off the record rather than assumed, because a key
// outlives the decision that produced it: rotating to a new algorithm has
// to leave the old keys verifiable until the tokens they signed expire.
const publicKey = await importSPKI(stored.publicKey, stored.alg, { extractable: true });
const privateKey = await importPKCS8(stored.privateKey, stored.alg);
const jwk = await exportJWK(publicKey);
jwk.kid = stored.id;
if (kind === 'signing') jwk.use = 'sig';
return {
id: stored.id,
alg: alg[kind],
created: new Date(stored.created),
expired: stored.expired ? new Date(stored.expired) : undefined,
public: publicKey,
private: privateKey,
jwk
};
}
/**
* @deprecated use `signingKeys` instead
* Every key of a kind, newest first, creating one if none is usable.
*
* Expired keys are returned alongside live ones and sorted after them: the
* first entry is what signs, and the rest are what still verifies. Retiring a
* key therefore does not invalidate the tokens it signed — they age out on
* their own — which is the only way a rotation is not also a mass sign-out.
*/
export async function legacySigningKeys(storage: StorageAdapter): Promise<KeyPair[]> {
const alg = 'RS512';
const results = [] as KeyPair[];
const scanner = Storage.scan<SerializedKeyPair>(storage, ['oauth:key']);
for await (const [_key, value] of scanner) {
const publicKey = await importSPKI(value.publicKey, alg, {
extractable: true
});
const privateKey = await importPKCS8(value.privateKey, alg);
const jwk = await exportJWK(publicKey);
jwk.kid = value.id;
results.push({
id: value.id,
alg,
created: new Date(value.created),
public: publicKey,
private: privateKey,
expired: new Date(1735858114000),
jwk
});
}
return results;
}
export async function signingKeys(storage: StorageAdapter): Promise<KeyPair[]> {
const results = [] as KeyPair[];
const scanner = Storage.scan<SerializedKeyPair>(storage, ['signing:key']);
for await (const [_key, value] of scanner) {
const publicKey = await importSPKI(value.publicKey, value.alg, {
extractable: true
});
const privateKey = await importPKCS8(value.privateKey, value.alg);
const jwk = await exportJWK(publicKey);
jwk.kid = value.id;
jwk.use = 'sig';
results.push({
id: value.id,
alg: signingAlg,
created: new Date(value.created),
expired: value.expired ? new Date(value.expired) : undefined,
public: publicKey,
private: privateKey,
jwk
});
}
async function keysOf(store: KeyStore, kind: KeyKind): Promise<KeyPair[]> {
const stored = await store.list(kind);
const results = await Promise.all(stored.map((k) => toKeyPair(kind, k)));
results.sort((a, b) => b.created.getTime() - a.created.getTime());
if (results.filter((item) => !item.expired).length) return results;
if (results.some((item) => !item.expired)) return results;
const key = await generateKeyPair(signingAlg, {
extractable: true
});
const serialized: SerializedKeyPair = {
const key = await generateKeyPair(alg[kind], { extractable: true });
const created: StoredKey = {
id: crypto.randomUUID(),
publicKey: await exportSPKI(key.publicKey),
privateKey: await exportPKCS8(key.privateKey),
created: Date.now(),
alg: signingAlg
alg: alg[kind]
};
await Storage.set(storage, ['signing:key', serialized.id], serialized);
return signingKeys(storage);
await store.create(kind, created);
// Read back rather than returning what was just built, so that two issuers
// starting at once converge on whichever key the store actually kept.
return keysOf(store, kind);
}
export async function encryptionKeys(storage: StorageAdapter): Promise<KeyPair[]> {
const results = [] as KeyPair[];
const scanner = Storage.scan<SerializedKeyPair>(storage, ['encryption:key']);
for await (const [_key, value] of scanner) {
const publicKey = await importSPKI(value.publicKey, value.alg, {
extractable: true
});
const privateKey = await importPKCS8(value.privateKey, value.alg);
const jwk = await exportJWK(publicKey);
jwk.kid = value.id;
results.push({
id: value.id,
alg: encryptionAlg,
created: new Date(value.created),
expired: value.expired ? new Date(value.expired) : undefined,
public: publicKey,
private: privateKey,
jwk
});
}
results.sort((a, b) => b.created.getTime() - a.created.getTime());
if (results.filter((item) => !item.expired).length) return results;
const key = await generateKeyPair(encryptionAlg, {
extractable: true
});
const serialized: SerializedKeyPair = {
id: crypto.randomUUID(),
publicKey: await exportSPKI(key.publicKey),
privateKey: await exportPKCS8(key.privateKey),
created: Date.now(),
alg: encryptionAlg
};
await Storage.set(storage, ['encryption:key', serialized.id], serialized);
return encryptionKeys(storage);
export function signingKeys(store: KeyStore): Promise<KeyPair[]> {
return keysOf(store, 'signing');
}
export function encryptionKeys(store: KeyStore): Promise<KeyPair[]> {
return keysOf(store, 'encryption');
}

View File

@@ -0,0 +1,138 @@
/**
* Where refresh tokens live, and how one is spent.
*
* A refresh token is the longest-lived credential this issuer hands out, and
* the only one whose record is written once and read months later. Two things
* follow, and both are why this is an interface rather than a pair of get and
* set calls.
*
* The first is that spending a token has to happen exactly once. Reuse
* detection works by remembering *when* a token was first spent, so the moment
* that is recorded must be the same operation as the check that it had not
* been recorded already. Read it, compare, write it back, and two refreshes
* arriving together both look like the first one — which is precisely the case
* reuse detection exists to catch.
*
* The second is that a token is a bearer credential, so the store is asked for
* a hash and never the token itself. See {@link hashRefreshToken}.
*
* @packageDocumentation
*/
import type { StorageAdapter } from './storage/storage.js';
import { Storage } from './storage/storage.js';
import { sha256hex } from './util.js';
/** What a refresh token stands for. */
export interface RefreshRecord {
type: string;
properties: any;
subject: string;
clientID: string;
ttl: { access: number; refresh: number };
/**
* The token that replaces this one, chosen when this one was issued.
*
* Reserved in advance so that two refreshes inside the reuse window are
* answered with the same token rather than racing to mint different ones.
*
* Note what this means for a store that leaks: the successor is readable
* before it is issued. It is not usable until the holder actually refreshes
* — nothing is stored under it before then — but from that moment the
* successor is known. That is inherited from the token scheme rather than
* from where it is kept, and it is the reason the record's *own* token is
* still only ever stored as a hash.
*/
nextToken?: string;
/** Epoch ms the token was first spent. Absent until it has been. */
timeUsed?: number;
}
/** What spending a token turned out to be. */
export type RefreshClaim =
| { status: 'missing' }
/** It had not been spent before. This caller is the one that spent it. */
| { status: 'fresh'; record: RefreshRecord }
/** It had been spent already, at `timeUsed`. Whether that is allowed is the caller's arithmetic. */
| { status: 'reused'; record: RefreshRecord; timeUsed: number };
export interface RefreshStore {
create(
subject: string,
tokenHash: string,
record: RefreshRecord,
ttl: number
): Promise<void>;
/**
* Spend a token, in one operation.
*
* `retainFor` is how many seconds a spent record should be kept so that
* reuse can be recognised. Zero means reuse is not tolerated at all, and
* the record is taken away instead of marked — so `fresh` is still the only
* answer any one caller can get, and every later attempt reads `missing`.
*
* The caller must not decide any of this by reading first.
*/
claim(
subject: string,
tokenHash: string,
at: number,
retainFor: number
): Promise<RefreshClaim>;
/** Every token belonging to a subject, for when reuse is detected. */
removeSubject(subject: string): Promise<void>;
}
/**
* The hash a refresh token is stored under.
*
* The token is what its holder presents to be issued a session, so anything
* that can read the store could otherwise resume every session in it. What is
* kept is enough to recognise a token and not enough to present one.
*/
export function hashRefreshToken(token: string): Promise<string> {
return sha256hex(token);
}
/**
* A refresh store backed by the generic {@link StorageAdapter}.
*
* The default, and what every deployment had before `refreshStore` existed.
* `claim` here is a get followed by a set, which is the race described at the
* top of this file — unavoidable through an interface that offers only whole
* records, and the reason an issuer that cares passes a store that can do it
* in one statement.
*/
export function StorageRefreshStore(storage: StorageAdapter): RefreshStore {
const key = (subject: string, tokenHash: string) => ['oauth:refresh', subject, tokenHash];
return {
async create(subject, tokenHash, record, ttl) {
await Storage.set(storage, key(subject, tokenHash), record, ttl);
},
async claim(subject, tokenHash, at, retainFor) {
const k = key(subject, tokenHash);
const record = await Storage.get<RefreshRecord>(storage, k);
if (!record) return { status: 'missing' };
if (record.timeUsed) return { status: 'reused', record, timeUsed: record.timeUsed };
if (retainFor <= 0) {
await Storage.remove(storage, k);
} else {
await Storage.set(storage, k, { ...record, timeUsed: at }, retainFor);
}
return { status: 'fresh', record };
},
async removeSubject(subject) {
// Resolved before removing, in case modifying the store while
// iterating it interferes with the scan.
const keys = await Array.fromAsync(Storage.scan(storage, ['oauth:refresh', subject]));
for (const [k] of keys) {
await Storage.remove(storage, k);
}
}
};
}

View File

@@ -54,3 +54,15 @@ export function lazy<T>(fn: () => T): () => T {
return value;
};
}
/**
* The SHA-256 of a string, hex encoded.
*
* Used wherever a bearer credential has to be recognised later without being
* kept in a form that could be presented. Whoever can read the store learns
* that a token existed and not what it was.
*/
export async function sha256hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}