websri/src/index.ts

219 lines
6.2 KiB
TypeScript

/** Content Security Policy Level 2, section 4.2 */
export type HashAlgorithm = "sha256" | "sha384" | "sha512";
/** Supported Hash Algorithms */
export const supportedHashAlgorithms = {
sha256: "SHA-256",
sha384: "SHA-384",
sha512: "SHA-512",
} as const satisfies Record<HashAlgorithm, HashAlgorithmIdentifier>;
export type PrioritizedHashAlgorithm = "" | HashAlgorithm;
/** [W3C Subresource Integrity getPrioritizedHashFunction(a, b)](https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b) */
export type GetPrioritizedHashAlgorithm = (
a: HashAlgorithm,
b: HashAlgorithm,
) => PrioritizedHashAlgorithm;
/** [W3C Subresource Integrity getPrioritizedHashFunction(a, b)](https://www.w3.org/TR/SRI/#dfn-getprioritizedhashfunction-a-b) */
export function getPrioritizedHashAlgorithm(
a: HashAlgorithm,
b: HashAlgorithm,
): PrioritizedHashAlgorithm {
if (a === b) return "";
if (!(a in supportedHashAlgorithms)) {
return b in supportedHashAlgorithms ? b : "";
}
if (!(b in supportedHashAlgorithms)) {
return a in supportedHashAlgorithms ? a : "";
}
return a < b ? b : a;
}
export const IntegrityMetadataRegex =
/^(?<alg>sha256|sha384|sha512)-(?<val>(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)(?:[?](?<opt>[\x21-\x7e]*))?$/;
export const SeparatorRegex = /[^\x21-\x7e]+/;
/** Integrity Metadata Like */
export type IntegrityMetadataLike = {
alg: PrioritizedHashAlgorithm;
val: string;
opt?: string[];
};
/** Integrity Metadata */
export class IntegrityMetadata implements IntegrityMetadataLike {
alg: PrioritizedHashAlgorithm;
val: string;
opt: string[];
constructor(integrity: IntegrityMetadataLike | string | null | undefined) {
const integrityString =
typeof integrity === "object" && integrity !== null
? IntegrityMetadata.stringify(integrity)
: String(integrity ?? "").trim();
const {
alg = "",
val = "",
opt,
} = IntegrityMetadataRegex.exec(integrityString)?.groups ?? {};
Object.assign(this, {
alg,
val,
opt: opt?.split("?") ?? [],
});
}
match(integrity: IntegrityMetadataLike | string | null | undefined): boolean {
const { alg, val } = new IntegrityMetadata(integrity);
if (!alg) return false;
if (!val) return false;
if (!(alg in supportedHashAlgorithms)) return false;
return alg === this.alg && val === this.val;
}
toString(): string {
return IntegrityMetadata.stringify(this);
}
toJSON(): string {
return this.toString();
}
static stringify({ alg, val, opt = [] }: IntegrityMetadataLike): string {
if (!alg) return "";
if (!val) return "";
if (!(alg in supportedHashAlgorithms)) return "";
return `${alg}-${[val, ...opt].join("?")}`;
}
}
export async function createIntegrityMetadata(
hashAlgorithm: HashAlgorithm,
data: ArrayBuffer,
opt: string[] = [],
): Promise<IntegrityMetadata> {
const alg = hashAlgorithm.toLowerCase() as HashAlgorithm;
if (!(alg in supportedHashAlgorithms)) {
return new IntegrityMetadata("");
}
const hashAlgorithmIdentifier = supportedHashAlgorithms[alg];
const arrayBuffer = await crypto.subtle.digest(hashAlgorithmIdentifier, data);
const val = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const integrity = IntegrityMetadata.stringify({ alg, val, opt });
return new IntegrityMetadata(integrity);
}
/** Integrity Metadata Set Options */
export type IntegrityMetadataSetOptions = {
getPrioritizedHashAlgorithm?: GetPrioritizedHashAlgorithm;
};
/** Integrity Metadata Set */
export class IntegrityMetadataSet {
/** [W3C Subresource Integrity 3.3.4 Get the strongest metadata from set.](https://www.w3.org/TR/SRI/#get-the-strongest-metadata-from-set) */
readonly strongest: Array<IntegrityMetadata> = [];
#set: ReadonlyArray<IntegrityMetadata>;
constructor(
integrity:
| ReadonlyArray<IntegrityMetadataLike | string | null | undefined>
| IntegrityMetadataLike
| string
| null
| undefined,
{
getPrioritizedHashAlgorithm:
_getPrioritizedHashAlgorithm = getPrioritizedHashAlgorithm,
}: IntegrityMetadataSetOptions = {},
) {
const set: ReadonlyArray<
IntegrityMetadataLike | string | null | undefined
> = [integrity]
.flat()
.flatMap(
(
integrity: IntegrityMetadataLike | string | null | undefined,
): ReadonlyArray<IntegrityMetadataLike | string | null | undefined> =>
typeof integrity === "string"
? integrity.split(SeparatorRegex)
: [integrity],
);
this.#set = set
.map((integrity) => new IntegrityMetadata(integrity))
.filter((integrityMetadata) => integrityMetadata.toString() !== "");
for (const integrityMetadata of this.#set) {
const [strongest = new IntegrityMetadata("")] = this.strongest;
const prioritizedHashAlgorithm = _getPrioritizedHashAlgorithm(
strongest.alg as HashAlgorithm,
integrityMetadata.alg as HashAlgorithm,
);
switch (prioritizedHashAlgorithm) {
case "":
this.strongest.push(integrityMetadata);
break;
case integrityMetadata.alg:
this.strongest = [integrityMetadata];
break;
}
}
}
*[Symbol.iterator](): Generator<IntegrityMetadata> {
for (const integrityMetadata of this.#set) {
yield integrityMetadata;
}
}
get size(): number {
return this.#set.length;
}
get strongestHashAlgorithms(): ReadonlyArray<HashAlgorithm> {
const strongestHashAlgorithms = this.strongest
.map(({ alg }) => alg as HashAlgorithm)
.filter(Boolean);
return [...new Set(strongestHashAlgorithms)];
}
join(separator = " "): string {
return this.#set.map(String).join(separator);
}
toString(): string {
return this.join();
}
toJSON(): string {
return this.toString();
}
}
export async function createIntegrityMetadataSet(
hashAlgorithms: HashAlgorithm[],
data: ArrayBuffer,
options: IntegrityMetadataSetOptions = {
getPrioritizedHashAlgorithm,
},
): Promise<IntegrityMetadataSet> {
const integrityMetadata = await Promise.all(
hashAlgorithms.map((alg) => createIntegrityMetadata(alg, data)),
);
return new IntegrityMetadataSet(integrityMetadata.join(" "), options);
}