Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ To be released.

### @fedify/vocab-runtime

- Added SHA-256 `digestMultibase` and simple `hl:` hashlink helpers for
computing, parsing, creating, and verifying portable media resource
digests as required by [FEP-ef61]. [[#831], [#935]]

- Added the [FEP-ef61] JSON-LD context to the preloaded context registry so
portable actor and media documents can compact and expand `gateways` and
`digestMultibase` without fetching the context remotely. [[#830], [#928]]
Expand Down Expand Up @@ -184,9 +188,11 @@ To be released.
content type, rather than generic JSON parser crashes. [[#912], [#913]]

[#828]: https://github.com/fedify-dev/fedify/issues/828
[#831]: https://github.com/fedify-dev/fedify/issues/831
[#912]: https://github.com/fedify-dev/fedify/issues/912
[#913]: https://github.com/fedify-dev/fedify/pull/913
[#924]: https://github.com/fedify-dev/fedify/pull/924
[#935]: https://github.com/fedify-dev/fedify/pull/935

### @fedify/cli

Expand Down
29 changes: 24 additions & 5 deletions docs/manual/vocab.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,21 +246,40 @@ const actor = new Person({
Each gateway must be an HTTP(S) base URI with no path, query, or fragment.

Links and media/document objects expose `digestMultibase` for the integrity
digest required when portable objects reference external resources:
digest required when portable objects reference external resources. Use
`computeDigestMultibase()` to compute the SHA-256 multihash and
`createHashlink()` to construct a metadata-free `hl:` URI:

~~~~ typescript twoslash
import { Image } from "@fedify/vocab";
import {
computeDigestMultibase,
createHashlink,
verifyDigestMultibase,
verifyHashlink,
} from "@fedify/vocab-runtime";

const bytes = new TextEncoder().encode("image data");
const digestMultibase = await computeDigestMultibase(bytes);
const hashlink = createHashlink(digestMultibase);

const image = new Image({
url: new URL("hl:zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n"),
url: new URL(hashlink),
mediaType: "image/png",
digestMultibase: "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n",
digestMultibase,
});

await verifyDigestMultibase(bytes, digestMultibase); // true
await verifyHashlink(bytes, hashlink); // true
~~~~

The vocabulary layer stores and serializes the `digestMultibase` value exactly
as provided. Computing SHA-256 digests, parsing hashlinks, and verifying media
bytes are handled by separate helper APIs.
as provided. The verification helpers return `false` when the bytes do not
match. `parseDigestMultibase()` and `parseHashlink()` validate values when the
decoded digest or hashlink components are needed. These helpers accept only
SHA-256 digests and simple `hl:` URIs without metadata; malformed values,
unsupported hash algorithms, metadata-bearing hashlinks, and legacy `?hl=`
URLs cause a `TypeError`.

[FEP-ef61]: https://w3id.org/fep/ef61

Expand Down
212 changes: 212 additions & 0 deletions packages/vocab-runtime/src/digest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { deepStrictEqual, equal, rejects, throws } from "node:assert/strict";
import { test } from "node:test";
import { addMulticodecPrefix } from "./internal/multicodec.ts";
import { encodeMultibase } from "./multibase/mod.ts";
import {
computeDigestMultibase,
createHashlink,
parseDigestMultibase,
parseHashlink,
verifyDigestMultibase,
verifyHashlink,
} from "./digest.ts";

const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = encoder.encode("Hello World!");
// Test vector from draft-sporny-hashlink-07, appendix B.1.
const digestMultibase = "zQmWvQxTqbG2Z9HPJgG57jjwR154cKhbtJenbyYTWkjgF3e";
const hashlink = `hl:${digestMultibase}`;

test("computeDigestMultibase() computes a SHA-256 multihash", async () => {
equal(await computeDigestMultibase(bytes), digestMultibase);
const parsed = parseDigestMultibase(digestMultibase);
equal(parsed.algorithm, "sha2-256");
deepStrictEqual(
parsed.digest,
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
);
});

test("digest helpers accept SharedArrayBuffer-backed bytes", async () => {
const sharedBytes = new Uint8Array(new SharedArrayBuffer(bytes.length));
sharedBytes.set(bytes);
Object.defineProperty(sharedBytes, "slice", {
value: () => {
throw new Error("Shared input must not be copied with slice().");
},
});
equal(await computeDigestMultibase(sharedBytes), digestMultibase);
equal(await verifyDigestMultibase(sharedBytes, digestMultibase), true);
});

test("digest helpers ignore spoofed shared buffer tags", async () => {
const taggedBytes = bytes.slice();
Object.defineProperty(taggedBytes.buffer, Symbol.toStringTag, {
value: "SharedArrayBuffer",
});
Object.defineProperty(taggedBytes, "slice", {
value: () => {
throw new Error("ArrayBuffer input must not be copied.");
},
});
equal(await computeDigestMultibase(taggedBytes), digestMultibase);
equal(await verifyDigestMultibase(taggedBytes, digestMultibase), true);
});

test("createHashlink() and parseHashlink() round-trip simple hashlinks", () => {
equal(createHashlink(digestMultibase), hashlink);
deepStrictEqual(parseHashlink(hashlink), { digestMultibase });
deepStrictEqual(parseHashlink(new URL(hashlink)), { digestMultibase });

const base64DigestMultibase = decoder.decode(
encodeMultibase(
"base64",
addMulticodecPrefix(
0x12,
addMulticodecPrefix(32, new Uint8Array(32).fill(0xff)),
),
),
);
const base64Hashlink = createHashlink(base64DigestMultibase);
equal(
base64Hashlink,
"hl:mEiD//////////////////////////////////////////w",
);
deepStrictEqual(parseHashlink(base64Hashlink), {
digestMultibase: base64DigestMultibase,
});
deepStrictEqual(parseHashlink(new URL(base64Hashlink)), {
digestMultibase: base64DigestMultibase,
});
});

test("digest and hashlink verification accepts matching bytes", async () => {
equal(await verifyDigestMultibase(bytes, digestMultibase), true);
equal(await verifyHashlink(bytes, hashlink), true);
equal(await verifyHashlink(bytes, new URL(hashlink)), true);
});

test("digest and hashlink verification rejects non-matching bytes", async () => {
const different = encoder.encode("Hello World?");
equal(await verifyDigestMultibase(different, digestMultibase), false);
equal(await verifyHashlink(different, hashlink), false);
});

test("parseDigestMultibase() rejects unsupported algorithms", () => {
const sha1Multihash = addMulticodecPrefix(
0x11,
addMulticodecPrefix(20, new Uint8Array(20)),
);
const value = decoder.decode(encodeMultibase("base58btc", sha1Multihash));
throws(
() => parseDigestMultibase(value),
new TypeError("Unsupported digest algorithm: 0x11"),
);
});

test("parseDigestMultibase() rejects malformed values", async () => {
throws(
() => parseDigestMultibase("not-multibase"),
new TypeError("Invalid digestMultibase encoding."),
);

const missingLength = decoder.decode(
encodeMultibase("base58btc", Uint8Array.of(0x12)),
);
throws(
() => parseDigestMultibase(missingLength),
new TypeError("Invalid digestMultibase multihash."),
);

const multihash = addMulticodecPrefix(
0x12,
addMulticodecPrefix(32, new Uint8Array(32)),
);
const padded = decoder.decode(encodeMultibase("base64pad", multihash));
equal(padded.endsWith("=="), true);
deepStrictEqual(parseDigestMultibase(padded).digest, new Uint8Array(32));
throws(
() => parseDigestMultibase(padded.slice(0, -1)),
new TypeError("Invalid digestMultibase encoding."),
);
throws(
() => createHashlink(`${padded}=`),
new TypeError("Invalid digestMultibase encoding."),
);

const overlongAlgorithm = decoder.decode(
encodeMultibase(
"base58btc",
Uint8Array.of(0x92, 0x00, 0x20, ...new Uint8Array(32)),
),
);
throws(
() => parseDigestMultibase(overlongAlgorithm),
new TypeError("Invalid digestMultibase multihash."),
);

const overlongLength = decoder.decode(
encodeMultibase(
"base58btc",
Uint8Array.of(0x12, 0xa0, 0x00, ...new Uint8Array(32)),
),
);
throws(
() => parseDigestMultibase(overlongLength),
new TypeError("Invalid digestMultibase multihash."),
);

const shortDigest = decoder.decode(
encodeMultibase(
"base58btc",
addMulticodecPrefix(0x12, addMulticodecPrefix(31, new Uint8Array(31))),
),
);
throws(
() => parseDigestMultibase(shortDigest),
new TypeError("Invalid SHA-256 digest length."),
);
await rejects(
() => verifyDigestMultibase(bytes, shortDigest),
new TypeError("Invalid SHA-256 digest length."),
);
});

test("simple hashlink helpers reject metadata and malformed forms", async () => {
for (const terminator of ["\n", "\r", "\r\n", "\u2028", "\u2029"]) {
const malformedHashlink = `${hashlink}${terminator}`;
throws(
() => parseHashlink(malformedHashlink),
new TypeError("Invalid simple hashlink."),
);
await rejects(
() => verifyHashlink(bytes, malformedHashlink),
new TypeError("Invalid simple hashlink."),
);
}
throws(
() => parseHashlink(`${hashlink}:zmetadata`),
new TypeError("Invalid simple hashlink."),
);
await rejects(
() => verifyHashlink(bytes, `${hashlink}:zmetadata`),
new TypeError("Invalid simple hashlink."),
);
throws(
() => parseHashlink(`https://example.com/file?hl=${digestMultibase}`),
new TypeError("Invalid simple hashlink."),
);
throws(
() => parseHashlink("hl:not-multibase"),
new TypeError("Invalid digestMultibase encoding."),
);
await rejects(
() => verifyHashlink(bytes, "hl:not-multibase"),
new TypeError("Invalid digestMultibase encoding."),
);
throws(
() => createHashlink("not-multibase"),
new TypeError("Invalid digestMultibase encoding."),
);
});
Loading
Loading