621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import {
|
|
chmod,
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
|
import {
|
|
byteDigest,
|
|
canonicalJsonDigest,
|
|
canonicalValueBytes,
|
|
isSha256Hex,
|
|
parseCanonicalJson,
|
|
} from "../../../src/security/canonical-digest.ts";
|
|
import { validatePublicPath } from "../../../src/security/production-input-root.ts";
|
|
import { validateReleaseManifest } from "../../../src/security/release-manifest.ts";
|
|
import { verifySignedReleaseEnvelope as auditedReleaseVerifier } from "../../release/sign-release.mjs";
|
|
|
|
const encoder = new TextEncoder();
|
|
const reservedPrefix = ".release/";
|
|
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
|
|
|
export function exactObject(value, fields, label) {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
throw new Error(`${label} must be an object.`);
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...fields].sort();
|
|
if (
|
|
actual.length !== expected.length ||
|
|
actual.some((field, index) => field !== expected[index])
|
|
) {
|
|
throw new Error(`${label} contains missing or unknown fields.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function assertToken(value, label) {
|
|
if (typeof value !== "string" || !safeToken.test(value))
|
|
throw new Error(`${label} is invalid.`);
|
|
return value;
|
|
}
|
|
|
|
export function assertTimestamp(value, label) {
|
|
if (
|
|
typeof value !== "string" ||
|
|
!timestamp.test(value) ||
|
|
Number.isNaN(Date.parse(value))
|
|
)
|
|
throw new Error(`${label} is invalid.`);
|
|
return value;
|
|
}
|
|
|
|
export function assertDigest(value, label) {
|
|
if (!isSha256Hex(value)) throw new Error(`${label} is invalid.`);
|
|
return value;
|
|
}
|
|
|
|
export async function readCanonicalFile(path, label) {
|
|
const info = await lstat(path);
|
|
if (!info.isFile() || info.isSymbolicLink())
|
|
throw new Error(`${label} must be a regular file.`);
|
|
return parseCanonicalJson(await readFile(path));
|
|
}
|
|
|
|
export function validateProtectedState(value) {
|
|
const state = exactObject(
|
|
value,
|
|
[
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"currentManifestDigest",
|
|
"publisherFence",
|
|
"releaseTrustPolicyDigest",
|
|
],
|
|
"protected state",
|
|
);
|
|
assertDigest(state.latestLedgerHeadHash, "protected ledger head");
|
|
assertDigest(
|
|
state.releaseTrustPolicyDigest,
|
|
"protected release trust policy",
|
|
);
|
|
if (
|
|
!Number.isSafeInteger(state.latestLedgerRevision) ||
|
|
state.latestLedgerRevision < 0 ||
|
|
!Number.isSafeInteger(state.publisherFence) ||
|
|
state.publisherFence < 0 ||
|
|
(state.currentManifestDigest !== null &&
|
|
!isSha256Hex(state.currentManifestDigest))
|
|
)
|
|
throw new Error("Protected state is invalid.");
|
|
return state;
|
|
}
|
|
|
|
export function validateAdmission(value, protectedState, now) {
|
|
const admission = exactObject(
|
|
value,
|
|
[
|
|
"schema",
|
|
"manifestDigest",
|
|
"manifestPayload",
|
|
"expiresAt",
|
|
"predecessorManifestDigest",
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"expectedFence",
|
|
"signatureVerified",
|
|
],
|
|
"verified release admission",
|
|
);
|
|
if (
|
|
admission.schema !== "portfolio-verified-release-admission/v1" ||
|
|
admission.signatureVerified !== true
|
|
) {
|
|
throw new Error("Release signature was not verified.");
|
|
}
|
|
assertDigest(admission.manifestDigest, "manifest digest");
|
|
assertTimestamp(now, "trusted time");
|
|
assertTimestamp(admission.expiresAt, "admission expiry");
|
|
if (Date.parse(now) >= Date.parse(admission.expiresAt))
|
|
throw new Error("Release admission is expired.");
|
|
if (admission.predecessorManifestDigest !== null)
|
|
assertDigest(
|
|
admission.predecessorManifestDigest,
|
|
"predecessor manifest digest",
|
|
);
|
|
const state = validateProtectedState(protectedState);
|
|
if (
|
|
admission.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
|
admission.latestLedgerRevision !== state.latestLedgerRevision
|
|
) {
|
|
throw new Error(
|
|
"Release admission is stale relative to the protected ledger.",
|
|
);
|
|
}
|
|
if (
|
|
admission.predecessorManifestDigest !== state.currentManifestDigest ||
|
|
admission.expectedFence !== state.publisherFence + 1
|
|
) {
|
|
throw new Error("Release admission predecessor or fence is stale.");
|
|
}
|
|
const manifest = validateReleaseManifest(admission.manifestPayload, { now });
|
|
if (
|
|
manifest.expiresAt !== admission.expiresAt ||
|
|
manifest.currentManifestDigest !== admission.predecessorManifestDigest ||
|
|
manifest.predecessorManifestDigest !== admission.predecessorManifestDigest
|
|
)
|
|
throw new Error("Admission does not bind the embedded release manifest.");
|
|
return { admission, manifest };
|
|
}
|
|
|
|
async function verifySignedReleaseAt({
|
|
signedManifestPath,
|
|
trustPolicyPath,
|
|
verifier,
|
|
protectedState,
|
|
now,
|
|
}) {
|
|
const [signedRelease, trustPolicy] = await Promise.all([
|
|
readCanonicalFile(signedManifestPath, "signed release"),
|
|
readCanonicalFile(trustPolicyPath, "trust policy"),
|
|
]);
|
|
const state = validateProtectedState(protectedState);
|
|
if (
|
|
canonicalJsonDigest("portfolio-trust-policy/v1", trustPolicy) !==
|
|
state.releaseTrustPolicyDigest
|
|
) {
|
|
throw new Error(
|
|
"Release trust-policy identity does not match protected deployment authority state.",
|
|
);
|
|
}
|
|
if (typeof verifier !== "function")
|
|
throw new Error("A release signature verifier is required.");
|
|
const result = await verifier({
|
|
signedRelease,
|
|
trustPolicy,
|
|
now,
|
|
protectedState,
|
|
});
|
|
const verified = validateAdmission(result, protectedState, now);
|
|
return {
|
|
...verified,
|
|
signedRelease,
|
|
signedReleaseBytes: canonicalValueBytes(signedRelease),
|
|
};
|
|
}
|
|
|
|
export async function verifySignedRelease(input) {
|
|
exactObject(
|
|
input,
|
|
["signedManifestPath", "authorityRoot", "protectedState"],
|
|
"production release verification request",
|
|
);
|
|
if (
|
|
typeof input.authorityRoot !== "string" ||
|
|
!isAbsolute(input.authorityRoot)
|
|
)
|
|
throw new Error("Protected authority root is invalid.");
|
|
const now = new Date().toISOString();
|
|
const signedRelease = await readCanonicalFile(
|
|
input.signedManifestPath,
|
|
"signed release",
|
|
);
|
|
const trustPolicyPath = join(
|
|
input.authorityRoot,
|
|
".control",
|
|
"release-trust.json",
|
|
);
|
|
const trustPolicy = await readCanonicalFile(
|
|
trustPolicyPath,
|
|
"protected release trust policy",
|
|
);
|
|
const state = validateProtectedState(input.protectedState);
|
|
if (
|
|
canonicalJsonDigest("portfolio-trust-policy/v1", trustPolicy) !==
|
|
state.releaseTrustPolicyDigest
|
|
) {
|
|
throw new Error(
|
|
"Release trust-policy identity does not match protected deployment authority state.",
|
|
);
|
|
}
|
|
const result = auditedReleaseVerifier({
|
|
signedRelease,
|
|
trustPolicy,
|
|
protectedState: input.protectedState,
|
|
});
|
|
const verified = validateAdmission(result, input.protectedState, now);
|
|
return {
|
|
...verified,
|
|
signedRelease,
|
|
signedReleaseBytes: canonicalValueBytes(signedRelease),
|
|
};
|
|
}
|
|
|
|
export async function verifySignedReleaseForTest(input) {
|
|
exactObject(
|
|
input,
|
|
[
|
|
"signedManifestPath",
|
|
"trustPolicyPath",
|
|
"verifySignedReleaseEnvelope",
|
|
"protectedState",
|
|
"now",
|
|
],
|
|
"test-only release verification request",
|
|
);
|
|
return verifySignedReleaseAt({
|
|
signedManifestPath: input.signedManifestPath,
|
|
trustPolicyPath: input.trustPolicyPath,
|
|
verifier: input.verifySignedReleaseEnvelope,
|
|
protectedState: input.protectedState,
|
|
now: input.now,
|
|
});
|
|
}
|
|
|
|
export async function enumerateRegularFiles(root) {
|
|
const rootPath = await realpath(root);
|
|
const output = [];
|
|
async function visit(directory, prefix = "") {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
entries.sort((left, right) =>
|
|
Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)),
|
|
);
|
|
for (const entry of entries) {
|
|
const path = join(directory, entry.name);
|
|
const publicPath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
if (entry.isSymbolicLink())
|
|
throw new Error(
|
|
`Symlink is forbidden in release closure: ${publicPath}`,
|
|
);
|
|
if (entry.isDirectory()) await visit(path, publicPath);
|
|
else if (entry.isFile()) output.push({ path, publicPath });
|
|
else
|
|
throw new Error(
|
|
`Non-regular release input is forbidden: ${publicPath}`,
|
|
);
|
|
}
|
|
}
|
|
await visit(rootPath);
|
|
return output;
|
|
}
|
|
|
|
function splitTarPath(path) {
|
|
const bytes = encoder.encode(path);
|
|
if (bytes.length <= 100) return { name: path, prefix: "" };
|
|
const segments = path.split("/");
|
|
for (let index = segments.length - 1; index > 0; index -= 1) {
|
|
const prefix = segments.slice(0, index).join("/");
|
|
const name = segments.slice(index).join("/");
|
|
if (
|
|
encoder.encode(prefix).length <= 155 &&
|
|
encoder.encode(name).length <= 100
|
|
)
|
|
return { name, prefix };
|
|
}
|
|
throw new Error(`Release path is too long for deterministic ustar: ${path}`);
|
|
}
|
|
|
|
function writeString(target, offset, length, value) {
|
|
const bytes = encoder.encode(value);
|
|
if (bytes.length > length)
|
|
throw new Error("Tar header value exceeds its field.");
|
|
target.set(bytes, offset);
|
|
}
|
|
|
|
function writeOctal(target, offset, length, value) {
|
|
const text = value.toString(8).padStart(length - 1, "0");
|
|
if (text.length !== length - 1)
|
|
throw new Error("Tar numeric field overflow.");
|
|
writeString(target, offset, length, `${text}\0`);
|
|
}
|
|
|
|
function tarHeader(path, size) {
|
|
const header = new Uint8Array(512);
|
|
const { name, prefix } = splitTarPath(path);
|
|
writeString(header, 0, 100, name);
|
|
writeOctal(header, 100, 8, 0o444);
|
|
writeOctal(header, 108, 8, 0);
|
|
writeOctal(header, 116, 8, 0);
|
|
writeOctal(header, 124, 12, size);
|
|
writeOctal(header, 136, 12, 0);
|
|
header.fill(0x20, 148, 156);
|
|
writeString(header, 156, 1, "0");
|
|
writeString(header, 257, 6, "ustar\0");
|
|
writeString(header, 263, 2, "00");
|
|
writeString(header, 345, 155, prefix);
|
|
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
|
const checksumText = checksum.toString(8).padStart(6, "0");
|
|
writeString(header, 148, 8, `${checksumText}\0 `);
|
|
return header;
|
|
}
|
|
|
|
export function createDeterministicTar(files) {
|
|
const ordered = [...files].sort((left, right) =>
|
|
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
|
|
);
|
|
const seen = new Set();
|
|
const chunks = [];
|
|
for (const file of ordered) {
|
|
validatePublicPath(file.path);
|
|
if (seen.has(file.path))
|
|
throw new Error(`Duplicate release archive path: ${file.path}`);
|
|
seen.add(file.path);
|
|
const bytes = new Uint8Array(file.bytes);
|
|
chunks.push(tarHeader(file.path, bytes.length), bytes);
|
|
const remainder = bytes.length % 512;
|
|
if (remainder !== 0) chunks.push(new Uint8Array(512 - remainder));
|
|
}
|
|
chunks.push(new Uint8Array(1024));
|
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
}
|
|
|
|
function readString(bytes, start, length) {
|
|
const end = bytes.indexOf(0, start);
|
|
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
bytes.subarray(
|
|
start,
|
|
end === -1 || end > start + length ? start + length : end,
|
|
),
|
|
);
|
|
}
|
|
|
|
function readOctal(bytes, start, length, label) {
|
|
const text = readString(bytes, start, length).trim();
|
|
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
|
|
const value = Number.parseInt(text, 8);
|
|
if (!Number.isSafeInteger(value) || value < 0)
|
|
throw new Error(`Invalid tar ${label}.`);
|
|
return value;
|
|
}
|
|
|
|
export function parseDeterministicTar(source) {
|
|
const bytes = new Uint8Array(source);
|
|
const files = [];
|
|
const seen = new Set();
|
|
let offset = 0;
|
|
let zeroBlocks = 0;
|
|
while (offset + 512 <= bytes.length) {
|
|
const header = bytes.subarray(offset, offset + 512);
|
|
offset += 512;
|
|
if (header.every((byte) => byte === 0)) {
|
|
zeroBlocks += 1;
|
|
if (zeroBlocks === 2) break;
|
|
continue;
|
|
}
|
|
if (zeroBlocks !== 0) throw new Error("Malformed tar end marker.");
|
|
const expectedChecksum = readOctal(header, 148, 8, "checksum");
|
|
const checksumHeader = new Uint8Array(header);
|
|
checksumHeader.fill(0x20, 148, 156);
|
|
if (
|
|
checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum
|
|
)
|
|
throw new Error("Tar checksum mismatch.");
|
|
if (
|
|
readString(header, 257, 6) !== "ustar" ||
|
|
String.fromCharCode(header[156] ?? 0) !== "0"
|
|
)
|
|
throw new Error("Only deterministic regular ustar entries are accepted.");
|
|
const name = readString(header, 0, 100);
|
|
const prefix = readString(header, 345, 155);
|
|
const path = prefix === "" ? name : `${prefix}/${name}`;
|
|
validatePublicPath(path);
|
|
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
|
|
seen.add(path);
|
|
const size = readOctal(header, 124, 12, "size");
|
|
if (offset + size > bytes.length) throw new Error("Truncated tar entry.");
|
|
files.push({ path, bytes: bytes.slice(offset, offset + size) });
|
|
offset += Math.ceil(size / 512) * 512;
|
|
}
|
|
if (zeroBlocks !== 2 || offset !== bytes.length)
|
|
throw new Error("Tar archive has trailing or missing data.");
|
|
return files;
|
|
}
|
|
|
|
export function packageIndexFor({
|
|
admission,
|
|
manifest,
|
|
signedBytes,
|
|
sbomBytes,
|
|
}) {
|
|
const artifacts = [...manifest.artifacts].sort((left, right) =>
|
|
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
|
|
);
|
|
return {
|
|
schema: "portfolio-release-package/v1",
|
|
buildId: manifest.buildId,
|
|
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
|
|
manifestDigest: admission.manifestDigest,
|
|
predecessorManifestDigest: admission.predecessorManifestDigest,
|
|
expiresAt: admission.expiresAt,
|
|
expectedFence: admission.expectedFence,
|
|
latestLedgerHeadHash: admission.latestLedgerHeadHash,
|
|
latestLedgerRevision: admission.latestLedgerRevision,
|
|
signedManifest: {
|
|
path: ".release/signed-release-manifest.json",
|
|
sha256: byteDigest(signedBytes),
|
|
bytes: signedBytes.length,
|
|
},
|
|
sbom: {
|
|
path: ".release/sbom.cdx.json",
|
|
sha256: byteDigest(sbomBytes),
|
|
bytes: sbomBytes.length,
|
|
},
|
|
artifacts,
|
|
};
|
|
}
|
|
|
|
export function validatePackageFiles(files) {
|
|
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
const indexFile = byPath.get(".release/package-index.json");
|
|
if (indexFile === undefined)
|
|
throw new Error("Release package index is missing.");
|
|
const index = exactObject(
|
|
parseCanonicalJson(indexFile.bytes),
|
|
[
|
|
"schema",
|
|
"buildId",
|
|
"releaseId",
|
|
"manifestDigest",
|
|
"predecessorManifestDigest",
|
|
"expiresAt",
|
|
"expectedFence",
|
|
"latestLedgerHeadHash",
|
|
"latestLedgerRevision",
|
|
"signedManifest",
|
|
"sbom",
|
|
"artifacts",
|
|
],
|
|
"release package index",
|
|
);
|
|
if (index.schema !== "portfolio-release-package/v1")
|
|
throw new Error("Release package schema is invalid.");
|
|
assertToken(index.buildId, "package buildId");
|
|
assertToken(index.releaseId, "package releaseId");
|
|
assertDigest(index.manifestDigest, "package manifest digest");
|
|
assertTimestamp(index.expiresAt, "package expiry");
|
|
if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1)
|
|
throw new Error("Package fence is invalid.");
|
|
const metadata = [index.signedManifest, index.sbom];
|
|
for (const record of metadata) {
|
|
exactObject(record, ["path", "sha256", "bytes"], "package metadata record");
|
|
const file = byPath.get(record.path);
|
|
if (
|
|
file === undefined ||
|
|
byteDigest(file.bytes) !== record.sha256 ||
|
|
file.bytes.length !== record.bytes
|
|
)
|
|
throw new Error("Package metadata hash or size mismatch.");
|
|
}
|
|
if (!Array.isArray(index.artifacts) || index.artifacts.length === 0)
|
|
throw new Error("Package artifact closure is empty.");
|
|
const expectedPaths = new Set([
|
|
".release/package-index.json",
|
|
".release/signed-release-manifest.json",
|
|
".release/sbom.cdx.json",
|
|
]);
|
|
for (const artifact of index.artifacts) {
|
|
exactObject(
|
|
artifact,
|
|
["path", "sha256", "bytes", "encoding"],
|
|
"package artifact",
|
|
);
|
|
if (
|
|
typeof artifact.path !== "string" ||
|
|
artifact.path.startsWith(reservedPrefix)
|
|
)
|
|
throw new Error("Package artifact uses a reserved path.");
|
|
const file = byPath.get(artifact.path);
|
|
if (
|
|
file === undefined ||
|
|
byteDigest(file.bytes) !== artifact.sha256 ||
|
|
file.bytes.length !== artifact.bytes
|
|
)
|
|
throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
|
expectedPaths.add(artifact.path);
|
|
}
|
|
if (
|
|
expectedPaths.size !== byPath.size ||
|
|
[...byPath.keys()].some((path) => !expectedPaths.has(path))
|
|
)
|
|
throw new Error(
|
|
"Release package files do not exactly match the signed closure.",
|
|
);
|
|
return { index, byPath };
|
|
}
|
|
|
|
export async function writeArchiveFiles(files, targetRoot) {
|
|
await mkdir(targetRoot, { recursive: false, mode: 0o700 });
|
|
const root = resolve(targetRoot);
|
|
for (const file of files) {
|
|
const destination = resolve(root, file.path);
|
|
if (
|
|
relative(root, destination).startsWith(`..${sep}`) ||
|
|
destination === root
|
|
)
|
|
throw new Error("Archive entry escapes staging root.");
|
|
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
await writeFile(destination, file.bytes, { flag: "wx", mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
export async function makeImmutable(root, uid, gid) {
|
|
if (
|
|
!Number.isSafeInteger(uid) ||
|
|
uid < 0 ||
|
|
!Number.isSafeInteger(gid) ||
|
|
gid < 0
|
|
)
|
|
throw new Error("Release owner UID/GID is invalid.");
|
|
async function visit(path) {
|
|
const info = await lstat(path);
|
|
if (info.isSymbolicLink())
|
|
throw new Error("Symlink found in immutable release.");
|
|
if (info.uid !== uid || info.gid !== gid)
|
|
throw new Error(`Release ownership mismatch at ${path}.`);
|
|
if (info.isDirectory()) {
|
|
for (const entry of await readdir(path)) await visit(join(path, entry));
|
|
await chmod(path, 0o555);
|
|
} else if (info.isFile()) await chmod(path, 0o444);
|
|
else throw new Error("Immutable release contains a non-file entry.");
|
|
}
|
|
await visit(root);
|
|
}
|
|
|
|
export async function assertReleaseRoot(rootPath) {
|
|
if (typeof rootPath !== "string" || !isAbsolute(rootPath))
|
|
throw new Error("Release root must be an explicit absolute path.");
|
|
const normalized = resolve(rootPath);
|
|
if (normalized === "/" || normalized.split("/").filter(Boolean).length < 2)
|
|
throw new Error("Broad release root is forbidden.");
|
|
const info = await lstat(normalized);
|
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
throw new Error("Release root must be a real dedicated directory.");
|
|
const marker = await readFile(
|
|
join(normalized, ".portfolio-release-root"),
|
|
"utf8",
|
|
).catch((error) => {
|
|
if (error?.code === "ENOENT")
|
|
throw new Error("Release root sentinel is missing or invalid.");
|
|
throw error;
|
|
});
|
|
if (marker !== "portfolio-release-root/v1\n")
|
|
throw new Error("Release root sentinel is missing or invalid.");
|
|
return normalized;
|
|
}
|
|
|
|
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
|
|
const temporary = `${path}.next-${process.pid}`;
|
|
const handle = await open(temporary, "wx", mode);
|
|
try {
|
|
await handle.writeFile(canonicalValueBytes(value));
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
await rename(temporary, path);
|
|
}
|
|
|
|
export async function safeRemoveStage(root, stageName) {
|
|
assertToken(stageName, "stage name");
|
|
const staging = join(root, "staging");
|
|
const target = join(staging, stageName);
|
|
const rel = relative(staging, target);
|
|
if (rel.startsWith("..") || isAbsolute(rel))
|
|
throw new Error("Stage cleanup escaped its root.");
|
|
await rm(target, { recursive: true, force: true });
|
|
}
|
|
|
|
export function digestBytes(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|