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:

  1. The wallet reveals the value + salt for each attribute

  2. Your backend recomputes hash(value + salt)

  3. 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

hasCertificate

boolean

Whether a certificate was found on-chain

address

string

User's wallet address (keeta_a...)

network

string

"main" or "test"

container

string

PEM-encoded sharable (only if hasCertificate)

transportSeed

string

Hex-encoded ephemeral key for decryption (only if hasCertificate)

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 codes

Step 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

fullName

string

Email

email

string

Date of Birth

dateOfBirth

Date

Serialize to ISO string

Address

address

object

Contains .country field

Optional Attributes

Attribute

SDK Name

Type

Notes

Nationality

nationality

string

Passport

documentPassport

object

Metadata only — no images

ID Card

documentIdCard

object

Metadata only — no images

Driver's License

documentDriversLicense

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 ──► success

Implement 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

More in Identity & Auth · All documentation