From 79fbd17ff84a73a4cb0e6f35346c101594ee900e Mon Sep 17 00:00:00 2001
From: Shibo Lyu <github@of.sb>
Date: Thu, 31 Oct 2024 01:42:38 +0800
Subject: [PATCH] refactor: extract BlahActKey to streamline BlahIdentity

---
 crypto/keypair.ts         |   4 +
 identity/actKey.ts        | 156 +++++++++++++++++++++++++++++++++++
 identity/identity.test.ts |   8 +-
 identity/identity.ts      | 166 +++++++++-----------------------------
 4 files changed, 203 insertions(+), 131 deletions(-)

diff --git a/crypto/keypair.ts b/crypto/keypair.ts
index e2e076f..2f176e3 100644
--- a/crypto/keypair.ts
+++ b/crypto/keypair.ts
@@ -42,6 +42,10 @@ export class BlahKeyPair {
     this.internalPrivateKey = privateKey;
   }
 
+  async verifyPayload<P>(signedPayload: BlahSignedPayload<P>): Promise<P> {
+    return await this.internalPublicKey.verifyPayload(signedPayload);
+  }
+
   static async generate(extractable: boolean = true): Promise<BlahKeyPair> {
     const { publicKey, privateKey } = await crypto.subtle.generateKey(
       "Ed25519",
diff --git a/identity/actKey.ts b/identity/actKey.ts
index 9314b85..bdad622 100644
--- a/identity/actKey.ts
+++ b/identity/actKey.ts
@@ -1,4 +1,7 @@
 import z from "zod";
+import { BlahPublicKey } from "../crypto/publicKey.ts";
+import { BlahKeyPair } from "../crypto/keypair.ts";
+import type { BlahSignedPayload } from "../crypto/signedPayload.ts";
 
 export const blahActKeyRecordSchema = z.object({
   typ: z.literal("user_act_key"),
@@ -13,3 +16,156 @@ export type BlahActKeyRecord = {
   expire_time: number;
   comment: string;
 };
+
+export type ActKeyUpdate = {
+  expiresAt?: Date;
+  comment?: string;
+};
+
+export class BlahActKey {
+  private internalKey: BlahPublicKey | BlahKeyPair;
+  private internalExpiresAt: Date;
+  private internalComment: string;
+  private internalIdKeyPublic: BlahPublicKey;
+  private internalSigValid: boolean;
+  private internalSignedRecord: BlahSignedPayload<BlahActKeyRecord>;
+
+  private constructor(
+    key: BlahPublicKey | BlahKeyPair,
+    idKeyPublic: BlahPublicKey,
+    fullConfig: Required<ActKeyUpdate>,
+    sigValid: boolean,
+    signedRecord: BlahSignedPayload<BlahActKeyRecord>,
+  ) {
+    this.internalKey = key;
+    this.internalIdKeyPublic = idKeyPublic;
+    this.internalExpiresAt = fullConfig.expiresAt;
+    this.internalComment = fullConfig.comment;
+    this.internalSigValid = sigValid;
+    this.internalSignedRecord = signedRecord;
+  }
+
+  static async fromSignedRecord(
+    raw: BlahSignedPayload<BlahActKeyRecord>,
+    idKeyPublic: BlahPublicKey,
+    keypair?: BlahKeyPair,
+  ): Promise<BlahActKey> {
+    let record: BlahActKeyRecord;
+    let sigValid = false;
+    try {
+      record = await idKeyPublic.verifyPayload(raw);
+      sigValid = true;
+    } catch {
+      record = raw.signee.payload;
+      sigValid = false;
+    }
+
+    const key: BlahPublicKey | BlahKeyPair = keypair ??
+      await BlahPublicKey.fromID(record.act_key);
+    const fullConfig: Required<ActKeyUpdate> = {
+      expiresAt: new Date(record.expire_time * 1000),
+      comment: record.comment,
+    };
+
+    return new BlahActKey(key, idKeyPublic, fullConfig, sigValid, raw);
+  }
+
+  static async create(
+    key: BlahPublicKey | BlahKeyPair,
+    idKeyPair: BlahKeyPair,
+    config?: ActKeyUpdate,
+  ): Promise<BlahActKey> {
+    const fullConfig: Required<ActKeyUpdate> = {
+      expiresAt: new Date(Date.now() + 365 * 24 * 3600 * 1000),
+      comment: "",
+      ...config,
+    };
+    const record: BlahActKeyRecord = {
+      typ: "user_act_key",
+      act_key: key.id,
+      expire_time: Math.floor(fullConfig.expiresAt.getTime() / 1000),
+      comment: fullConfig.comment,
+    };
+
+    const signedRecord = await idKeyPair.signPayload(record);
+
+    return new BlahActKey(
+      key,
+      idKeyPair.publicKey,
+      fullConfig,
+      true,
+      signedRecord,
+    );
+  }
+
+  get isExpired(): boolean {
+    return new Date() > this.internalExpiresAt;
+  }
+
+  get isSigValid(): boolean {
+    return this.internalSigValid;
+  }
+
+  get comment(): string {
+    return this.internalComment;
+  }
+
+  get canSign(): boolean {
+    return this.internalKey instanceof BlahKeyPair;
+  }
+
+  get publicKey(): BlahPublicKey {
+    if (this.internalKey instanceof BlahKeyPair) {
+      return this.internalKey.publicKey;
+    } else {
+      return this.internalKey;
+    }
+  }
+
+  async signPayload<P>(
+    payload: P,
+    date: Date = new Date(),
+  ): Promise<BlahSignedPayload<P>> {
+    if (!this.canSign) throw new Error("Cannot sign without a private key");
+
+    return await (this.internalKey as BlahKeyPair).signPayload(
+      payload,
+      date,
+      this.internalIdKeyPublic.id,
+    );
+  }
+
+  async verifyPayload<P>(
+    payload: BlahSignedPayload<P>,
+  ): Promise<P> {
+    if (payload.signee.id_key !== this.internalIdKeyPublic.id) {
+      throw new Error("Payload signed with a different ID key");
+    }
+    if (new Date(payload.signee.timestamp * 1000) > this.internalExpiresAt) {
+      throw new Error("Key was expired at the time of signing");
+    }
+    return await this.internalKey.verifyPayload(payload);
+  }
+
+  async update(update: ActKeyUpdate, idKeyPair: BlahKeyPair): Promise<void> {
+    if (update.expiresAt) this.internalExpiresAt = update.expiresAt;
+    if (update.comment) this.internalComment = update.comment;
+
+    this.internalSignedRecord = await idKeyPair.signPayload({
+      typ: "user_act_key",
+      act_key: this.internalKey.id,
+      expire_time: Math.floor(this.internalExpiresAt.getTime() / 1000),
+      comment: this.internalComment,
+    });
+    this.internalSigValid = true;
+  }
+
+  toSignedRecord(): BlahSignedPayload<BlahActKeyRecord> {
+    return this.internalSignedRecord;
+  }
+
+  setKeyPair(keypair: BlahKeyPair) {
+    if (this.internalKey.id !== keypair.id) throw new Error("Key ID mismatch");
+    this.internalKey = keypair;
+  }
+}
diff --git a/identity/identity.test.ts b/identity/identity.test.ts
index 39349c5..1aeacc9 100644
--- a/identity/identity.test.ts
+++ b/identity/identity.test.ts
@@ -76,7 +76,7 @@ Deno.test("identity file act key sigs are properly verfied", async () => {
     .fromIdentityFile(
       identityFileWithActKeyInvalidActKeySig,
     );
-  expect(identityWithActKeyInvalidActKeySig.actKeys[0].sigValid).toBe(false);
+  expect(identityWithActKeyInvalidActKeySig.actKeys[0].isSigValid).toBe(false);
 });
 
 Deno.test("add a second act key", async () => {
@@ -105,6 +105,12 @@ Deno.test("update first act key", async () => {
   expect(record.comment).toBe("test2");
 });
 
+Deno.test("act key properly expires", async () => {
+  expect(identity.actKeys[0].isExpired).toBe(false);
+  await identity.updateActKey(actKeyPair.id, { expiresAt: new Date(10000) });
+  expect(identity.actKeys[0].isExpired).toBe(true);
+});
+
 Deno.test("update profile", async () => {
   const newProfile: BlahProfile = {
     typ: "profile",
diff --git a/identity/identity.ts b/identity/identity.ts
index 5de241c..bc8e573 100644
--- a/identity/identity.ts
+++ b/identity/identity.ts
@@ -3,87 +3,20 @@ import {
   BlahPublicKey,
   type BlahSignedPayload,
 } from "../crypto/mod.ts";
-import { type BlahActKeyRecord, blahActKeyRecordSchema } from "./actKey.ts";
+import { type ActKeyUpdate, BlahActKey } from "./actKey.ts";
 import { blahIdentityFileSchema } from "./identityFile.ts";
 import type { BlahIdentityFile } from "./mod.ts";
 import type { BlahProfile } from "./profile.ts";
 
-type InternalActKey = {
-  raw: BlahSignedPayload<BlahActKeyRecord>;
-  key: BlahPublicKey | BlahKeyPair;
-  expiresAt: Date;
-  sigValid: boolean;
-};
-
-type ActKey = {
-  publicKey: BlahPublicKey;
-  expiresAt: Date;
-  sigValid: boolean;
-  comment: string;
-};
-
-type ActKeyConfig = Partial<Omit<ActKey, "publicKey" | "sigValid">>;
-
-async function constructActKeyFromRaw(
-  raw: BlahSignedPayload<BlahActKeyRecord>,
-  idKey: BlahPublicKey | BlahKeyPair,
-): Promise<InternalActKey> {
-  const publicKey = idKey instanceof BlahKeyPair ? idKey.publicKey : idKey;
-  let sigValid = false;
-  try {
-    await publicKey.verifyPayload(raw);
-    sigValid = true;
-  } catch {
-    sigValid = false;
-  }
-
-  const key = await BlahPublicKey.fromID(raw.signee.payload.act_key);
-  const expiresAt = new Date(raw.signee.payload.expire_time * 1000);
-  return { raw, key, expiresAt, sigValid };
-}
-
-async function constructInternalActKey(
-  idKeyPair: BlahKeyPair,
-  key: BlahPublicKey | BlahKeyPair,
-  config?: ActKeyConfig,
-): Promise<InternalActKey> {
-  const actKey: ActKey = {
-    publicKey: key instanceof BlahKeyPair ? key.publicKey : key,
-    expiresAt: new Date(Date.now() + 365 * 24 * 3600 * 1000),
-    sigValid: true,
-    comment: "",
-    ...config,
-  };
-
-  const rawRecord = await idKeyPair
-    .signPayload(blahActKeyRecordSchema.parse(
-      {
-        typ: "user_act_key",
-        expire_time: Math.floor(actKey.expiresAt.getTime() / 1000),
-        comment: actKey.comment,
-        act_key: actKey.publicKey.id,
-      } satisfies BlahActKeyRecord,
-    ));
-
-  const internalActKey: InternalActKey = {
-    raw: rawRecord,
-    key,
-    expiresAt: actKey.expiresAt,
-    sigValid: true,
-  };
-
-  return internalActKey;
-}
-
 export class BlahIdentity {
   private internalIdKey: BlahPublicKey | BlahKeyPair;
-  private internalActKeys: InternalActKey[];
+  private internalActKeys: BlahActKey[];
   private rawProfile: BlahSignedPayload<BlahProfile>;
   private internalProfileSigValid: boolean;
 
   private constructor(
     internalIdKey: BlahPublicKey | BlahKeyPair,
-    internalActKeys: InternalActKey[],
+    internalActKeys: BlahActKey[],
     rawProfile: BlahSignedPayload<BlahProfile>,
     internalProfileSigValid: boolean,
   ) {
@@ -107,13 +40,8 @@ export class BlahIdentity {
       : this.internalIdKey;
   }
 
-  get actKeys(): ActKey[] {
-    return this.internalActKeys.map(({ key, expiresAt, sigValid, raw }) => ({
-      publicKey: key instanceof BlahKeyPair ? key.publicKey : key,
-      expiresAt,
-      sigValid,
-      comment: raw.signee.payload.comment,
-    }));
+  get actKeys(): BlahActKey[] {
+    return this.internalActKeys;
   }
 
   static async fromIdentityFile(
@@ -133,28 +61,31 @@ export class BlahIdentity {
     if (idKey.id !== id_key) {
       throw new Error("ID key pair does not match ID key in identity file.");
     }
+    const idKeyPublic = idKey instanceof BlahKeyPair ? idKey.publicKey : idKey;
 
-    const actKeys: InternalActKey[] = await Promise.all(
+    const actKeys: BlahActKey[] = await Promise.all(
       act_keys.map(async (raw) => {
-        const actKey = await constructActKeyFromRaw(raw, idKey);
-        if (actingKeyPair?.id === actKey.key.id) actKey.key = actingKeyPair;
+        const actKey = await BlahActKey.fromSignedRecord(raw, idKeyPublic);
+        if (actingKeyPair?.id === actKey.publicKey.id) {
+          actKey.setKeyPair(actingKeyPair);
+        }
         return actKey;
       }),
     );
 
     const rawProfile = profile;
-    const profileSigningKey = await BlahPublicKey.fromID(
-      rawProfile.signee.act_key,
+    const profileSigningKey = actKeys.find((k) =>
+      k.publicKey.id === profile.signee.act_key
     );
-    if (actKeys.findIndex((k) => k.key.id === profileSigningKey.id) === -1) {
-      throw new Error("Profile is not signed by any of the act keys.");
-    }
+
     let profileSigValid = false;
-    try {
-      await profileSigningKey.verifyPayload(rawProfile);
-      profileSigValid = true;
-    } catch {
-      profileSigValid = false;
+    if (profileSigningKey) {
+      try {
+        await profileSigningKey.verifyPayload(rawProfile);
+        profileSigValid = true;
+      } catch {
+        profileSigValid = false;
+      }
     }
 
     return new BlahIdentity(idKey, actKeys, rawProfile, profileSigValid);
@@ -164,82 +95,57 @@ export class BlahIdentity {
     idKeyPair: BlahKeyPair,
     firstActKey: BlahKeyPair,
     profile: BlahProfile,
-    firstActKeyConfig?: ActKeyConfig,
+    firstActKeyConfig?: ActKeyUpdate,
   ): Promise<BlahIdentity> {
-    const internalActKey = await constructInternalActKey(
-      idKeyPair,
+    const actKey = await BlahActKey.create(
       firstActKey,
+      idKeyPair,
       firstActKeyConfig,
     );
 
-    const profileRecord: BlahSignedPayload<BlahProfile> = await firstActKey
-      .signPayload(profile);
+    const profileRecord = await firstActKey.signPayload(profile);
 
-    return new BlahIdentity(
-      idKeyPair,
-      [internalActKey],
-      profileRecord,
-      true,
-    );
+    return new BlahIdentity(idKeyPair, [actKey], profileRecord, true);
   }
 
   generateIdentityFile(): BlahIdentityFile {
     return blahIdentityFileSchema.parse(
       {
         id_key: this.idPublicKey.id,
-        act_keys: this.internalActKeys.map((k) => (k.raw)),
+        act_keys: this.internalActKeys.map((k) => k.toSignedRecord()),
         profile: this.rawProfile,
       } satisfies BlahIdentityFile,
     );
   }
 
-  async addActKey(actKey: BlahKeyPair | BlahPublicKey, config?: ActKeyConfig) {
+  async addActKey(actKey: BlahKeyPair | BlahPublicKey, config?: ActKeyUpdate) {
     if (this.internalIdKey instanceof BlahPublicKey) {
       throw new Error("Cannot add act key to identity without ID key pair.");
     }
 
-    const internalActKey = await constructInternalActKey(
-      this.internalIdKey,
-      actKey,
-      config,
-    );
-
-    this.internalActKeys.push(internalActKey);
+    const key = await BlahActKey.create(actKey, this.internalIdKey, config);
+    this.internalActKeys.push(key);
   }
 
-  async updateActKey(
-    keyId: string,
-    config: ActKeyConfig,
-  ) {
+  async updateActKey(id: string, update: ActKeyUpdate) {
     if (this.internalIdKey instanceof BlahPublicKey) {
       throw new Error("Cannot update act key in identity without ID key pair.");
     }
 
-    const actKeyIndex = this.internalActKeys.findIndex(
-      (k) => k.key.id === keyId,
-    );
-    if (actKeyIndex === -1) {
+    const key = this.internalActKeys.find((k) => k.publicKey.id === id);
+    if (!key) {
       throw new Error("Act key not found in identity.");
     }
-
-    this.internalActKeys[actKeyIndex] = await constructInternalActKey(
-      this.internalIdKey,
-      this.internalActKeys[actKeyIndex].key,
-      config,
-    );
+    await key.update(update, this.internalIdKey);
   }
 
   async updateProfile(profile: BlahProfile) {
-    const signingActKey = this.internalActKeys.find((k) =>
-      k.key instanceof BlahKeyPair
-    );
+    const signingActKey = this.internalActKeys.find((k) => k.canSign);
     if (!signingActKey) {
       throw new Error("No act key to sign profile with.");
     }
 
-    this.rawProfile = await (signingActKey.key as BlahKeyPair).signPayload(
-      profile,
-    );
+    this.rawProfile = await signingActKey.signPayload(profile);
     this.internalProfileSigValid = true;
   }
 }