-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from 19 commits
174ee87
749bf5a
9443996
06c1714
6904576
c213fe0
db76535
0a0d747
b9eee01
54bbff4
a6776e2
3fc21b6
11af771
82bce44
9f7a500
29a240e
bdf3bd1
11453fe
5042403
9c35430
d1de58a
bc6cfe6
af977ed
2b6d2d0
eb02c9e
123fa49
4f9ab12
34ce0ac
c9c6d5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export class MalformedIndexChainError extends Error {} | ||
|
||
export class MalformedHashChainError extends Error {} | ||
|
||
export class BadSignatureError extends Error {} | ||
|
||
export class BadNextKeySpecifiedError extends Error {} | ||
|
||
export class BadOptionsSpecifiedError extends Error {} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would remove the TODO and create an issue instead There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Error(); | ||
sosweetham marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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(); | ||
coodos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const logEvent: LogEvent = { | ||
id: latestEntry.id, | ||
sosweetham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
versionTime: new Date(Date.now()), | ||
versionId: `${index}-${logHash}`, | ||
updateKeys: [nextKeySigner.pubKey], | ||
nextKeyHashes: nextKeyHashes, | ||
sosweetham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
} |
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: (string: 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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
/** | ||
* Utility function to check if A is subset of B | ||
*/ | ||
|
||
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; | ||
} |
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); | ||
} | ||
sosweetham marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import canonicalize from "canonicalize"; | ||
|
||
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"); | ||
} | ||
dataToHash = canonical; | ||
} | ||
|
||
const buffer = new TextEncoder().encode(dataToHash); | ||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); | ||
const hashArray = Array.from(new Uint8Array(hashBuffer)); | ||
const hashHex = hashArray | ||
.map((b) => b.toString(16).padStart(2, "0")) | ||
.join(""); | ||
|
||
return hashHex; | ||
} |
Uh oh!
There was an error while loading. Please reload this page.