refactor: extract BlahActKey to streamline BlahIdentity

This commit is contained in:
Shibo Lyu 2024-10-31 01:42:38 +08:00
parent f7afcaa1f2
commit 79fbd17ff8
4 changed files with 203 additions and 131 deletions

View file

@ -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",

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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;
}
}