Skip to content

Commit cf7ae90

Browse files
coodossosweetham
andauthored
Feat/w3id log generation (#98)
* chore: create basic log generation mechanism * chore: add hashing utility function * chore: rotation event * feat: genesis entry * feat: generalize hash function * feat: append entry * chore: basic tests * chore: add tests for rotation * feat: add malform throws * chore: add the right errors * chore: fix CI stuff * chore: add missing file * chore: fix event type enum * chore: format * feat: add proper error * chore: format * chore: remove eventtypes enum * chore: add new error for bad options * chore: add options tests * feat: add codec tests * fix: err handling && jsdoc * fix: run format * fix: remove unused import * fix: improve default error messages * fix: move redundant logic to function * fix: run format * fix: type shadow * fix: useless conversion/cast * fix: run format --------- Co-authored-by: Soham Jaiswal <[email protected]>
1 parent fdd6007 commit cf7ae90

File tree

11 files changed

+568
-0
lines changed

11 files changed

+568
-0
lines changed

infrastructure/w3id/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
"author": "",
2020
"license": "ISC",
2121
"dependencies": {
22+
"canonicalize": "^2.1.0",
23+
"multiformats": "^13.3.2",
24+
"tweetnacl": "^1.0.3",
2225
"uuid": "^11.1.0"
2326
},
2427
"devDependencies": {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export class MalformedIndexChainError extends Error {
2+
constructor(message: string = "Malformed index chain detected") {
3+
super(message);
4+
this.name = "MalformedIndexChainError";
5+
}
6+
}
7+
8+
export class MalformedHashChainError extends Error {
9+
constructor(message: string = "Malformed hash chain detected") {
10+
super(message);
11+
this.name = "MalformedHashChainError";
12+
}
13+
}
14+
15+
export class BadSignatureError extends Error {
16+
constructor(message: string = "Bad signature detected") {
17+
super(message);
18+
this.name = "BadSignatureError";
19+
}
20+
}
21+
22+
export class BadNextKeySpecifiedError extends Error {
23+
constructor(message: string = "Bad next key specified") {
24+
super(message);
25+
this.name = "BadNextKeySpecifiedError";
26+
}
27+
}
28+
29+
export class BadOptionsSpecifiedError extends Error {
30+
constructor(message: string = "Bad options specified") {
31+
super(message);
32+
this.name = "BadOptionsSpecifiedError";
33+
}
34+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import canonicalize from "canonicalize";
2+
import {
3+
BadNextKeySpecifiedError,
4+
BadOptionsSpecifiedError,
5+
BadSignatureError,
6+
MalformedHashChainError,
7+
MalformedIndexChainError,
8+
} from "../errors/errors";
9+
import { isSubsetOf } from "../utils/array";
10+
import { hash } from "../utils/hash";
11+
import {
12+
isGenesisOptions,
13+
isRotationOptions,
14+
type CreateLogEventOptions,
15+
type GenesisLogOptions,
16+
type LogEvent,
17+
type RotationLogOptions,
18+
type VerifierCallback,
19+
} from "./log.types";
20+
import type { StorageSpec } from "./storage/storage-spec";
21+
22+
/**
23+
* Class to generate historic event logs for all historic events for an Identifier
24+
* starting with generating it's first log entry
25+
*/
26+
27+
// TODO: Create a specification link inside our docs for how generation of identifier works
28+
29+
export class IDLogManager {
30+
repository: StorageSpec<LogEvent, LogEvent>;
31+
32+
constructor(repository: StorageSpec<LogEvent, LogEvent>) {
33+
this.repository = repository;
34+
}
35+
36+
static async validateLogChain(
37+
log: LogEvent[],
38+
verifyCallback: VerifierCallback,
39+
) {
40+
let currIndex = 0;
41+
let currentNextKeyHashesSeen: string[] = [];
42+
let lastUpdateKeysSeen: string[] = [];
43+
let lastHash: string | null = null;
44+
45+
for (const e of log) {
46+
const [_index, _hash] = e.versionId.split("-");
47+
const index = Number(_index);
48+
if (currIndex !== index) throw new MalformedIndexChainError();
49+
const hashedUpdateKeys = await Promise.all(
50+
e.updateKeys.map(async (k) => await hash(k)),
51+
);
52+
if (index > 0) {
53+
const updateKeysSeen = isSubsetOf(
54+
hashedUpdateKeys,
55+
currentNextKeyHashesSeen,
56+
);
57+
if (!updateKeysSeen || lastHash !== _hash)
58+
throw new MalformedHashChainError();
59+
}
60+
61+
currentNextKeyHashesSeen = e.nextKeyHashes;
62+
await IDLogManager.verifyLogEventProof(
63+
e,
64+
lastUpdateKeysSeen.length > 0 ? lastUpdateKeysSeen : e.updateKeys,
65+
verifyCallback,
66+
);
67+
lastUpdateKeysSeen = e.updateKeys;
68+
currIndex++;
69+
lastHash = await hash(canonicalize(e) as string);
70+
}
71+
return true;
72+
}
73+
74+
private static async verifyLogEventProof(
75+
e: LogEvent,
76+
currentUpdateKeys: string[],
77+
verifyCallback: VerifierCallback,
78+
) {
79+
const proof = e.proof;
80+
const copy = JSON.parse(JSON.stringify(e));
81+
// biome-ignore lint/performance/noDelete: we need to delete proof completely
82+
delete copy.proof;
83+
const canonicalJson = canonicalize(copy);
84+
let verified = false;
85+
if (!proof) throw new BadSignatureError("No proof found in the log event.");
86+
for (const key of currentUpdateKeys) {
87+
const signValidates = await verifyCallback(
88+
canonicalJson as string,
89+
proof,
90+
key,
91+
);
92+
if (signValidates) verified = true;
93+
}
94+
if (!verified) throw new BadSignatureError();
95+
}
96+
97+
private async appendEntry(entries: LogEvent[], options: RotationLogOptions) {
98+
const { signer, nextKeyHashes, nextKeySigner } = options;
99+
const latestEntry = entries[entries.length - 1];
100+
const logHash = await hash(latestEntry);
101+
const index = Number(latestEntry.versionId.split("-")[0]) + 1;
102+
103+
const currKeyHash = await hash(nextKeySigner.pubKey);
104+
if (!latestEntry.nextKeyHashes.includes(currKeyHash))
105+
throw new BadNextKeySpecifiedError();
106+
107+
const logEvent: LogEvent = {
108+
id: latestEntry.id,
109+
versionTime: new Date(Date.now()),
110+
versionId: `${index}-${logHash}`,
111+
updateKeys: [nextKeySigner.pubKey],
112+
nextKeyHashes: nextKeyHashes,
113+
method: "w3id:v0.0.0",
114+
};
115+
116+
const proof = await signer.sign(canonicalize(logEvent) as string);
117+
logEvent.proof = proof;
118+
119+
await this.repository.create(logEvent);
120+
return logEvent;
121+
}
122+
123+
private async createGenesisEntry(options: GenesisLogOptions) {
124+
const { id, nextKeyHashes, signer } = options;
125+
const logEvent: LogEvent = {
126+
id,
127+
versionId: `0-${id.split("@")[1]}`,
128+
versionTime: new Date(Date.now()),
129+
updateKeys: [signer.pubKey],
130+
nextKeyHashes: nextKeyHashes,
131+
method: "w3id:v0.0.0",
132+
};
133+
const proof = await signer.sign(canonicalize(logEvent) as string);
134+
logEvent.proof = proof;
135+
await this.repository.create(logEvent);
136+
return logEvent;
137+
}
138+
139+
async createLogEvent(options: CreateLogEventOptions) {
140+
const entries = await this.repository.findMany({});
141+
if (entries.length > 0) {
142+
if (!isRotationOptions(options)) throw new BadOptionsSpecifiedError();
143+
return this.appendEntry(entries, options);
144+
}
145+
if (!isGenesisOptions(options)) throw new BadOptionsSpecifiedError();
146+
return this.createGenesisEntry(options);
147+
}
148+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type LogEvent = {
2+
id: string;
3+
versionId: string;
4+
versionTime: Date;
5+
updateKeys: string[];
6+
nextKeyHashes: string[];
7+
method: `w3id:v${string}`;
8+
proof?: string;
9+
};
10+
11+
export type VerifierCallback = (
12+
message: string,
13+
signature: string,
14+
pubKey: string,
15+
) => Promise<boolean>;
16+
17+
export type Signer = {
18+
sign: (message: string) => Promise<string> | string;
19+
pubKey: string;
20+
};
21+
22+
export type RotationLogOptions = {
23+
nextKeyHashes: string[];
24+
signer: Signer;
25+
nextKeySigner: Signer;
26+
};
27+
28+
export type GenesisLogOptions = {
29+
nextKeyHashes: string[];
30+
id: string;
31+
signer: Signer;
32+
};
33+
34+
export function isGenesisOptions(
35+
options: CreateLogEventOptions,
36+
): options is GenesisLogOptions {
37+
return "id" in options;
38+
}
39+
export function isRotationOptions(
40+
options: CreateLogEventOptions,
41+
): options is RotationLogOptions {
42+
return "nextKeySigner" in options;
43+
}
44+
45+
export type CreateLogEventOptions = GenesisLogOptions | RotationLogOptions;

infrastructure/w3id/src/logs/store.ts

Whitespace-only changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Utility function to check if A is subset of B
3+
*
4+
* @param a Array to check if it's a subset
5+
* @param b Array to check against
6+
* @returns true if every element in 'a' is present in 'b' with at least the same frequency
7+
* @example
8+
* isSubsetOf([1, 2], [1, 2, 3]) // returns true
9+
* isSubsetOf([1, 1, 2], [1, 2, 3]) // returns false (not enough 1's in b)
10+
* isSubsetOf([], [1, 2]) // returns true (empty set is a subset of any set)
11+
*/
12+
13+
export function isSubsetOf(a: unknown[], b: unknown[]) {
14+
const map = new Map();
15+
16+
for (const el of b) {
17+
map.set(el, (map.get(el) || 0) + 1);
18+
}
19+
20+
for (const el of a) {
21+
if (!map.has(el) || map.get(el) === 0) {
22+
return false;
23+
}
24+
map.set(el, map.get(el) - 1);
25+
}
26+
27+
return true;
28+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function uint8ArrayToHex(bytes: Uint8Array): string {
2+
return Array.from(bytes)
3+
.map((b) => b.toString(16).padStart(2, "0"))
4+
.join("");
5+
}
6+
7+
export function hexToUint8Array(hex: string): Uint8Array {
8+
if (hex.length % 2 !== 0) {
9+
throw new Error("Hex string must have an even length");
10+
}
11+
const bytes = new Uint8Array(hex.length / 2);
12+
for (let i = 0; i < hex.length; i += 2) {
13+
bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
14+
}
15+
return bytes;
16+
}
17+
18+
export function stringToUint8Array(str: string): Uint8Array {
19+
return new TextEncoder().encode(str);
20+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import canonicalize from "canonicalize";
2+
import { uint8ArrayToHex } from "./codec";
3+
4+
export async function hash(
5+
input: string | Record<string, unknown>,
6+
): Promise<string> {
7+
let dataToHash: string;
8+
9+
if (typeof input === "string") {
10+
dataToHash = input;
11+
} else {
12+
const canonical = canonicalize(input);
13+
if (!canonical) {
14+
throw new Error(
15+
`Failed to canonicalize object: ${JSON.stringify(input).substring(0, 100)}...`,
16+
);
17+
}
18+
dataToHash = canonical;
19+
}
20+
21+
const buffer = new TextEncoder().encode(dataToHash);
22+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
23+
const hashHex = uint8ArrayToHex(new Uint8Array(hashBuffer));
24+
25+
return hashHex;
26+
}

0 commit comments

Comments
 (0)