Skip to content

Feat/w3id log generation #98

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
174ee87
chore: create basic log generation mechanism
coodos Apr 3, 2025
749bf5a
chore: add hashing utility function
coodos Apr 3, 2025
9443996
chore: rotation event
coodos Apr 4, 2025
06c1714
feat: genesis entry
coodos Apr 8, 2025
6904576
feat: generalize hash function
coodos Apr 8, 2025
c213fe0
feat: append entry
coodos Apr 8, 2025
db76535
chore: basic tests
coodos Apr 8, 2025
0a0d747
chore: add tests for rotation
coodos Apr 9, 2025
b9eee01
feat: add malform throws
coodos Apr 9, 2025
54bbff4
chore: add the right errors
coodos Apr 9, 2025
a6776e2
chore: fix CI stuff
coodos Apr 9, 2025
3fc21b6
chore: add missing file
coodos Apr 9, 2025
11af771
chore: fix event type enum
coodos Apr 9, 2025
82bce44
chore: format
coodos Apr 9, 2025
9f7a500
feat: add proper error
coodos Apr 9, 2025
29a240e
chore: format
coodos Apr 9, 2025
bdf3bd1
chore: remove eventtypes enum
coodos Apr 9, 2025
11453fe
chore: add new error for bad options
coodos Apr 9, 2025
5042403
chore: add options tests
coodos Apr 9, 2025
9c35430
feat: add codec tests
sosweetham Apr 9, 2025
d1de58a
fix: err handling && jsdoc
sosweetham Apr 9, 2025
bc6cfe6
fix: run format
sosweetham Apr 9, 2025
af977ed
fix: remove unused import
sosweetham Apr 9, 2025
2b6d2d0
fix: improve default error messages
sosweetham Apr 9, 2025
eb02c9e
fix: move redundant logic to function
sosweetham Apr 9, 2025
123fa49
fix: run format
sosweetham Apr 9, 2025
4f9ab12
fix: type shadow
sosweetham Apr 9, 2025
34ce0ac
fix: useless conversion/cast
sosweetham Apr 9, 2025
c9c6d5e
fix: run format
sosweetham Apr 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions infrastructure/w3id/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"author": "",
"license": "ISC",
"dependencies": {
"canonicalize": "^2.1.0",
"multiformats": "^13.3.2",
"tweetnacl": "^1.0.3",
"uuid": "^11.1.0"
},
"devDependencies": {
Expand Down
34 changes: 34 additions & 0 deletions infrastructure/w3id/src/errors/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export class MalformedIndexChainError extends Error {
constructor(message: string = "Malformed index chain detected") {
super(message);
this.name = "MalformedIndexChainError";
}
}

export class MalformedHashChainError extends Error {
constructor(message: string = "Malformed hash chain detected") {
super(message);
this.name = "MalformedHashChainError";
}
}

export class BadSignatureError extends Error {
constructor(message: string = "Bad signature detected") {
super(message);
this.name = "BadSignatureError";
}
}

export class BadNextKeySpecifiedError extends Error {
constructor(message: string = "Bad next key specified") {
super(message);
this.name = "BadNextKeySpecifiedError";
}
}

export class BadOptionsSpecifiedError extends Error {
constructor(message: string = "Bad options specified") {
super(message);
this.name = "BadOptionsSpecifiedError";
}
}
148 changes: 148 additions & 0 deletions infrastructure/w3id/src/logs/log-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import canonicalize from "canonicalize";
import {
BadNextKeySpecifiedError,
BadOptionsSpecifiedError,
BadSignatureError,
MalformedHashChainError,
MalformedIndexChainError,
} from "../errors/errors";
import { isSubsetOf } from "../utils/array";
import { hash } from "../utils/hash";
import {
isGenesisOptions,
isRotationOptions,
type CreateLogEventOptions,
type GenesisLogOptions,
type LogEvent,
type RotationLogOptions,
type VerifierCallback,
} from "./log.types";
import type { StorageSpec } from "./storage/storage-spec";

/**
* Class to generate historic event logs for all historic events for an Identifier
* starting with generating it's first log entry
*/

// TODO: Create a specification link inside our docs for how generation of identifier works

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove the TODO and create an issue instead

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concur @coodos, unless you had something else in mind


export class IDLogManager {
repository: StorageSpec<LogEvent, LogEvent>;

constructor(repository: StorageSpec<LogEvent, LogEvent>) {
this.repository = repository;
}

static async validateLogChain(
log: LogEvent[],
verifyCallback: VerifierCallback,
) {
let currIndex = 0;
let currentNextKeyHashesSeen: string[] = [];
let lastUpdateKeysSeen: string[] = [];
let lastHash: string | null = null;

for (const e of log) {
const [_index, _hash] = e.versionId.split("-");
const index = Number(_index);
if (currIndex !== index) throw new MalformedIndexChainError();
const hashedUpdateKeys = await Promise.all(
e.updateKeys.map(async (k) => await hash(k)),
);
if (index > 0) {
const updateKeysSeen = isSubsetOf(
hashedUpdateKeys,
currentNextKeyHashesSeen,
);
if (!updateKeysSeen || lastHash !== _hash)
throw new MalformedHashChainError();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we add a message or error key we can add more details here. It makes it easier to debug in production

Copy link
Member

@sosweetham sosweetham Apr 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be vague on purpose, @coodos confirm pl, although i dont see much of a problem throwing out the key in the message cuz it's wrong anyways lol

}

currentNextKeyHashesSeen = e.nextKeyHashes;
await IDLogManager.verifyLogEventProof(
e,
lastUpdateKeysSeen.length > 0 ? lastUpdateKeysSeen : e.updateKeys,
verifyCallback,
);
lastUpdateKeysSeen = e.updateKeys;
currIndex++;
lastHash = await hash(canonicalize(e) as string);
}
return true;
}

private static async verifyLogEventProof(
e: LogEvent,
currentUpdateKeys: string[],
verifyCallback: VerifierCallback,
) {
const proof = e.proof;
const copy = JSON.parse(JSON.stringify(e));
// biome-ignore lint/performance/noDelete: we need to delete proof completely
delete copy.proof;
const canonicalJson = canonicalize(copy);
let verified = false;
if (!proof) throw new BadSignatureError("No proof found in the log event.");
for (const key of currentUpdateKeys) {
const signValidates = await verifyCallback(
canonicalJson as string,
proof,
key,
);
if (signValidates) verified = true;
}
if (!verified) throw new BadSignatureError();
}

private async appendEntry(entries: LogEvent[], options: RotationLogOptions) {
const { signer, nextKeyHashes, nextKeySigner } = options;
const latestEntry = entries[entries.length - 1];
const logHash = await hash(latestEntry);
const index = Number(latestEntry.versionId.split("-")[0]) + 1;

const currKeyHash = await hash(nextKeySigner.pubKey);
if (!latestEntry.nextKeyHashes.includes(currKeyHash))
throw new BadNextKeySpecifiedError();

const logEvent: LogEvent = {
id: latestEntry.id,
versionTime: new Date(Date.now()),
versionId: `${index}-${logHash}`,
updateKeys: [nextKeySigner.pubKey],
nextKeyHashes: nextKeyHashes,
method: "w3id:v0.0.0",
};

const proof = await signer.sign(canonicalize(logEvent) as string);
logEvent.proof = proof;

await this.repository.create(logEvent);
return logEvent;
}

private async createGenesisEntry(options: GenesisLogOptions) {
const { id, nextKeyHashes, signer } = options;
const logEvent: LogEvent = {
id,
versionId: `0-${id.split("@")[1]}`,
versionTime: new Date(Date.now()),
updateKeys: [signer.pubKey],
nextKeyHashes: nextKeyHashes,
method: "w3id:v0.0.0",
};
const proof = await signer.sign(canonicalize(logEvent) as string);
logEvent.proof = proof;
await this.repository.create(logEvent);
return logEvent;
}

async createLogEvent(options: CreateLogEventOptions) {
const entries = await this.repository.findMany({});
if (entries.length > 0) {
if (!isRotationOptions(options)) throw new BadOptionsSpecifiedError();
return this.appendEntry(entries, options);
}
if (!isGenesisOptions(options)) throw new BadOptionsSpecifiedError();
return this.createGenesisEntry(options);
}
}
45 changes: 45 additions & 0 deletions infrastructure/w3id/src/logs/log.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type LogEvent = {
id: string;
versionId: string;
versionTime: Date;
updateKeys: string[];
nextKeyHashes: string[];
method: `w3id:v${string}`;
proof?: string;
};

export type VerifierCallback = (
message: string,
signature: string,
pubKey: string,
) => Promise<boolean>;

export type Signer = {
sign: (message: string) => Promise<string> | string;
pubKey: string;
};

export type RotationLogOptions = {
nextKeyHashes: string[];
signer: Signer;
nextKeySigner: Signer;
};

export type GenesisLogOptions = {
nextKeyHashes: string[];
id: string;
signer: Signer;
};

export function isGenesisOptions(
options: CreateLogEventOptions,
): options is GenesisLogOptions {
return "id" in options;
}
export function isRotationOptions(
options: CreateLogEventOptions,
): options is RotationLogOptions {
return "nextKeySigner" in options;
}

export type CreateLogEventOptions = GenesisLogOptions | RotationLogOptions;
Empty file.
28 changes: 28 additions & 0 deletions infrastructure/w3id/src/utils/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Utility function to check if A is subset of B
*
* @param a Array to check if it's a subset
* @param b Array to check against
* @returns true if every element in 'a' is present in 'b' with at least the same frequency
* @example
* isSubsetOf([1, 2], [1, 2, 3]) // returns true
* isSubsetOf([1, 1, 2], [1, 2, 3]) // returns false (not enough 1's in b)
* isSubsetOf([], [1, 2]) // returns true (empty set is a subset of any set)
*/

export function isSubsetOf(a: unknown[], b: unknown[]) {
const map = new Map();

for (const el of b) {
map.set(el, (map.get(el) || 0) + 1);
}

for (const el of a) {
if (!map.has(el) || map.get(el) === 0) {
return false;
}
map.set(el, map.get(el) - 1);
}

return true;
}
20 changes: 20 additions & 0 deletions infrastructure/w3id/src/utils/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function uint8ArrayToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

export function hexToUint8Array(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error("Hex string must have an even length");
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}

export function stringToUint8Array(str: string): Uint8Array {
return new TextEncoder().encode(str);
}
26 changes: 26 additions & 0 deletions infrastructure/w3id/src/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import canonicalize from "canonicalize";
import { uint8ArrayToHex } from "./codec";

export async function hash(
input: string | Record<string, unknown>,
): Promise<string> {
let dataToHash: string;

if (typeof input === "string") {
dataToHash = input;
} else {
const canonical = canonicalize(input);
if (!canonical) {
throw new Error(
`Failed to canonicalize object: ${JSON.stringify(input).substring(0, 100)}...`,
);
}
dataToHash = canonical;
}

const buffer = new TextEncoder().encode(dataToHash);
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashHex = uint8ArrayToHex(new Uint8Array(hashBuffer));

return hashHex;
}
Loading