Unverified Commit 151eb864 authored by Hong Minhee's avatar Hong Minhee
Browse files

Verify LD Signatures

parent a86e6806
Loading
Loading
Loading
Loading
+14 −0
Original line number Diff line number Diff line
@@ -8,6 +8,20 @@ Version 1.0.0

To be released.

 -  Fedify now supports [Linked Data Signatures], which is outdated but still
    widely used in the fediverse.

     -  Added `verifySignature()` function.
     -  Added `VerifySignatureOptions` interface.
     -  Added `detachSignature()` function.

 -  Added more log messages using the [LogTape] library.  Currently the below
    logger categories are used:

     -  `["fedify", "sig", "ld"]`

[Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/


Version 0.15.0
--------------
+153 −0
Original line number Diff line number Diff line
@@ -630,6 +630,159 @@ const preloadedContexts: Record<string, unknown> = {
      },
    },
  },

  "https://w3id.org/identity/v1": {
    "@context": {
      "id": "@id",
      "type": "@type",
      "cred": "https://w3id.org/credentials#",
      "dc": "http://purl.org/dc/terms/",
      "identity": "https://w3id.org/identity#",
      "perm": "https://w3id.org/permissions#",
      "ps": "https://w3id.org/payswarm#",
      "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
      "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
      "sec": "https://w3id.org/security#",
      "schema": "http://schema.org/",
      "xsd": "http://www.w3.org/2001/XMLSchema#",
      "Group": "https://www.w3.org/ns/activitystreams#Group",
      "claim": {
        "@id": "cred:claim",
        "@type": "@id",
      },
      "credential": {
        "@id": "cred:credential",
        "@type": "@id",
      },
      "issued": {
        "@id": "cred:issued",
        "@type": "xsd:dateTime",
      },
      "issuer": {
        "@id": "cred:issuer",
        "@type": "@id",
      },
      "recipient": {
        "@id": "cred:recipient",
        "@type": "@id",
      },
      "Credential": "cred:Credential",
      "CryptographicKeyCredential": "cred:CryptographicKeyCredential",
      "about": {
        "@id": "schema:about",
        "@type": "@id",
      },
      "address": {
        "@id": "schema:address",
        "@type": "@id",
      },
      "addressCountry": "schema:addressCountry",
      "addressLocality": "schema:addressLocality",
      "addressRegion": "schema:addressRegion",
      "comment": "rdfs:comment",
      "created": {
        "@id": "dc:created",
        "@type": "xsd:dateTime",
      },
      "creator": {
        "@id": "dc:creator",
        "@type": "@id",
      },
      "description": "schema:description",
      "email": "schema:email",
      "familyName": "schema:familyName",
      "givenName": "schema:givenName",
      "image": {
        "@id": "schema:image",
        "@type": "@id",
      },
      "label": "rdfs:label",
      "name": "schema:name",
      "postalCode": "schema:postalCode",
      "streetAddress": "schema:streetAddress",
      "title": "dc:title",
      "url": {
        "@id": "schema:url",
        "@type": "@id",
      },
      "Person": "schema:Person",
      "PostalAddress": "schema:PostalAddress",
      "Organization": "schema:Organization",
      "identityService": {
        "@id": "identity:identityService",
        "@type": "@id",
      },
      "idp": {
        "@id": "identity:idp",
        "@type": "@id",
      },
      "Identity": "identity:Identity",
      "paymentProcessor": "ps:processor",
      "preferences": {
        "@id": "ps:preferences",
        "@type": "@vocab",
      },
      "cipherAlgorithm": "sec:cipherAlgorithm",
      "cipherData": "sec:cipherData",
      "cipherKey": "sec:cipherKey",
      "digestAlgorithm": "sec:digestAlgorithm",
      "digestValue": "sec:digestValue",
      "domain": "sec:domain",
      "expires": {
        "@id": "sec:expiration",
        "@type": "xsd:dateTime",
      },
      "initializationVector": "sec:initializationVector",
      "member": {
        "@id": "schema:member",
        "@type": "@id",
      },
      "memberOf": {
        "@id": "schema:memberOf",
        "@type": "@id",
      },
      "nonce": "sec:nonce",
      "normalizationAlgorithm": "sec:normalizationAlgorithm",
      "owner": {
        "@id": "sec:owner",
        "@type": "@id",
      },
      "password": "sec:password",
      "privateKey": {
        "@id": "sec:privateKey",
        "@type": "@id",
      },
      "privateKeyPem": "sec:privateKeyPem",
      "publicKey": {
        "@id": "sec:publicKey",
        "@type": "@id",
      },
      "publicKeyPem": "sec:publicKeyPem",
      "publicKeyService": {
        "@id": "sec:publicKeyService",
        "@type": "@id",
      },
      "revoked": {
        "@id": "sec:revoked",
        "@type": "xsd:dateTime",
      },
      "signature": "sec:signature",
      "signatureAlgorithm": "sec:signatureAlgorithm",
      "signatureValue": "sec:signatureValue",
      "CryptographicKey": "sec:Key",
      "EncryptedMessage": "sec:EncryptedMessage",
      "GraphSignature2012": "sec:GraphSignature2012",
      "LinkedDataSignature2015": "sec:LinkedDataSignature2015",
      "accessControl": {
        "@id": "perm:accessControl",
        "@type": "@id",
      },
      "writePermission": {
        "@id": "perm:writePermission",
        "@type": "@id",
      },
    },
  },
};

export default preloadedContexts;
+10 −1
Original line number Diff line number Diff line
@@ -162,7 +162,16 @@ export async function verifyRequest(
    for (let [algo, digestBase64] of digests) {
      algo = algo.trim().toLowerCase();
      if (!(algo in supportedHashAlgorithms)) continue;
      const digest = decodeBase64(digestBase64);
      let digest: Uint8Array;
      try {
        digest = decodeBase64(digestBase64);
      } catch (error) {
        logger.debug("Failed to verify; invalid base64 encoding: {digest}.", {
          digest: digestBase64,
          error,
        });
        return null;
      }
      const expectedDigest = await crypto.subtle.digest(
        supportedHashAlgorithms[algo],
        body,

src/sig/ld.test.ts

0 → 100644
+177 −0
Original line number Diff line number Diff line
import { assert } from "@std/assert/assert";
import { assertEquals } from "@std/assert/assert-equals";
import { encodeBase64 } from "@std/encoding/base64";
import { mockDocumentLoader } from "../testing/docloader.ts";
import { test } from "../testing/mod.ts";
import { CryptographicKey } from "../vocab/vocab.ts";
import { generateCryptoKeyPair } from "./key.ts";
import { detachSignature, verifyJsonLd, verifySignature } from "./ld.ts";

const document = {
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "ostatus": "http://ostatus.org#",
      "atomUri": "ostatus:atomUri",
      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
      "conversation": "ostatus:conversation",
      "sensitive": "as:sensitive",
      "toot": "http://joinmastodon.org/ns#",
      "votersCount": "toot:votersCount",
    },
  ],
  "id":
    "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/activity",
  "type": "Create",
  "actor": "https://activitypub.academy/users/brauca_darradiul",
  "published": "2024-09-12T16:50:45Z",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public",
  ],
  "cc": [
    "https://activitypub.academy/users/brauca_darradiul/followers",
  ],
  "object": {
    "id":
      "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678",
    "type": "Note",
    "summary": null,
    "inReplyTo": null,
    "published": "2024-09-12T16:50:45Z",
    "url": "https://activitypub.academy/@brauca_darradiul/113125611605598678",
    "attributedTo": "https://activitypub.academy/users/brauca_darradiul",
    "to": [
      "https://www.w3.org/ns/activitystreams#Public",
    ],
    "cc": [
      "https://activitypub.academy/users/brauca_darradiul/followers",
    ],
    "sensitive": false,
    "atomUri":
      "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678",
    "inReplyToAtomUri": null,
    "conversation":
      "tag:activitypub.academy,2024-09-12:objectId=187606:objectType=Conversation",
    "content": "<p>Test</p>",
    "contentMap": {
      "en": "<p>Test</p>",
    },
    "attachment": [],
    "tag": [],
    "replies": {
      "id":
        "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies",
      "type": "Collection",
      "first": {
        "type": "CollectionPage",
        "next":
          "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies?only_other_accounts=true&page=true",
        "partOf":
          "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies",
        "items": [],
      },
    },
  },
};

const signature = {
  "type": "RsaSignature2017",
  "creator": "https://activitypub.academy/users/brauca_darradiul#main-key",
  "created": "2024-09-12T16:50:46Z",
  "signatureValue":
    "osp9n4Pubp8XFvBi0iwrpCjDkIpuuUr2klp+r8Jp289ISqRNlUPeHVvNrQSE2vqNm4j/cJGuQruIqZPTAmTjjB3HtqgawoAG11DA7OPpY6mJLruKnbqadV1cy5V0DJI9CRJXEBuEmMTJRO9gi1cyzlM4QxK30YrjmtQNLoU9th97da4lumsl+a5cAue38MDuJZvLWDOTZ1EGixwhLP8FevdnZ+jqwctGu9KrgDImBIpBkQaqHFTTGrbE7FlXsj1pneOUQTuRDa9zlk2DmgXeEBWN2OJZDjgJ4iBsF2JHtCn6PccKbuI9s2VLhnobPtLB8YdHYKqIPLmv0UOjAM8XrQ==",
};

const testVector = { ...document, signature };

test("detachSignature()", () => {
  assertEquals(detachSignature(testVector), document);
  assertEquals(detachSignature(document), document);
});

test("verifySignature()", async () => {
  const doc = { ...testVector };
  const key = await verifySignature(doc, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assertEquals(doc, testVector);
  assertEquals(
    key,
    await CryptographicKey.fromJsonLd({
      "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1",
      ],
      id: "https://activitypub.academy/users/brauca_darradiul#main-key",
      owner: "https://activitypub.academy/users/brauca_darradiul",
      publicKeyPem:
        "-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5W/9rYXddIjKo9Ury/LK XqQYbj0cOx/c+T1uRHJzced8JbvXdiBCNZXVVrIaygy3G/MOvxMW4kbA1bqeiSYY V9TXBMI6gVVDnl5VG64uGxswcvUWqQU5Q1mwuwyGCPhexAq3BKe/7uH64AZgx11e KLl3W3WcIMKmunYn8+z6hm0003hMensXMNpMVfqLoXaeuro7pYnwOSWoHFS3AxWK llMwAoa5waulgai8gD7/uA5Y9Hvguk/OBYBh9YnIX5N5jScsmY/EYuesNIH2Ct9s E3aVkTjZUt55JtXnk8Q9eTnrcB/98RtLWH4pJTKJhzxv19i3aZT3yDApPk0Q/biI JQIDAQAB -----END PUBLIC KEY----- ",
    }),
  );

  // Test invalid signature (wrong base64):
  const doc2 = {
    ...testVector,
    signature: { ...testVector.signature, signatureValue: "!" },
  };
  const key2 = await verifySignature(doc2, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assertEquals(doc2, {
    ...testVector,
    signature: { ...testVector.signature, signatureValue: "!" },
  });
  assertEquals(key2, null);

  // Test incorrect signature:
  const incorrectSig = encodeBase64(new Uint8Array([1, 2, 3, 4]));
  const doc3 = {
    ...testVector,
    signature: { ...testVector.signature, signatureValue: incorrectSig },
  };
  const key3 = await verifySignature(doc3, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assertEquals(doc3, {
    ...testVector,
    signature: { ...testVector.signature, signatureValue: incorrectSig },
  });
  assertEquals(key3, null);

  // Test outdated key cache:
  const doc4 = { ...testVector };
  const key4 = await verifySignature(doc4, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
    keyCache: {
      async get(keyId: URL) {
        return new CryptographicKey({
          id: keyId,
          owner: new URL("https://activitypub.academy/users/brauca_darradiul"),
          publicKey:
            (await generateCryptoKeyPair("RSASSA-PKCS1-v1_5")).publicKey,
        });
      },
      set(_keyId: URL, _key: CryptographicKey) {
        return Promise.resolve();
      },
    },
  });
  assertEquals(doc4, testVector);
  assertEquals(key4, key);
});

test("verifyJsonLd()", async () => {
  const verified = await verifyJsonLd(testVector, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assert(verified);

  // TODO: Test a correctly signed document, but with a different key.
});

// cSpell: ignore ostatus

src/sig/ld.ts

0 → 100644
+216 −0
Original line number Diff line number Diff line
import { getLogger } from "@logtape/logtape";
import { decodeBase64 } from "@std/encoding/base64";
import { encodeHex } from "@std/encoding/hex";
import jsonld from "jsonld";
import {
  type DocumentLoader,
  fetchDocumentLoader,
} from "../runtime/docloader.ts";
import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts";
import { fetchKey, type KeyCache } from "./key.ts";

const logger = getLogger(["fedify", "sig", "ld"]);

interface SignedJsonLd {
  signature: {
    type: "RsaSignature2017";
    creator: string;
    created: string;
    signatureValue: string;
  };
}

function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd {
  if (typeof jsonLd !== "object" || jsonLd == null) return false;
  if ("signature" in jsonLd) {
    const signature = jsonLd.signature;
    if (typeof signature !== "object" || signature == null) return false;
    return "type" in signature && signature.type === "RsaSignature2017" &&
      "creator" in signature && typeof signature.creator === "string" &&
      "created" in signature && typeof signature.created === "string" &&
      "signatureValue" in signature &&
      typeof signature.signatureValue === "string";
  }
  return false;
}

/**
 * Detaches Linked Data Signatures from the given JSON-LD document.
 * @param jsonLd The JSON-LD document to modify.
 * @returns The modified JSON-LD document.  If the input document does not
 *          contain a signature, the original document is returned.
 * @since 1.0.0
 */
export function detachSignature(jsonLd: unknown): unknown {
  if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd;
  const doc: { signature?: unknown } = { ...jsonLd };
  delete doc.signature;
  return doc;
}

/**
 * Options for verifying Linked Data Signatures.
 * @since 1.0.0
 */
export interface VerifySignatureOptions {
  /**
   * The document loader to use for fetching the public key.
   */
  documentLoader?: DocumentLoader;

  /**
   * The context loader to use for JSON-LD context retrieval.
   */
  contextLoader?: DocumentLoader;

  /**
   * The key cache to use for caching public keys.
   */
  keyCache?: KeyCache;
}

/**
 * Verifies Linked Data Signatures of the given JSON-LD document.
 * @param jsonLd The JSON-LD document to verify.
 * @param options Options for verifying the signature.
 * @returns The public key that signed the document or `null` if the signature
 *          is invalid or the key is not found.
 * @since 1.0.0
 */
export async function verifySignature(
  jsonLd: unknown,
  options: VerifySignatureOptions = {},
): Promise<CryptographicKey | null> {
  if (!hasSignature(jsonLd)) return null;
  const sig = jsonLd.signature;
  let signature: Uint8Array;
  try {
    signature = decodeBase64(sig.signatureValue);
  } catch (error) {
    logger.debug(
      "Failed to verify; invalid base64 signatureValue: {signatureValue}",
      { ...sig, error },
    );
    return null;
  }
  const keyResult = await fetchKey(
    new URL(sig.creator),
    CryptographicKey,
    options,
  );
  if (keyResult == null) return null;
  const { key, cached } = keyResult;
  const sigOpts: {
    "@context": string;
    type?: string;
    id?: string;
    signatureValue?: string;
  } = {
    ...sig,
    "@context": "https://w3id.org/identity/v1",
  };
  delete sigOpts.type;
  delete sigOpts.id;
  delete sigOpts.signatureValue;
  const sigOptsHash = await hashJsonLd(sigOpts, options.contextLoader);
  const document: { signature?: unknown } = { ...jsonLd };
  delete document.signature;
  const docHash = await hashJsonLd(document, options.contextLoader);
  const encoder = new TextEncoder();
  const message = sigOptsHash + docHash;
  const messageBytes = encoder.encode(message);
  const verified = await crypto.subtle.verify(
    "RSASSA-PKCS1-v1_5",
    key.publicKey,
    signature,
    messageBytes,
  );
  if (verified) return key;
  if (cached) {
    logger.debug(
      "Failed to verify with the cached key {keyId}; " +
        "signature {signatureValue} is invalid.  " +
        "Retrying with the freshly fetched key...",
      { keyId: sig.creator, ...sig },
    );
    const keyResult = await fetchKey(
      new URL(sig.creator),
      CryptographicKey,
      { ...options, keyCache: undefined },
    );
    if (keyResult == null) return null;
    const { key } = keyResult;
    const verified = await crypto.subtle.verify(
      "RSASSA-PKCS1-v1_5",
      key.publicKey,
      signature,
      messageBytes,
    );
    return verified ? key : null;
  }
  logger.debug(
    "Failed to verify with the fetched key {keyId}; " +
      "signature {signatureValue} is invalid.  " +
      "Check if the key is correct or if the signed message is correct.  " +
      "The message to sign is:\n{message}",
    { keyId: sig.creator, ...sig, message },
  );
  return null;
}

/**
 * Options for verifying JSON-LD documents.
 */
export interface VerifyJsonLdOptions extends VerifySignatureOptions {
}

/**
 * Verify the authenticity of the given JSON-LD document using Linked Data
 * Signatures.  If the document is signed, this function verifies the signature
 * and checks if the document is attributed to the owner of the public key.
 * If the document is not signed, this function returns `false`.
 * @param jsonLd The JSON-LD document to verify.
 * @param options Options for verifying the document.
 * @returns `true` if the document is authentic; `false` otherwise.
 */
export async function verifyJsonLd(
  jsonLd: unknown,
  options: VerifyJsonLdOptions = {},
): Promise<boolean> {
  const object = await Object.fromJsonLd(jsonLd, options);
  const attributions = new Set(object.attributionIds.map((uri) => uri.href));
  if (object instanceof Activity) {
    for (const uri of object.actorIds) attributions.add(uri.href);
  }
  const key = await verifySignature(jsonLd, options);
  if (key == null) return false;
  if (key.ownerId == null) {
    logger.debug("Key {keyId} has no owner.", { keyId: key.id?.href });
    return false;
  }
  attributions.delete(key.ownerId.href);
  if (attributions.size > 0) {
    logger.debug(
      "Some attributions are not authenticated by the Linked Data Signatures" +
        ": {attributions}.",
      { attributions: [...attributions] },
    );
    return false;
  }
  return true;
}

async function hashJsonLd(
  jsonLd: unknown,
  contextLoader: DocumentLoader | undefined,
): Promise<string> {
  const canon = await jsonld.canonize(jsonLd, {
    format: "application/n-quads",
    documentLoader: contextLoader ?? fetchDocumentLoader,
  });
  const encoder = new TextEncoder();
  const hash = await crypto.subtle.digest("SHA-256", encoder.encode(canon));
  return encodeHex(hash);
}

// cSpell: ignore URGNA2012
Loading