# How a key transparency self-audit can verify the wrong identity

> How a key transparency self-audit can verify the wrong identity, what a Proton bug-bounty report exposed, and what I found in Thelemail.

- Author: [Vlad Gorokhov](https://gorokhov.dev/)
- Published: 2026-08-31
- Updated: 2026-08-31
- Canonical URL: https://gorokhov.dev/posts/key-transparency-self-audit/
- Topics: security, cryptography, email


An encrypted email can arrive in the right inbox after someone else has already read it.

The cryptography is still doing its job. The mistake happens earlier, when the client receives the wrong public key.

I am building [Thelemail](https://thelemail.com), private email hosting for people and small teams using their own domains. Stored mail remains encrypted, and readable private-key material stays on each user's device.

Thelemail has to solve public-key distribution too. A sender needs the correct key for the intended recipient. Give the sender a different one and the message is encrypted correctly for the wrong person.

In June, I came across a [security write-up about a flaw reported through Proton's bug bounty program](https://schaerli.org/weblog/5-proton/). The write-up says Proton responded and paid a bounty. The review was possible because Proton's clients are open source.

I went back to Thelemail and used the report as a checklist.

The report covers Proton's implementation at that time. Proton currently [documents key transparency as a beta feature](https://proton.me/support/key-transparency) that users enable in its web client. I have not verified whether the same flaw is still present.

## The public key comes before encryption

Alice wants to send Bob an encrypted email.

Alice's client asks the provider for the public key belonging to `bob@example.com`. The provider returns Bob's public key, and Alice encrypts the message with it. Bob then uses his private key to decrypt the message.

Encryption only knows which key it was given. It has no way to know whether that key belongs to Bob.

Alice sees nothing wrong when the provider returns an attacker's public key. Her client encrypts the message with that key and sends it. The attacker decrypts it, reads it, then encrypts it again using Bob's genuine public key.

Nothing looks unusual when Bob receives the encrypted message.

```mermaid
sequenceDiagram
    autonumber

    participant Alice as Alice's client
    participant Directory as Key directory
    participant Attacker as Attacker or malicious provider
    participant Bob as Bob's client

    Alice->>Directory: Request key for bob@example.com

    alt Honest response
        Directory-->>Alice: Bob's public key K_B
        Alice->>Bob: Encrypt with K_B and send
        Bob->>Bob: Decrypt with private key SK_B
    else Malicious response
        Directory-->>Alice: Attacker's public key K_A
        Alice->>Attacker: Encrypt with K_A and send
        Attacker->>Attacker: Decrypt with private key SK_A
        Attacker->>Bob: Re-encrypt with K_B and forward
        Bob->>Bob: Decrypt with private key SK_B
    end
```

One way to prevent this is manual fingerprint verification. Alice and Bob compare their public-key fingerprints over another trusted channel and then pin the verified keys.

That works, but I do not expect people to compare fingerprints for every contact. Key transparency automates much of this checking.

A transparency directory records the keys associated with each identity. The client asks for a cryptographic proof that a key appears in the directory.

The client keeps a previous directory checkpoint and checks that the next one extends the same history. Removing or rewriting an old entry should then leave evidence.

The client still has to know which identity to check.

## The self-audit checked the wrong entry

[Proton's key transparency design](https://proton.me/files/proton_keytransparency_whitepaper.pdf) includes a self-audit: the client checks that the directory contains the correct public keys for its own email address. This can expose a malicious key published under that identity.

In the reported implementation, the client asked the backend which email address to audit. Under a malicious-backend threat model, that gives the attacker control over the identity being checked.

Bob's real address is `bob@example.com`, but the backend tells his client to audit `other@example.com`.

The backend places Bob's genuine public keys under `other@example.com`. It then places an attacker's public key under `bob@example.com`.

Bob's client sees his genuine keys under `other@example.com`. The proof checks out, so the self-audit passes. It never looks at `bob@example.com`.

Alice asks for the key belonging to `bob@example.com`. She receives the attacker's key and a valid proof showing that this key is recorded under Bob's real address.

Every proof verifies. Bob's client still audited the wrong identity.

```mermaid
sequenceDiagram
    autonumber

    participant Bob as Bob's client
    participant Backend as Malicious backend
    participant Log as Transparency directory
    participant Alice as Alice's client

    Note over Backend,Log: other@example.com maps to Bob's genuine key K_B<br/>bob@example.com maps to attacker's key K_A

    rect rgb(230, 247, 235)
        Bob->>Backend: Which address should I audit?
        Backend-->>Bob: other@example.com
        Bob->>Log: Prove keys for other@example.com
        Log-->>Bob: K_B with a valid proof
        Bob->>Bob: Self-audit passes
    end

    rect rgb(255, 235, 235)
        Alice->>Backend: Request key for bob@example.com
        Backend-->>Alice: Attacker's key K_A
        Alice->>Log: Verify K_A for bob@example.com
        Log-->>Alice: Valid inclusion proof
        Alice->>Backend: Message encrypted with K_A
        Backend->>Backend: Decrypt using SK_A
        Backend->>Bob: Re-encrypt with K_B and deliver
    end

    Note over Bob,Alice: Every proof can be valid while the clients verify different identities
```

Append-only does not help here. The backend writes valid entries under different identities and never has to rewrite anything or produce a bad proof.

The audit identity must come from somewhere the backend cannot silently change.

## Checking Thelemail against the same problem

Reading the report sent me back to [Thelemail's append-only public-key log](https://thelemail.com/keys).

The log does not store email addresses directly. It maps them to opaque labels derived with a verifiable random function, or VRF.

The client requests an inclusion proof to confirm that a key was recorded under a label. It also checks consistency proofs between log checkpoints. A consistency proof shows that a new version of the log extends the previous version instead of replacing its history.

The [browser client](https://github.com/thelemail/web-client) and [key transparency log server](https://github.com/thelemail/keylog) are open source. Thelemail also publishes an [overview of the components and production revisions](https://thelemail.com/open-source).

I can use these proofs to check inclusion and append-only history. They cannot tell the client which account identity to use for the VRF label.

I have not finished auditing that binding. I need to trace where the client first learns its account and mailbox identities, which parts it stores locally, and whether the backend can later change them without leaving evidence.

The proof can be perfect and the label can still be wrong.

```mermaid
flowchart TD
    Identity["Account or mailbox identity"]

    Binding{"Is the identity bound<br/>independently of the backend?"}

    Wrong["Risk: the client may<br/>verify the wrong label"]

    VRF["Derive opaque VRF label"]
    Log["Append-only public-key log"]

    Inclusion["Verify inclusion proof"]
    Consistency["Verify consistency proof"]

    Result{"Verification result"}

    Verified["Verified"]
    Unavailable["Verification unavailable"]
    Failed["Verification failed"]

    Checkpoint["Signed log checkpoint"]
    Witnesses["Independent witnesses"]
    Threshold{"Witness threshold met?"}
    SplitView["Split views are not<br/>automatically detected"]

    Identity --> Binding
    Binding -->|No or unknown| Wrong
    Binding -->|Yes| VRF

    VRF --> Log

    Log --> Inclusion
    Log --> Consistency

    Inclusion --> Result
    Consistency --> Result

    Result -->|Proofs valid| Verified
    Result -->|Proof cannot be fetched| Unavailable
    Result -->|Invalid or inconsistent proof| Failed

    Log --> Checkpoint
    Checkpoint --> Witnesses
    Witnesses --> Threshold
    Threshold -->|Current threshold: 0| SplitView
```

I kept these problems on separate branches in the diagram. A missing proof is different from a broken proof, and neither one tells me whether the client started with the correct identity. Split views need one more thing: someone independent has to compare signed log histories.

## A failed proof currently does not block a message

I run Thelemail's transparency checks in [monitor mode today](https://thelemail.com/security). The client verifies proofs and records failures, but still allows the message through. That helps while I test the implementation. It is not the final enforcement policy.

Some failures are ordinary outages: the client cannot reach the log service. Others are much harder to dismiss: a proof is cryptographically invalid or conflicts with a checkpoint the client already accepted.

The second case may mean that the log is lying, corrupted, or under attack. I do not want both cases hidden behind the same warning.

I could block every message when proof retrieval fails, but then a log outage stops mail. A warning that users can ignore is not much better for invalid proofs because most people will click through something they do not understand.

I have not settled the final behaviour yet.

I am leaning toward three states: verified, unavailable, and failed. A dangerous result should be difficult to ignore. A temporary outage should not stop email completely.

## An append-only log can still show two histories

[Thelemail has no independent witnesses yet](https://thelemail.com/keys). The threshold is currently zero.

A malicious log can give Alice one history and Bob another. Each history grows append-only, and both clients receive valid inclusion and consistency proofs. They still end up seeing different histories.

```mermaid
sequenceDiagram
    autonumber

    participant Alice as Alice's client
    participant Log as Malicious log
    participant Bob as Bob's client
    participant Witness as Independent witness

    Log-->>Alice: Signed checkpoint C_A
    Log-->>Bob: Signed checkpoint C_B

    Alice->>Log: Request consistency proof from previous checkpoint
    Log-->>Alice: Valid proof within history A

    Bob->>Log: Request consistency proof from previous checkpoint
    Log-->>Bob: Valid proof within history B

    Note over Alice,Bob: Alice and Bob each see a consistent history<br/>but C_A and C_B are incompatible

    Alice-->>Witness: Publish or report C_A
    Bob-->>Witness: Publish or report C_B

    Witness->>Witness: Compare signed checkpoints
    Witness-->>Alice: Conflicting histories detected
    Witness-->>Bob: Conflicting histories detected
```

An independent witness has one job here: compare signed checkpoints seen by different clients. Two incompatible checkpoints are evidence that the log misbehaved.

Running several witnesses myself would make checkpoint collection more robust, but it would not add independent trust. I still need to find other people willing to run them, and I have not worked out how a small service should start using witnesses without pretending they are independent. If compromising Thelemail also compromises a witness operator, that witness adds very little.

## What I am taking from this review

After this review, I still think Thelemail's log and proof checks are worth keeping. They verify inclusion and append-only history, but I am not treating the work as complete.

The next checks are more specific:

1. Trace the account and mailbox identity from registration through self-audit.
2. Confirm that the backend cannot silently replace the identity used to derive the log label.
3. Separate unavailable verification from invalid verification.
4. Decide when the client must block sending.
5. Add independent witnesses and require a non-zero threshold.

I am especially interested in how other small systems have handled the last two points.

How do you enforce proof failures without allowing a temporary log outage to stop the whole product?

How do you bootstrap independent witnesses before there are outside organizations willing to run them?

