Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ To be released.

### @fedify/vocab

- Added [FEP-ef61] vocabulary terms for portable ActivityPub objects.
Actor classes now expose ordered `gateways` lists, and `Link` plus
document/media classes expose `digestMultibase` for external resource
integrity metadata. [[#830], [#928]]

- Updated [FEP-fe34] cross-origin checks to understand cryptographic origins
for [FEP-ef61] portable ActivityPub IDs and DID URLs. Generated property
accessors and `lookupObject()` now treat `ap:`/`ap+ef61:` IDs and matching
Expand All @@ -113,11 +118,17 @@ To be released.
[FEP-7aa9]: https://w3id.org/fep/7aa9
[#810]: https://github.com/fedify-dev/fedify/issues/810
[#826]: https://github.com/fedify-dev/fedify/issues/826
[#830]: https://github.com/fedify-dev/fedify/issues/830
[#850]: https://github.com/fedify-dev/fedify/pull/850
[#914]: https://github.com/fedify-dev/fedify/pull/914
[#928]: https://github.com/fedify-dev/fedify/pull/928

### @fedify/vocab-runtime

- 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]]

- Added `getFe34Origin()` and `haveSameFe34Origin()` for comparing ordinary
web origins and [FEP-ef61] cryptographic origins with one shared
[FEP-fe34] helper. HTTP(S) URLs keep web-origin semantics, while
Expand Down
80 changes: 63 additions & 17 deletions docs/manual/vocab.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,25 +108,12 @@ const create = new Create({
Note that every URI is represented as a [`URL`] object. This is for
distinguishing the URIs from the other strings.

Fedify also accepts [FEP-ef61] portable ActivityPub IRIs in JSON-LD input.
Both the `ap:` and `ap+ef61:` schemes are accepted, whether the DID authority
is decoded (e.g., `ap+ef61://did:key:.../actor`) or percent-encoded. Fedify
stores these IRIs as `URL` objects with a URL-safe authority internally, and
serializes them as canonical `ap+ef61:` IRIs with the decoded DID authority.
When comparing portable object IDs, use `canonicalizePortableUri()` or
`arePortableUrisEqual()` from `@fedify/vocab-runtime`; these helpers remove
query hints such as `gateways` according to [FEP-ef61]. Pass raw URI strings
to these comparison helpers, because JavaScript `URL` objects normalize opaque
path segments before Fedify can compare them. Serialization keeps those query
hints intact.

> [!TIP]
> You can instantiate an object from a JSON-LD document by calling the
> `fromJsonLd()` method of the object. See the [*JSON-LD* section](#json-ld)
> for details.

[`URL`]: https://developer.mozilla.org/en-US/docs/Web/API/URL
[FEP-ef61]: https://w3id.org/fep/ef61


Properties
Expand Down Expand Up @@ -217,6 +204,67 @@ property.
[FEP-044f]: https://w3id.org/fep/044f


FEP-ef61 portable objects
-------------------------

*This section is applicable since Fedify 2.4.0.*

Fedify accepts [FEP-ef61] portable ActivityPub IRIs in JSON-LD input. Both
the `ap:` and `ap+ef61:` schemes are accepted. DID delimiters in the authority
can be percent-encoded for URL-safe input, as in
`ap+ef61://did%3Akey%3A.../actor`. Fedify stores these IRIs as `URL` objects
with a URL-safe authority internally, and serializes them as canonical
`ap+ef61:` IRIs with the decoded DID authority.

When comparing portable object IDs, use `canonicalizePortableUri()` or
`arePortableUrisEqual()` from `@fedify/vocab-runtime`; these helpers remove
query hints such as `gateways` according to FEP-ef61. Pass raw URI strings to
these comparison helpers, because JavaScript `URL` objects normalize opaque
path segments before Fedify can compare them. Serialization keeps those query
hints intact.

Portable IDs also participate in Fedify's origin-based security model. An
`ap:` or `ap+ef61:` URI is owned by the DID in its authority component, and a
DID URL such as `did:key:z...#z...` is owned by its DID component. See the
[*Origin-based security model* section](#origin-based-security-model) for how
this affects property access.

Actor classes expose the FEP-ef61 `gateways` term as an ordered list:

~~~~ typescript twoslash
import { Person } from "@fedify/vocab";

const actor = new Person({
id: new URL("ap+ef61://did%3Akey%3Az6Mkabc/actor"),
gateways: [
new URL("https://server1.example/"),
new URL("https://server2.example/"),
],
});
~~~~

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:

~~~~ typescript twoslash
import { Image } from "@fedify/vocab";

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

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.

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


Object IDs and remote objects
-----------------------------

Expand Down Expand Up @@ -602,10 +650,8 @@ boundaries, preventing malicious actors from impersonating content from other
servers.

For ordinary HTTP(S) object IDs, the origin is the web origin: scheme, host,
and port. For [FEP-ef61] portable IDs, Fedify uses the cryptographic origin
defined by the portable identifier. An `ap:` or `ap+ef61:` URI is owned by the
DID in its authority component, and a DID URL such as `did:key:z...#z...` is
owned by its DID component.
and port. For FEP-ef61 portable IDs, Fedify uses the cryptographic origin
defined by the portable identifier.

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

Expand Down
2 changes: 2 additions & 0 deletions packages/fedify/src/federation/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ test("handleActor()", async () => {
assertEquals(await response.json(), {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/fep/ef61",
"https://w3id.org/security/v1",
"https://w3id.org/security/data-integrity/v1",
"https://www.w3.org/ns/did/v1",
Expand Down Expand Up @@ -278,6 +279,7 @@ test("handleActor()", async () => {
assertEquals(await response.json(), {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/fep/ef61",
"https://w3id.org/security/v1",
"https://w3id.org/security/data-integrity/v1",
"https://www.w3.org/ns/did/v1",
Expand Down
10 changes: 10 additions & 0 deletions packages/fixture/src/fixtures/w3id.org/fep/ef61.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"@context": {
"gateways": {
"@id": "https://w3id.org/fep/ef61/gateways",
"@type": "http://www.w3.org/2001/XMLSchema#anyURI",
Comment thread
dahlia marked this conversation as resolved.
Outdated
"@container": "@list"
},
"digestMultibase": "https://www.w3.org/ns/credentials/v2#digestMultibase"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"@context": {
"id": "@id",
"type": "@type",
"@protected": true,
"proof": {
"@id": "https://w3id.org/security#proof",
"@type": "@id",
"@container": "@graph"
},
"DataIntegrityProof": {
"@id": "https://w3id.org/security#DataIntegrityProof",
"@context": {
"@protected": true,
"id": "@id",
"type": "@type",
"challenge": "https://w3id.org/security#challenge",
"created": {
"@id": "http://purl.org/dc/terms/created",
"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
},
"domain": "https://w3id.org/security#domain",
"expires": {
"@id": "https://w3id.org/security#expiration",
"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
},
"nonce": "https://w3id.org/security#nonce",
"previousProof": {
"@id": "https://w3id.org/security#previousProof",
"@type": "@id"
},
"proofPurpose": {
"@id": "https://w3id.org/security#proofPurpose",
"@type": "@vocab",
"@context": {
"@protected": true,
"id": "@id",
"type": "@type",
"assertionMethod": {
"@id": "https://w3id.org/security#assertionMethod",
"@type": "@id",
"@container": "@set"
},
"authentication": {
"@id": "https://w3id.org/security#authenticationMethod",
"@type": "@id",
"@container": "@set"
},
"capabilityInvocation": {
"@id": "https://w3id.org/security#capabilityInvocationMethod",
"@type": "@id",
"@container": "@set"
},
"capabilityDelegation": {
"@id": "https://w3id.org/security#capabilityDelegationMethod",
"@type": "@id",
"@container": "@set"
},
"keyAgreement": {
"@id": "https://w3id.org/security#keyAgreementMethod",
"@type": "@id",
"@container": "@set"
}
}
},
"cryptosuite": {
"@id": "https://w3id.org/security#cryptosuite",
"@type": "https://w3id.org/security#cryptosuiteString"
},
"proofValue": {
"@id": "https://w3id.org/security#proofValue",
"@type": "https://w3id.org/security#multibase"
},
"verificationMethod": {
"@id": "https://w3id.org/security#verificationMethod",
"@type": "@id"
}
}
}
}
}
2 changes: 2 additions & 0 deletions packages/vocab-runtime/src/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import activitystreams from "./contexts/activitystreams.json" with {
import didV1 from "./contexts/did-v1.json" with { type: "json" };
import fep5711 from "./contexts/fep-5711.json" with { type: "json" };
import fep7aa9 from "./contexts/fep-7aa9.json" with { type: "json" };
import fepEf61 from "./contexts/fep-ef61.json" with { type: "json" };
Comment thread
dahlia marked this conversation as resolved.
import gotosocial from "./contexts/gotosocial.json" with { type: "json" };
import identityV1 from "./contexts/identity-v1.json" with { type: "json" };
import joinLemmyContext from "./contexts/join-lemmy.json" with { type: "json" };
Expand Down Expand Up @@ -37,6 +38,7 @@ const preloadedContexts: Record<string, unknown> = {
"https://gotosocial.org/ns": gotosocial,
"https://w3id.org/fep/5711": fep5711,
"https://w3id.org/fep/7aa9": fep7aa9,
"https://w3id.org/fep/ef61": fepEf61,
Comment thread
dahlia marked this conversation as resolved.

// Lemmy's context document is served as application/json without the JSON-LD
// context Link header. The default document loader treats that as a regular
Expand Down
10 changes: 10 additions & 0 deletions packages/vocab-runtime/src/contexts/fep-ef61.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"@context": {
"gateways": {
"@id": "https://w3id.org/fep/ef61/gateways",
"@type": "http://www.w3.org/2001/XMLSchema#anyURI",
Comment thread
dahlia marked this conversation as resolved.
Outdated
"@container": "@list"
},
"digestMultibase": "https://www.w3.org/ns/credentials/v2#digestMultibase"
Comment thread
dahlia marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
"id": "@id",
"type": "@type",
"@protected": true,
"digestMultibase": {
"@id": "https://w3id.org/security#digestMultibase",
"@type": "https://w3id.org/security#multibase"
},
"proof": {
"@id": "https://w3id.org/security#proof",
"@type": "@id",
Expand Down
2 changes: 2 additions & 0 deletions packages/vocab-runtime/src/docloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,10 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
* - <https://www.w3.org/ns/activitystreams>
* - <https://w3id.org/security/v1>
* - <https://w3id.org/security/data-integrity/v1>
* - <https://w3id.org/security/data-integrity/v2>
* - <https://www.w3.org/ns/did/v1>
* - <https://w3id.org/security/multikey/v1>
* - <https://w3id.org/fep/ef61>
* - <https://purl.archive.org/socialweb/webfinger>
* - <http://schema.org/>
* @param options Options for the document loader.
Expand Down
2 changes: 2 additions & 0 deletions packages/vocab-runtime/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ export {
getFe34Origin,
haveSameFe34Origin,
haveSameIriOrigin,
isGatewayUrl,
isValidPublicIPv4Address,
isValidPublicIPv6Address,
parseGatewayUrl,
parseIri,
parseJsonLdId,
UrlError,
Expand Down
21 changes: 21 additions & 0 deletions packages/vocab-runtime/src/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
getFe34Origin,
haveSameFe34Origin,
haveSameIriOrigin,
isGatewayUrl,
isValidPublicIPv4Address,
isValidPublicIPv6Address,
parseGatewayUrl,
parseIri,
parseJsonLdId,
UrlError,
Expand Down Expand Up @@ -638,6 +640,25 @@ test("parseIri() preserves encoded percent signs while decoding delimiters", ()
);
});

test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
for (const url of ["https://server.example/", "http://server.example/"]) {
deepStrictEqual(parseGatewayUrl(url), new URL(url));
ok(isGatewayUrl(new URL(url)));
}

for (
const url of [
"ftp://server.example/",
"https://server.example/path",
"https://server.example/?x=1",
"https://server.example/#fragment",
]
) {
throws(() => parseGatewayUrl(url), TypeError);
ok(!isGatewayUrl(new URL(url)));
}
Comment thread
dahlia marked this conversation as resolved.
});

test("validatePublicUrl()", async () => {
await rejects(() => validatePublicUrl("ftp://localhost"), UrlError);
await rejects(
Expand Down
22 changes: 22 additions & 0 deletions packages/vocab-runtime/src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,28 @@ function parseAtUri(uri: string): URL {
return new URL("at://" + encodeURIComponent(authority) + path);
}

/**
* Checks whether the URL is an FEP-ef61 gateway base URI.
*/
export function isGatewayUrl(url: URL): boolean {
return (url.protocol === "http:" || url.protocol === "https:") &&
url.pathname === "/" && url.search === "" && url.hash === "";
}
Comment thread
dahlia marked this conversation as resolved.

/**
* Parses and validates an FEP-ef61 gateway base URI.
*/
export function parseGatewayUrl(url: string): URL {
const parsed = parseIri(url);
if (!isGatewayUrl(parsed)) {
throw new TypeError(
"FEP-ef61 gateways must be HTTP(S) base URIs with no path, " +
"query, or fragment.",
);
}
return parsed;
}

/**
* Validates a URL to prevent SSRF attacks.
*/
Expand Down
Loading