Certificate & KYC Sharing
By AnchorFactory ·
Keeta stores identity certificates on-chain as X.509 certificates with hash commitments. Users verify once (through a KYC anchor like Footprint) and can then share their proven identity with any dApp — no repeated verification needed.
This recipe shows how to integrate KYC certificate sharing into your application.
How It Works
Hash Commitments (Why It's Trustworthy)
Each identity attribute is stored on-chain as hash(value + salt). The hash is public, but the value is hidden.
When a user shares their data with your dApp:
The wallet reveals the
value + saltfor each attributeYour backend recomputes
hash(value + salt)If it matches the on-chain commitment, the value is provably authentic — it came from the KYC issuer's certificate, not fabricated by the user
No trust in the user required. No trust in the wallet software. The cryptographic proof is self-verifying.
The Full Flow
┌─────────────────────────────────────────────────┐
│ Does the user have an on-chain certificate? │
│ │
│ YES ──► Share flow (one click) │
│ 1. Wallet decrypts certificate │
│ 2. Creates sharable with proofs │
│ 3. Sends to your backend │
│ 4. Backend verifies proofs │
│ │
│ NO ──► Get Verified flow │
│ 1. Fetch supported countries │
│ 2. User picks country │
│ 3. Redirect to KYC provider │
│ 4. Certificate published on-chain │
│ 5. Auto-trigger share flow │
└─────────────────────────────────────────────────┘Frontend: Sharing Existing Certificates
If the user's wallet already has a KYC certificate on-chain, sharing is a single call. The wallet extension handles decryption, proof generation, and transport encryption.
Step 1: Request the Certificate
// Call the wallet extension's KYC sharing method
const result = await window.keeta.shareKYC();
if (!result.hasCertificate) {
// No certificate on-chain — start the Get Verified flow (see below)
startVerificationFlow(result.address, result.network);
return;
}
// Certificate found — send to your backend
const { address, network, container, transportSeed } = result;The extension returns:
Field | Type | Description |
|---|---|---|
| boolean | Whether a certificate was found on-chain |
| string | User's wallet address ( |
| string |
|
| string | PEM-encoded sharable (only if |
| string | Hex-encoded ephemeral key for decryption (only if |
Step 2: Verify Wallet Ownership
Before accepting KYC data, prove the sender owns the wallet address by requesting a signature:
// Build a predictable message with your app's prefix
const prefix = "MY_DAPP_KYC_";
const message = prefix + address;
// Ask the wallet to sign it
const { signature } = await window.keeta.signMessage(message);
// Send everything to your backend
await fetch("/api/kyc/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
address,
message,
signature,
network,
container,
transportSeed,
}),
});Step 3: Check Existing Status
Before showing the share UI, check if the user has already shared their KYC with your app:
async function checkKycStatus(address) {
const message = "MY_DAPP_KYC_" + address;
const { signature } = await window.keeta.signMessage(message);
const res = await fetch("/api/kyc/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, message, signature, network }),
});
const data = await res.json();
if (data.shared) {
// Already shared — show status and offer revocation
showAlreadyShared(data.sharedAt, data.issuer);
} else {
// Not shared — show the share consent screen
showSharePrompt();
}
}Frontend: Get Verified Flow (No Certificate)
When a user doesn't have a certificate, guide them through verification with a KYC anchor.
Step 1: Fetch Supported Countries
// Check if the wallet extension supports in-app verification
if (typeof window.keeta.getKYCCountries !== "function") {
// Older extension — redirect to external verification page
window.open("https://wallet.keeta.com/certificate", "_blank");
return;
}
const { countries } = await window.keeta.getKYCCountries();
// countries: ["US", "GB", "DE", "FR", ...] — ISO 3166-1 alpha-2 codesStep 2: Start Verification
// User selects their country from the list
const selectedCountry = "US";
const { verificationId, webURL } = await window.keeta.startKYCVerification([selectedCountry]);
// Open the KYC provider's verification page in a new tab
window.open(webURL, "_blank");The KYC provider (e.g., Footprint) handles the actual identity verification — document scanning, selfie, liveness check, etc.
Step 3: Poll for Completion
After the user completes verification in the external tab, poll for the certificate:
let pollTimer = null;
let attempts = 0;
const MAX_ATTEMPTS = 120; // 6 minutes at 3-second intervals
function startPolling() {
pollTimer = setInterval(async () => {
attempts++;
if (attempts > MAX_ATTEMPTS) {
clearInterval(pollTimer);
showError("Verification timed out. Please try again.");
return;
}
try {
const status = await window.keeta.checkKYCVerification();
if (status.status === "completed") {
clearInterval(pollTimer);
// Certificate is now on-chain — trigger the share flow automatically
await handleShare();
}
// status === "pending" — keep polling
} catch (err) {
clearInterval(pollTimer);
// "No active verification found" = extension service worker restarted
showError("Verification session lost. Please restart the process.");
}
}, 3000);
}
// Clean up on modal close or component unmount
function cleanup() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}Step 4: Auto-Share After Verification
Once the certificate is published on-chain, immediately trigger the share flow so the user doesn't have to click again:
async function handleShare() {
const result = await window.keeta.shareKYC();
if (!result.hasCertificate) {
showError("Certificate not found after verification.");
return;
}
const message = "MY_DAPP_KYC_" + result.address;
const { signature } = await window.keeta.signMessage(message);
await fetch("/api/kyc/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
address: result.address,
message,
signature,
network: result.network,
container: result.container,
transportSeed: result.transportSeed,
}),
});
showSuccess();
}Backend: Processing Shared KYC Data
Decoding the Sharable Container
The container is a PEM-encoded blob encrypted with an ephemeral transport key. Your backend recreates the transport account and imports the sharable to read proven values.
const { Certificates } = await import("@keetanetwork/anchor/lib");
const { SharableCertificateAttributes } = Certificates;
const KeetaNet = require("@keetanetwork/keetanet-client");
async function decodeSharedKYC(containerPem, transportSeedHex) {
// Recreate the ephemeral transport account from the seed
const transportSeed = Buffer.from(transportSeedHex, "hex");
const transportAccount = KeetaNet.lib.Account.fromSeed(transportSeed, 0);
// Import the sharable container
const sharable = await SharableCertificateAttributes.import(containerPem, {
principal: transportAccount,
});
// Read proven attributes — each value is cryptographically verified
const fullName = await sharable.getAttribute("fullName");
const email = await sharable.getAttribute("email");
const dateOfBirth = await sharable.getAttribute("dateOfBirth");
const address = await sharable.getAttribute("address");
// Optional attributes (may be null)
const nationality = await sharable.getAttribute("nationality");
return {
fullName,
email,
dateOfBirth: dateOfBirth?.toISOString(),
country: address?.country,
nationality,
};
}Verifying Wallet Ownership
Validate the signature before processing KYC data:
const KeetaNet = require("@keetanetwork/keetanet-client");
const KYC_PREFIX = "MY_DAPP_KYC_";
function verifyOwnership(address, message, signatureHex) {
// Check the message has the expected prefix
if (!message.startsWith(KYC_PREFIX)) {
throw new Error("Invalid message prefix");
}
// Check the message references the correct address
if (message !== KYC_PREFIX + address) {
throw new Error("Message does not match address");
}
// Verify the cryptographic signature
const account = KeetaNet.lib.Account.fromPublicKeyString(address);
const msgBytes = new TextEncoder().encode(message);
const sigBytes = Buffer.from(signatureHex, "hex");
return account.verify(msgBytes.buffer, sigBytes);
}Storing KYC Data
After decoding and verifying, store what you need. Sensitive PII should be encrypted at rest:
async function handleKycShare(req) {
const { address, message, signature, network, container, transportSeed } = req.body;
// 1. Verify wallet ownership
const ownershipValid = verifyOwnership(address, message, signature);
if (!ownershipValid) throw new Error("Invalid wallet signature");
// 2. Decode the sharable container
const kycData = await decodeSharedKYC(container, transportSeed);
// 3. Store the proven values (encrypt PII before writing to database)
await db.collection("kyc").doc(address).set({
...kycData,
verified: true,
sharedAt: new Date(),
network,
});
// 4. Discard the container — never store the PEM blob
return { ok: true };
}Revocation Endpoint
Let users revoke their shared KYC data:
async function handleKycRevoke(req) {
const { address, message, signature, network } = req.body;
// Verify wallet ownership
const valid = verifyOwnership(address, message, signature);
if (!valid) throw new Error("Invalid wallet signature");
// Delete the stored KYC data
await db.collection("kyc").doc(address).delete();
return { ok: true };
}Re-Verification
Certificates expire. Periodically check that a user's on-chain certificate is still valid:
async function verifyCertificateOnChain(address, network) {
const account = KeetaNet.lib.Account.fromPublicKeyString(address);
const client = await KeetaNet.UserClient.fromNetwork(network, null, { account });
const state = await client.getAccountState(account);
if (!state.certificates || state.certificates.length === 0) {
return { valid: false, reason: "No certificate on-chain" };
}
// Sort by date, take newest
const cert = state.certificates.sort((a, b) => b.issuedAt - a.issuedAt)[0];
return {
valid: cert.validUntil > new Date(),
issuer: cert.issuer,
issuedAt: cert.issuedAt,
validUntil: cert.validUntil,
};
}Available Attributes
Core Attributes (always present)
Attribute | SDK Name | Type | Notes |
|---|---|---|---|
Full Name |
| string | |
| string | ||
Date of Birth |
| Date | Serialize to ISO string |
Address |
| object | Contains |
Optional Attributes
Attribute | SDK Name | Type | Notes |
|---|---|---|---|
Nationality |
| string | |
Passport |
| object | Metadata only — no images |
ID Card |
| object | Metadata only — no images |
Driver's License |
| object | Metadata only — no images |
Document objects contain metadata fields: documentNumber, fullName, firstName, lastName, dateOfBirth, nationality, issuingCountry, expiresAt, issuedAt. Images (front, back, selfie) are never included.
UI State Machine
A typical KYC sharing UI has these states:
┌──────────┐
│ check │─── already shared? ──► "Already Shared" (show status, offer revoke)
│ status │
└────┬─────┘
│ not shared
▼
┌──────────┐
│ consent │─── user clicks "Share" ──► processing ──► success
│ screen │
└────┬─────┘
│ no certificate
▼
┌──────────┐
│ no cert │─── "Get Verified" ──► country select ──► redirect to KYC provider
│ │ │
└──────────┘ polling ◄────── user returns
│
completed ──► auto-share ──► successImplement this as a state machine — not a chain of if/else — to keep the logic clean and handle edge cases like modal dismissal during polling.
Security Properties
Property | How |
|---|---|
Proof of authenticity | Values verified via hash commitments against on-chain certificate |
No user input | All data extracted from the certificate — nothing typed by the user |
Ephemeral transport key | Random keypair per share, discarded after decoding |
Wallet ownership | Verified via signed message before accepting data |
Selective disclosure | User chooses which attributes to share |
Revocable | User can revoke shared data at any time |
Container not stored | Only extracted values persisted; PEM blob discarded |
Next Steps
Wallet Signatures — deep dive into the signature verification pattern
Webapp Integration — wallet extension integration patterns
SDK Overview — the anchor SDK's certificate APIs
Common Pitfalls — ESM import issues with the anchor SDK