-
Notifications
You must be signed in to change notification settings - Fork 2
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 all 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,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"; | ||
} | ||
} |
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 | ||
|
||
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 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(); | ||
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: (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; |
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; | ||
} |
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"; | ||
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; | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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