Kibitz

← The Kibitz Engine · deep dive

Cert-Binding — Serverless, Peer-to-Peer Verified Identity (the "Level 3" scheme)

How Kibitz proves that the party on the other end of this encrypted connection really is who they claim — with no server in the loop and a credential that can't be replayed.

This is the distinctive piece of Kibitz's identity layer. The cryptographic ingredients are standard (an RS256-signed OIDC token, SHA-256, the WebRTC DTLS handshake); the composition — stapling the identity token to the live transport certificate and verifying it peer-to-peer — is the part we built. Internally it's the third identity tier, hence L3.

Companion docs: verification.md (all admission methods, §4.1 is the OIDC entry), threat-model.md (what's in/out of scope).


1. The problem it solves

Plain "Sign in with Google" (OIDC) gives you a token that means "Google attests this is [email protected]." That's necessary but, in our setting, not sufficient — for two reasons:

  1. There's no server. A call is peer-to-peer; no Kibitz backend receives the token, validates it, and vouches for the user. Standard OIDC assumes a relying party (a server) does exactly that. We don't have one.
  2. It's a bearer token. Whoever holds the token is "alice." In a call the token is broadcast over the data channel, where any participant could capture it. A captured token presented by someone else would normally be indistinguishable from the real thing.

So the open question is: given a signed "I am alice" token arriving over a peer connection, how does each browser — with no server — know the person operating this specific connection is alice, and not someone replaying her token?

Cert-binding answers it by tying the token to the one thing unique to a live connection: its DTLS certificate fingerprint.


2. The idea in one sentence

At sign-in you put a hash of your connection's cert fingerprint inside the identity token; every peer re-derives that hash from the cert it actually handshook with and checks it matches — so the token is only valid for whoever holds this connection's certificate.

Every WebRTC peer connection is secured by a DTLS certificate. Its fingerprint (a hash of the cert's public key material) uniquely identifies that encrypted channel, and — crucially — each side can read the fingerprint of the certificate the other side presented (RTCDtlsTransport.getRemoteCertificates(), surfaced via safetyCode.ts). That readable, per-connection value is the anchor we bind the identity to.


3. The algorithm

Mint (at sign-in, on the prover's device)

  1. Read your own connection's pinned cert fingerprint fp.

  2. Compute the binding nonce:

    nonce = base64url( SHA-256( canonicalFingerprint(fp) + "|" + roomId ) )
    

    (canonicalFingerprint = trimmed, lowercase colon-hex, so both sides hash identical bytes; roomId salts it — see §5. Implemented in nonceForFingerprint(fp, roomId).)

  3. Run the OIDC sign-in passing that nonce in the provider's standard nonce field. The provider (Google, or our email-code backend) echoes the nonce into the signed token — it can't be altered without breaking the signature.

  4. Broadcast the signed token to peers over the data channel.

The prover never reveals a secret. The nonce isn't secret either — it's a commitment to a cert the prover controls.

Verify (on every other peer's device, and at the admission gate)

For a token received from a peer whose connection presented cert fingerprint remoteFp:

  1. Signature + claims (verifyIdToken): RS256 signature against the provider's published JWKS keys (algorithm pinned, never read from the token header), plus iss / aud / exp and email_verified === true.
  2. Binding (bindingMatches): recompute base64url(SHA-256(canonicalFingerprint(remoteFp) + "|" + roomId)) and require it to equal the token's nonce.
  3. If both pass → trust the identity ({ email, name, … }); else → reject with a reason.

Composed in verifyPeerIdentity (and routed by provider in verifyPeerMulti). No Kibitz server participates — each browser fetches the provider's public keys and checks locally.


4. Why the binding is the point

Without step 2, a valid signature only proves the token is genuine — not who is presenting it. The binding turns the bearer token into a connection-bound one:

So a verified peer isn't "someone who showed a valid alice token" — it's "the entity that, at sign-in, controlled the private key of the certificate securing this exact end-to-end-encrypted channel, and that entity is alice."


5. Room salting

The nonce input includes the room id (… + "|" + roomId). A binding minted in room A therefore won't verify in room B even against the same cert, so a token captured in one room can't be carried into another. Both peers derive the salt from the normalized room id, so two participants whose URLs differ only by casing still agree.


6. Composition with the safety code (SAS)

Kibitz already lets two people compare an emoji safety code derived from their cert fingerprints to rule out a man-in-the-middle (safetyCode.ts). Cert-binding reuses the same fingerprints, so "this is really alice" and "there's no MITM on this channel" collapse into a single guarantee: a passing binding is a passing SAS, checked automatically by the crypto instead of by humans reading emoji aloud.


7. What it does not do (limits)


8. Where to admission, badge, and the roster gate use it

The same check runs at three moments (all client-side, no server):

Moment Caller Effect
Admission (verified-only rooms) the authority's makeGateVerifyverifyPeerMulti a joiner is rostered only if their token verifies + binds; an unverified peer is never added
Per-peer badge + mutual pre-share useCall.getIdentityverifyPeerMulti (poll) drives the ✓ badge and the verified-roster "hold content until everyone's verified" gate
Self-verify useCall.signInIdentityselfVerify lights your own badge only from a real verify, never the raw payload

9. Code map

Piece File
Nonce derivation + binding check src/core/oidcBinding.ts (nonceForFingerprint, bindingMatches, canonicalFingerprint)
OIDC token verify (RS256, alg-pinned, JWKS) src/core/oidcVerify.ts
Compose: signature + email_verified + binding src/core/identity.ts (verifyPeerIdentity, verifyPeerMulti)
Reading the live cert fingerprints src/core/safetyCode.ts
Drivers (admission / badge / self) src/widget/Widget.tsx (makeGateVerify), src/react/useCall.ts (getIdentity, signInIdentity)

Status: built and shipped (the L3 identity work). bindsFingerprint: true on the OIDC and email-code methods.