Created
May 12, 2025 14:13
-
-
Save Harasz/e3c041c19b85a1f03e1637243554e47b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Client, | |
PrivateKey, | |
PublicKey, | |
TopicCreateTransaction, | |
TopicMessageSubmitTransaction, | |
} from "@hashgraph/sdk"; | |
import { | |
JsonLdDIDDocument, | |
KeysUtility, | |
VerificationMethod, | |
} from "@swiss-digital-assets-institute/core"; | |
import { TopicReaderHederaClient } from "@swiss-digital-assets-institute/resolver"; | |
import { inspect } from "util"; | |
import { canonicalize } from "@tufjs/canonical-json"; | |
import { createHash } from "crypto"; | |
// https://www.rfc-editor.org/rfc/rfc8037.html | |
interface Ed25519JWK { | |
kty: "OKP"; | |
crv: "Ed25519"; | |
alg?: "EdDSA"; | |
// Public key | |
x: string; | |
// Private key | |
d?: string; | |
kid?: string; | |
key_ops?: Array<"sign" | "verify">; | |
use?: "sig"; | |
} | |
type ProofPurpose = | |
| "authentication" | |
| "assertionMethod" | |
| "keyAgreement" | |
| "capabilityDelegation" | |
| "capabilityInvocation"; | |
interface Proof { | |
type: string; | |
created: string; | |
proofPurpose: ProofPurpose; | |
verificationMethod: string; | |
proofValue: string; | |
cryptosuite?: string; | |
expires?: string; | |
domain?: string; | |
challenge?: string; | |
[key: string]: unknown; | |
} | |
type LDContext = string | string[]; | |
interface InputDocument { | |
"@context"?: LDContext; | |
[key: string]: unknown; | |
} | |
interface SecuredDataDocument extends InputDocument { | |
proof: Proof; | |
} | |
interface ProofOptions { | |
proofPurpose: ProofPurpose; | |
verificationMethod: string; | |
expires?: string; | |
domain?: string; | |
challenge?: string; | |
} | |
interface ProofConfig extends ProofOptions { | |
type: string; | |
cryptosuite?: string; | |
created: string; | |
} | |
type UnsecuredDocument = InputDocument; | |
const canonizeJsonDocument = async (document: object): Promise<string> => { | |
return canonicalize(document); | |
}; | |
const hashData = async (data: string): Promise<Uint8Array> => { | |
return createHash("sha256").update(data).digest(); | |
}; | |
interface VerificationResult { | |
verified: boolean; | |
verifiedDocument: UnsecuredDocument; | |
} | |
class InternalEd25519Verifier { | |
private publicKey: PublicKey; | |
constructor(publicKey: string | PublicKey | Ed25519JWK) { | |
if (typeof publicKey === "string") { | |
this.publicKey = PublicKey.fromStringED25519(publicKey); | |
} else if (publicKey instanceof PublicKey) { | |
this.publicKey = publicKey; | |
} else { | |
this.publicKey = PublicKey.fromBytesED25519( | |
Buffer.from(publicKey.x, "base64url") | |
); | |
} | |
} | |
async verifyProof( | |
securedDocument: SecuredDataDocument | |
): Promise<VerificationResult> { | |
const unsecuredDocument = structuredClone(securedDocument); | |
delete unsecuredDocument.proof; | |
const proofOptions = structuredClone(securedDocument.proof); | |
delete proofOptions.proofValue; | |
const proofBytes = KeysUtility.fromMultibase( | |
securedDocument.proof.proofValue | |
).toBytes(); | |
if (proofOptions["@context"]) { | |
const proofContext = Array.isArray(proofOptions["@context"]) | |
? proofOptions["@context"] | |
: [proofOptions["@context"]]; | |
const docContext = Array.isArray(securedDocument["@context"]) | |
? securedDocument["@context"] | |
: [securedDocument["@context"] || []]; | |
const isValidContext = proofContext.every( | |
(ctx, index) => docContext[index] === ctx | |
); | |
if (!isValidContext) { | |
return { | |
verified: false, | |
verifiedDocument: null, | |
}; | |
} | |
unsecuredDocument["@context"] = proofOptions["@context"] as LDContext; | |
} | |
const canonicalProofConfig = await canonizeJsonDocument(proofOptions); | |
const canonicalDocument = await canonizeJsonDocument(unsecuredDocument); | |
const canonicalProofConfigHash = await hashData(canonicalProofConfig); | |
const canonicalDocumentHash = await hashData(canonicalDocument); | |
const hashedData = Buffer.concat([ | |
canonicalDocumentHash, | |
canonicalProofConfigHash, | |
]); | |
const verified = this.publicKey.verify(hashedData, proofBytes); | |
return { | |
verified, | |
verifiedDocument: verified ? unsecuredDocument : null, | |
}; | |
} | |
} | |
/** | |
* InternalEd25519Signer is a class that implements the Data Integrity EdDSA Cryptosuites v1.0 for the Ed25519 algorithm. | |
* | |
* @see https://www.w3.org/TR/vc-di-eddsa/ | |
*/ | |
class InternalEd25519Signer extends InternalEd25519Verifier { | |
private key: PrivateKey; | |
constructor(privateKey: string | PrivateKey | Ed25519JWK) { | |
let key: PrivateKey; | |
if (typeof privateKey === "string") { | |
key = PrivateKey.fromStringED25519(privateKey); | |
} else if (privateKey instanceof PrivateKey) { | |
key = privateKey; | |
} else { | |
key = PrivateKey.fromBytesED25519(Buffer.from(privateKey.d, "base64url")); | |
} | |
super(key.publicKey); | |
this.key = key; | |
} | |
async createProof( | |
inputDocument: InputDocument, | |
proofOptions: ProofOptions | |
): Promise<SecuredDataDocument> { | |
const proofConfig: ProofConfig = { | |
type: "DataIntegrityProof", | |
cryptosuite: "eddsa-jcs-2022", | |
created: new Date().toISOString(), | |
proofPurpose: proofOptions.proofPurpose, | |
verificationMethod: proofOptions.verificationMethod, | |
}; | |
if (inputDocument["@context"]) { | |
proofConfig["@context"] = inputDocument["@context"]; | |
} | |
if (proofOptions.expires) { | |
proofConfig.expires = proofOptions.expires; | |
} | |
if (proofOptions.domain) { | |
proofConfig.domain = proofOptions.domain; | |
} | |
if (proofOptions.challenge) { | |
proofConfig.challenge = proofOptions.challenge; | |
} | |
const canonicalProofConfig = await canonizeJsonDocument(proofConfig); | |
const canonicalDocument = await canonizeJsonDocument(inputDocument); | |
const canonicalProofConfigHash = await hashData(canonicalProofConfig); | |
const canonicalDocumentHash = await hashData(canonicalDocument); | |
const hashedData = Buffer.concat([ | |
canonicalDocumentHash, | |
canonicalProofConfigHash, | |
]); | |
const proofBytes = this.key.sign(hashedData); | |
const proofValue = | |
KeysUtility.fromBytes(proofBytes).toMultibase("base58btc"); | |
const proof: Proof = { | |
...proofConfig, | |
proofValue, | |
}; | |
return { | |
...inputDocument, | |
proof, | |
}; | |
} | |
} | |
// Create a new topic and publish a 'create' message | |
async function createDidAndPublish(client: Client) { | |
// 1. Generate Ed25519 keypair | |
const privateKey = PrivateKey.generateED25519(); | |
const publicKey = privateKey.publicKey; | |
const pubKeyBytes = publicKey.toBytesRaw(); | |
const base58btcKey = KeysUtility.fromBytes(pubKeyBytes).toBase58(); | |
const publicKeyMultibase = KeysUtility.fromBytes(pubKeyBytes).toMultibase(); | |
// 2. Create topic | |
const topicTx = await new TopicCreateTransaction() | |
.setTopicMemo("DID v2 PoC") | |
.execute(client); | |
const topicId = (await topicTx.getReceipt(client)).topicId.toString(); | |
// 3. Build DID | |
const network = "testnet"; | |
const did = `did:hedera:${network}:${base58btcKey}_${topicId}`; | |
const verificationMethodId = `${did}#key-1`; | |
// 4. Build DID document | |
const didDocument = { | |
"@context": [ | |
"https://www.w3.org/ns/did/v1", | |
"https://w3id.org/security/suites/ed25519-2020/v1", | |
], | |
id: did, | |
controller: [did], | |
verificationMethod: [ | |
{ | |
id: verificationMethodId, | |
type: "Ed25519VerificationKey2020", | |
controller: did, | |
publicKeyMultibase, | |
}, | |
], | |
capabilityInvocation: [verificationMethodId], | |
}; | |
// 5. Build and sign create message | |
const createPayload = { | |
version: "2.0", | |
operation: "create", | |
did, | |
didDocument, | |
}; | |
const signer = new InternalEd25519Signer(privateKey); | |
const signedCreatePayload = await signer.createProof(createPayload, { | |
proofPurpose: "capabilityInvocation", | |
verificationMethod: verificationMethodId, | |
}); | |
// 6. Publish to topic | |
await new TopicMessageSubmitTransaction() | |
.setTopicId(topicId) | |
.setMessage(Buffer.from(JSON.stringify(signedCreatePayload))) | |
.execute(client); | |
console.log("\n--- DID Created ---"); | |
console.log("DID:", did); | |
console.log(`Topic: ${topicId}`); | |
return { | |
did, | |
topicId, | |
privateKey, | |
publicKey, | |
verificationMethodId, | |
didDocument, | |
}; | |
} | |
// Publish an update message (e.g., add a service) | |
async function updateDidAndPublish({ | |
client, | |
did, | |
topicId, | |
privateKey, | |
verificationMethodId, | |
didDocument, | |
updateFn, | |
}) { | |
// Apply update | |
const updatedDidDocument = updateFn({ ...didDocument }); | |
const updatePayload = { | |
version: "2.0", | |
operation: "update", | |
did, | |
didDocument: updatedDidDocument, | |
}; | |
const signer = new InternalEd25519Signer(privateKey); | |
const signedUpdatePayload = await signer.createProof(updatePayload, { | |
proofPurpose: "capabilityInvocation", | |
verificationMethod: verificationMethodId, | |
}); | |
await new TopicMessageSubmitTransaction() | |
.setTopicId(topicId) | |
.setMessage(Buffer.from(JSON.stringify(signedUpdatePayload))) | |
.execute(client); | |
return updatedDidDocument; | |
} | |
// Resolve: fetch all messages, reconstruct latest valid DID document | |
async function resolveDid({ did }) { | |
const topicId = did.split("_")[1]; | |
const topicReader = new TopicReaderHederaClient(); | |
// wait for 10 seconds | |
await new Promise((resolve) => setTimeout(resolve, 10000)); | |
const messages = await topicReader.fetchAllToDate(topicId, "testnet"); | |
let previousDidDocument: JsonLdDIDDocument | null = null; | |
let currentDidDocument: JsonLdDIDDocument | null = null; | |
for (const message of messages) { | |
const parsed = JSON.parse(message); | |
const { version, operation, did, didDocument, proof } = parsed; | |
if (version !== "2.0" || !proof) continue; | |
let verificationMethodEntry: VerificationMethod | null = null; | |
let publicKeyMultibase: string | null = null; | |
// For 'create', use the verification method in the current didDocument | |
// For 'update', use the verification method in the previous didDocument | |
let docToCheck = operation === "create" ? didDocument : previousDidDocument; | |
if (!docToCheck) throw new Error("No document to check"); | |
// console.log(inspect(docToCheck, { depth: null })); | |
// Find the verification method entry | |
if ( | |
docToCheck.verificationMethod && | |
Array.isArray(docToCheck.verificationMethod) | |
) { | |
verificationMethodEntry = docToCheck.verificationMethod.find( | |
(vm: VerificationMethod) => { | |
return vm.id === proof.verificationMethod; | |
} | |
); | |
} | |
if (!verificationMethodEntry) | |
throw new Error("No verification method entry"); | |
if ( | |
verificationMethodEntry.type === "Ed25519VerificationKey2020" && | |
verificationMethodEntry.publicKeyMultibase | |
) { | |
publicKeyMultibase = verificationMethodEntry.publicKeyMultibase; | |
} else { | |
throw new Error("Invalid verification method"); | |
} | |
// Build the verifier | |
const publicKey = | |
KeysUtility.fromMultibase(publicKeyMultibase).toDerString(); | |
let verifier = new InternalEd25519Verifier(publicKey); | |
// Verify the proof | |
const { verified, verifiedDocument } = await verifier.verifyProof(parsed); | |
if (!verified) throw new Error("Proof verification failed"); | |
console.log(inspect(verifiedDocument, { depth: null })); | |
// If valid, update the current state | |
previousDidDocument = | |
verifiedDocument.didDocument as unknown as JsonLdDIDDocument; | |
currentDidDocument = didDocument; | |
} | |
return currentDidDocument; | |
} | |
// Main flow | |
(async () => { | |
const client = Client.forTestnet().setOperator( | |
"", | |
"" | |
); | |
// 1. Create DID | |
const { did, topicId, privateKey, verificationMethodId, didDocument } = | |
await createDidAndPublish(client); | |
const resolvedDidDocument0 = await resolveDid({ did }); | |
console.log(inspect(resolvedDidDocument0, { depth: null })); | |
const privateKey2 = PrivateKey.generateED25519(); | |
const publicKey2 = privateKey2.publicKey; | |
const pubKeyBytes2 = publicKey2.toBytesRaw(); | |
const publicKeyMultibase2 = KeysUtility.fromBytes(pubKeyBytes2).toMultibase(); | |
// 2. First update: add a service | |
const updatedDidDocument1 = await updateDidAndPublish({ | |
client, | |
did, | |
topicId, | |
privateKey, | |
verificationMethodId, | |
didDocument, | |
updateFn: (doc) => { | |
doc.verificationMethod.push({ | |
id: `${did}#key-2`, | |
type: "Ed25519VerificationKey2020", | |
controller: did, | |
publicKeyMultibase: publicKeyMultibase2, | |
}); | |
return doc; | |
}, | |
}); | |
const resolvedDidDocument1 = await resolveDid({ did }); | |
console.log(inspect(resolvedDidDocument1, { depth: null })); | |
// 3. Second update: add another service | |
await updateDidAndPublish({ | |
client, | |
did, | |
topicId, | |
privateKey: privateKey2, | |
verificationMethodId: `${did}#key-2`, | |
didDocument: updatedDidDocument1, | |
updateFn: (doc) => { | |
doc.service = [ | |
{ | |
id: `${did}#service-2`, | |
type: "LinkedDomains", | |
serviceEndpoint: "https://example.com/did", | |
}, | |
]; | |
return doc; | |
}, | |
}); | |
const resolvedDidDocument2 = await resolveDid({ did }); | |
console.log(inspect(resolvedDidDocument2, { depth: null })); | |
client.close(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment