create subresourceintegrity

This commit is contained in:
Nebel 2024-09-03 03:14:59 +09:00
parent 87a3f47c4e
commit a7c7e41d6e
Signed by: nebel
GPG key ID: 79807D08C6EF6460
12 changed files with 6216 additions and 0 deletions

35
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: release
run-name: "${{ inputs.version }}"
on:
workflow_dispatch:
inputs:
version:
description: 'Increment "major", "minor", "patch", or "pre*" version; or specify version'
default: patch
required: true
jobs:
main:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npm test
- name: Release
run: |
git config user.email "${{ github.actor }}@users.noreply.github.com"
git config user.name "${{ github.actor }}"
npm run release -- "${{ github.event.inputs.version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

21
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: test
on: push
jobs:
nodejs:
name: Node.js v${{ matrix.node-version }}
strategy:
matrix:
node-version:
- 18
- 20
- 22
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist
node_modules

15
.release-it.json Normal file
View file

@ -0,0 +1,15 @@
{
"$schema": "https://unpkg.com/release-it@17/schema/release-it.json",
"git": {
"tagName": "v${version}"
},
"github": {
"release": true
},
"plugins": {
"@release-it/keep-a-changelog": {
"addUnreleased": true,
"addVersionUrl": true
}
}
}

8
CHANGELOG.md Normal file
View file

@ -0,0 +1,8 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# subresourceintegrity
[![NPM Version](https://img.shields.io/npm/v/subresourceintegrity)](https://www.npmjs.com/package/subresourceintegrity) [![jsDocs.io](https://img.shields.io/badge/jsDocs.io-reference-blue)](https://www.jsdocs.io/package/subresourceintegrity)
`subresourceintegrity` is a utility designed for Subresource Integrity that works across various web-interoperable runtimes, including Node.js, browsers, Cloudflare Workers, Deno, Bun, and others.
## Usage
Install package:
```sh
# npm
npm install subresourceintegrity
# yarn
yarn add subresourceintegrity
# pnpm
pnpm install subresourceintegrity
# bun
bun install subresourceintegrity
```
[Integrity Metadata](https://www.w3.org/TR/SRI/#integrity-metadata):
```ts
import { createIntegrityMetadata } from "subresourceintegrity";
const res = new Response("Hello, world!");
const data = await res.arrayBuffer();
const integrityMetadata = await createIntegrityMetadata("sha256", data);
console.log(integrityMetadata.toString());
// sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=
```
## Documentation
[API Reference](https://www.jsdocs.io/package/subresourceintegrity)
## FAQ
### What is Subresource Integrity?
[Subresource Integrity (SRI)](https://www.w3.org/TR/SRI/) is a security feature that allows user agents to verify that a fetched resource has not been manipulated unexpectedly.
## License
`subresourceintegrity` is released under the MIT License.

5827
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "subresourceintegrity",
"version": "0.0.0",
"description": "Subresource Integrity",
"license": "MIT",
"author": "Kohei Watanabe <nebel@fogtype.com>",
"repository": {
"type": "git",
"url": "https://github.com/kou029w/subresourceintegrity.git"
},
"type": "module",
"exports": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"prepublishOnly": "tsup",
"test": "tsup && node --test",
"release": "release-it --"
},
"devDependencies": {
"@release-it/keep-a-changelog": "5.0.0",
"@types/node": "22.5.2",
"release-it": "17.6.0",
"tsup": "8.2.4",
"typescript": "5.5.4"
}
}

4
renovate.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:best-practices", ":automergeAll"]
}

167
src/index.ts Normal file
View file

@ -0,0 +1,167 @@
/** Content Security Policy Level 2, section 4.2 */
export type HashAlgorithm = "sha256" | "sha384" | "sha512";
export const supportedHashAlgorithm: ReadonlyArray<HashAlgorithm> = [
"sha512",
"sha384",
"sha256",
];
export const supportedHashAlgorithmName = {
sha256: "SHA-256",
sha384: "SHA-384",
sha512: "SHA-512",
} satisfies Record<HashAlgorithm, string>;
export type PrioritizedHash = "" | HashAlgorithm;
export function getPrioritizedHash(
a: HashAlgorithm,
b: HashAlgorithm,
): PrioritizedHash {
if (a === b) return "";
if (!supportedHashAlgorithm.includes(a)) return "";
if (!supportedHashAlgorithm.includes(b)) return "";
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}=)?)(?:[?](?<opts>[\x21-\x7e]*))?$/;
export const SeparatorRegex = /[^\x21-\x7e]+/;
/** Integrity Metadata */
export class IntegrityMetadata {
alg: PrioritizedHash;
val: string;
opt: string[];
constructor(integrity: string) {
const {
alg = "",
val = "",
opt,
} = IntegrityMetadataRegex.exec(integrity)?.groups ?? {};
Object.assign(this, {
alg,
val,
opt: opt?.split("?") ?? [],
});
}
match(integrity: { alg: PrioritizedHash; val: string }): boolean {
return integrity.alg === this.alg && integrity.val === this.val;
}
toString(): string {
return IntegrityMetadata.stringify(this);
}
toJSON(): string {
return this.toString();
}
static stringify(integrity: {
alg: PrioritizedHash;
val: string;
opt: string[];
}): string {
if (!integrity.alg) return "";
if (!integrity.val) return "";
if (!supportedHashAlgorithm.includes(integrity.alg)) return "";
return `${integrity.alg}-${[integrity.val, ...integrity.opt].join("?")}`;
}
}
export async function createIntegrityMetadata(
hashAlgorithm: HashAlgorithm,
data: ArrayBuffer,
opt: string[] = [],
): Promise<string> {
const alg = hashAlgorithm.toLowerCase() as HashAlgorithm;
if (!supportedHashAlgorithm.includes(alg)) return "";
const arrayBuffer = await crypto.subtle.digest(
supportedHashAlgorithmName[alg.toLowerCase()],
data,
);
const val = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
return IntegrityMetadata.stringify({
alg,
val,
opt,
});
}
/** Subresource Integrity */
export class SubresourceIntegrity extends Map<
HashAlgorithm,
IntegrityMetadata
> {
getPrioritizedHash: (a: HashAlgorithm, b: HashAlgorithm) => PrioritizedHash;
constructor(integrity: string, options = { getPrioritizedHash }) {
super();
const integrityMetadata = integrity.split(SeparatorRegex);
for (const integrity of integrityMetadata.filter(Boolean)) {
const integrityMetadata = new IntegrityMetadata(integrity);
if (integrityMetadata.alg) {
this.set(integrityMetadata.alg, integrityMetadata);
}
}
this.getPrioritizedHash = options.getPrioritizedHash;
}
get strongest(): IntegrityMetadata {
const [hashAlgorithm = supportedHashAlgorithm[0]]: HashAlgorithm[] = [
...this.keys(),
].sort((a, b) => {
switch (this.getPrioritizedHash(a, b)) {
default:
case "":
return 0;
case a:
return -1;
case b:
return +1;
}
});
return this.get(hashAlgorithm) ?? new IntegrityMetadata("");
}
match(integrity: { alg: PrioritizedHash; val: string }): boolean {
return this.strongest.match(integrity);
}
join(separator = " ") {
return [...this.values()].map(String).join(separator);
}
toString(): string {
return this.join();
}
toJSON(): string {
return this.toString();
}
}
export async function createSubresourceIntegrity(
hashAlgorithms: HashAlgorithm[],
data: ArrayBuffer,
): Promise<SubresourceIntegrity> {
const integrityMetadata = await Promise.all(
hashAlgorithms.map((alg) => createIntegrityMetadata(alg, data)),
);
return new SubresourceIntegrity(integrityMetadata.join(" "));
}

39
test/index.js Normal file
View file

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
createIntegrityMetadata,
createSubresourceIntegrity,
SubresourceIntegrity,
} from "../dist/index.js";
test("createIntegrityMetadata()", async function () {
const res = new Response("Hello, world!");
const data = await res.arrayBuffer();
const integrityMetadata = await createIntegrityMetadata("sha256", data);
assert.strictEqual(
integrityMetadata.toString(),
"sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
);
});
test("createSubresourceIntegrity()", async function () {
const res = new Response("Hello, world!");
const data = await res.arrayBuffer();
const sri = await createSubresourceIntegrity(["sha384", "sha512"], data);
assert.strictEqual(
sri.toString(),
"sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==",
);
});
test("SubresourceIntegrity.strongest", async function () {
const { strongest } = new SubresourceIntegrity(`
sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=
sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r
sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==
`);
assert.strictEqual(strongest.alg, "sha512");
});

10
tsup.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig(() => {
return {
clean: true,
dts: true,
entry: ["src"],
format: ["cjs", "esm"],
};
});