Unverified Commit 9fd0564e authored by Hong Minhee's avatar Hong Minhee
Browse files

Create LD Signatures

parent 151eb864
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -11,8 +11,16 @@ To be released.
 -  Fedify now supports [Linked Data Signatures], which is outdated but still
    widely used in the fediverse.

     -  Added `Signature` interface.
     -  Added `signJsonLd()` function.
     -  Added `SignJsonLdOptions` interface.
     -  Added `createSignature()` function.
     -  Added `CreateSignatureOptions` interface.
     -  Added `verifyJsonLd()` function.
     -  Added `VerifyJsonLdOptions` interface.
     -  Added `verifySignature()` function.
     -  Added `VerifySignatureOptions` interface.
     -  Added `attachSignature()` function.
     -  Added `detachSignature()` function.

 -  Added more log messages using the [LogTape] library.  Currently the below
+103 −2
Original line number Diff line number Diff line
import { assert } from "@std/assert/assert";
import { assertEquals } from "@std/assert/assert-equals";
import { assertFalse } from "@std/assert/assert-false";
import { assertRejects } from "@std/assert/assert-rejects";
import { assertThrows } from "@std/assert/assert-throws";
import { encodeBase64 } from "@std/encoding/base64";
import { mockDocumentLoader } from "../testing/docloader.ts";
import {
  ed25519Multikey,
  ed25519PrivateKey,
  rsaPrivateKey2,
  rsaPrivateKey3,
  rsaPublicKey2,
  rsaPublicKey3,
} from "../testing/keys.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";
import {
  attachSignature,
  createSignature,
  detachSignature,
  type Signature,
  signJsonLd,
  verifyJsonLd,
  verifySignature,
} from "./ld.ts";

test("attachSignature()", () => {
  const sig: Signature = {
    "@context": "https://w3id.org/identity/v1",
    type: "RsaSignature2017",
    creator: "https://activitypub.academy/users/brauca_darradiul#main-key",
    created: "2024-09-12T16:50:46Z",
    signatureValue: "asdf",
  };
  const doc = {
    "@context": "https://www.w3.org/ns/activitystreams",
    id: "https://example.com/1",
  };
  assertEquals(attachSignature(doc, sig), {
    ...doc,
    signature: sig,
  });
  assertThrows(() => attachSignature(null, sig), TypeError);
  assertThrows(() => attachSignature(1234, sig), TypeError);
});

test("createSignature()", async () => {
  const doc = {
    "@context": "https://www.w3.org/ns/activitystreams",
    id: "https://example.com/1",
    type: "Create",
  };
  const sig = await createSignature(doc, rsaPrivateKey2, rsaPublicKey2.id!, {
    contextLoader: mockDocumentLoader,
  });
  const key = await verifySignature(attachSignature(doc, sig), {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assertEquals(key, rsaPublicKey2);

  assertRejects(
    () =>
      createSignature(doc, rsaPublicKey2.publicKey!, rsaPublicKey2.id!, {
        contextLoader: mockDocumentLoader,
      }),
    TypeError,
  );
  assertRejects(
    () =>
      createSignature(doc, ed25519PrivateKey, ed25519Multikey.id!, {
        contextLoader: mockDocumentLoader,
      }),
    TypeError,
  );
});

test("signJsonLd()", async () => {
  const doc = {
    "@context": "https://www.w3.org/ns/activitystreams",
    id: "https://example.com/1",
    type: "Create",
    actor: "https://example.com/person2",
  };
  const signed = await signJsonLd(doc, rsaPrivateKey3, rsaPublicKey3.id!, {
    contextLoader: mockDocumentLoader,
  });
  const verified = await verifyJsonLd(signed, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assert(verified);
});

const document = {
  "@context": [
@@ -171,7 +258,21 @@ test("verifyJsonLd()", async () => {
  });
  assert(verified);

  // TODO: Test a correctly signed document, but with a different key.
  const doc = {
    "@context": "https://www.w3.org/ns/activitystreams",
    id: "https://example.com/1",
    type: "Create",
    actor: "https://example.com/person2",
  };
  // rsaPublicKey2 has no owner
  const signed = await signJsonLd(doc, rsaPrivateKey2, rsaPublicKey2.id!, {
    contextLoader: mockDocumentLoader,
  });
  const verified2 = await verifyJsonLd(signed, {
    documentLoader: mockDocumentLoader,
    contextLoader: mockDocumentLoader,
  });
  assertFalse(verified2);
});

// cSpell: ignore ostatus
+127 −8
Original line number Diff line number Diff line
import { getLogger } from "@logtape/logtape";
import { decodeBase64 } from "@std/encoding/base64";
import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
import { encodeHex } from "@std/encoding/hex";
import jsonld from "jsonld";
import {
@@ -7,17 +7,136 @@ import {
  fetchDocumentLoader,
} from "../runtime/docloader.ts";
import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts";
import { fetchKey, type KeyCache } from "./key.ts";
import { fetchKey, type KeyCache, validateCryptoKey } from "./key.ts";

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

interface SignedJsonLd {
  signature: {
/**
 * A signature of a JSON-LD document.
 * @since 1.0.0
 */
export interface Signature {
  "@context"?: "https://w3id.org/identity/v1";
  type: "RsaSignature2017";
  id?: string;
  creator: string;
  created: string;
  signatureValue: string;
}

/**
 * Attaches a LD signature to the given JSON-LD document.
 * @param jsonLd The JSON-LD document to attach the signature to.  It is not
 *               modified.
 * @param signature The signature to attach.
 * @returns The JSON-LD document with the attached signature.
 * @throws {TypeError} If the input document is not a valid JSON-LD document.
 * @since 1.0.0
 */
export function attachSignature(
  jsonLd: unknown,
  signature: Signature,
): { signature: Signature } {
  if (typeof jsonLd !== "object" || jsonLd == null) {
    throw new TypeError(
      "Failed to attach signature; invalid JSON-LD document.",
    );
  }
  return { ...jsonLd, signature };
}

/**
 * Options for creating Linked Data Signatures.
 * @since 1.0.0
 */
export interface CreateSignatureOptions {
  /**
   * The context loader for loading remote JSON-LD contexts.
   */
  contextLoader?: DocumentLoader;

  /**
   * The time when the signature was created.  If not specified, the current
   * time will be used.
   */
  created?: Temporal.Instant;
}

/**
 * Creates a LD signature for the given JSON-LD document.
 * @param jsonLd The JSON-LD document to sign.
 * @param privateKey The private key to sign the document.
 * @param keyId The ID of the public key that corresponds to the private key.
 * @param options Additional options for creating the signature.
 *                See also {@link CreateSignatureOptions}.
 * @return The created signature.
 * @throws {TypeError} If the private key is invalid or unsupported.
 * @since 1.0.0
 */
export async function createSignature(
  jsonLd: unknown,
  privateKey: CryptoKey,
  keyId: URL,
  { contextLoader, created }: CreateSignatureOptions = {},
): Promise<Signature> {
  validateCryptoKey(privateKey, "private");
  if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
    throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
  }
  const options = {
    "@context": "https://w3id.org/identity/v1" as const,
    creator: keyId.href,
    created: created?.toString() ?? new Date().toISOString(),
  };
  const optionsHash = await hashJsonLd(options, contextLoader);
  const docHash = await hashJsonLd(jsonLd, contextLoader);
  const message = optionsHash + docHash;
  const encoder = new TextEncoder();
  const messageBytes = encoder.encode(message);
  const signature = await crypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    privateKey,
    messageBytes,
  );
  return {
    ...options,
    type: "RsaSignature2017",
    signatureValue: encodeBase64(signature),
  };
}

/**
 * Options for signing JSON-LD documents.
 * @since 1.0.0
 */
export interface SignJsonLdOptions extends CreateSignatureOptions {
}

/**
 * Signs the given JSON-LD document with the private key and returns the signed
 * JSON-LD document.
 * @param jsonLd The JSON-LD document to sign.
 * @param privateKey The private key to sign the document.
 * @param keyId The key ID to use in the signature.  It will be used by the
 *              verifier to fetch the corresponding public key.
 * @param options Additional options for signing the document.
 *                See also {@link SignJsonLdOptions}.
 * @returns The signed JSON-LD document.
 * @throws {TypeError} If the private key is invalid or unsupported.
 * @since 1.0.0
 */
export async function signJsonLd(
  jsonLd: unknown,
  privateKey: CryptoKey,
  keyId: URL,
  options: SignJsonLdOptions,
): Promise<{ signature: Signature }> {
  const signature = await createSignature(jsonLd, privateKey, keyId, options);
  return attachSignature(jsonLd, signature);
}

interface SignedJsonLd {
  signature: Signature;
}

function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd {