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

(signedPayload: BlahSignedPayload

): Promise

{ + return await this.internalPublicKey.verifyPayload(signedPayload); + } + static async generate(extractable: boolean = true): Promise { 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; + + private constructor( + key: BlahPublicKey | BlahKeyPair, + idKeyPublic: BlahPublicKey, + fullConfig: Required, + sigValid: boolean, + signedRecord: BlahSignedPayload, + ) { + 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, + idKeyPublic: BlahPublicKey, + keypair?: BlahKeyPair, + ): Promise { + 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 = { + 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 { + const fullConfig: Required = { + 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

( + payload: P, + date: Date = new Date(), + ): Promise> { + 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

( + payload: BlahSignedPayload

, + ): Promise

{ + 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 { + 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 { + 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; - key: BlahPublicKey | BlahKeyPair; - expiresAt: Date; - sigValid: boolean; -}; - -type ActKey = { - publicKey: BlahPublicKey; - expiresAt: Date; - sigValid: boolean; - comment: string; -}; - -type ActKeyConfig = Partial>; - -async function constructActKeyFromRaw( - raw: BlahSignedPayload, - idKey: BlahPublicKey | BlahKeyPair, -): Promise { - 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 { - 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; private internalProfileSigValid: boolean; private constructor( internalIdKey: BlahPublicKey | BlahKeyPair, - internalActKeys: InternalActKey[], + internalActKeys: BlahActKey[], rawProfile: BlahSignedPayload, 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 { - const internalActKey = await constructInternalActKey( - idKeyPair, + const actKey = await BlahActKey.create( firstActKey, + idKeyPair, firstActKeyConfig, ); - const profileRecord: BlahSignedPayload = 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; } }