Recursive AI

Look up a PGP key by email (WKD) in C#

All onecrypto recipes

A Web Key Directory (WKD) lets a domain publish the OpenPGP key for each of its addresses at a fixed https URL, so a key for [email protected] is fetched from example.org itself. WkdClient builds that URL and fetches it; KeyDiscovery.ForAddress keeps only the certificate that carries the address you asked for. The program below looks up a real, public WKD entry and saves the key it finds.

Terminal
dotnet new console -o WkdLookup && cd WkdLookup
dotnet add package RecursiveAI.OneCrypto.OpenPgp --version 0.3.0-preview.2
dotnet add package RecursiveAI.OneCrypto.Online --version 0.3.0-preview.2

OneCrypto is a preview, so dotnet add package needs --version or --prerelease. RecursiveAI.OneCrypto.Online does the fetching. Put this in Program.cs and run dotnet run, or dotnet run -- name@domain for another address:

C#
using RecursiveAI.OneCrypto.Online;
using RecursiveAI.OneCrypto.OpenPgp;
 
string address = args.Length > 0 ? args[0] : "[email protected]";
DateTimeOffset now = DateTimeOffset.UtcNow;
 
// Ask the address's domain for its Web Key Directory entry, over https
using var transport = new HttpTransport();
var wkd = new WkdClient(new Fetcher(transport));
WkdLookupResult lookup = await wkd.LookupAsync(address);
foreach ((WkdMethod method, KeyFetchResult attempt) in lookup.Attempts)
{
    string why = attempt.IsSuccess ? "" : ", " + attempt.Reason;
    Console.WriteLine($"{method}: {attempt.Outcome}{why}\n  {attempt.Uri}");
}
if (!lookup.IsSuccess)
{
    Console.WriteLine($"No key: {lookup.Reason}");
    return;
}
 
// Keep only certificates that carry this address, with every other User ID removed
DiscoveredKeys found = KeyDiscovery.ForAddress(lookup.Body, address);
Console.WriteLine($"{found.Certificates.Count} certificate(s), " +
    $"{found.Dropped.Count} dropped, " +
    $"{found.ThirdPartySignaturesRemoved} third-party signature(s) removed");
foreach (PgpCertificate cert in found.Certificates)
{
    string userIds = string.Join(", ", cert.UserIds);
    Console.WriteLine($"{Convert.ToHexString(cert.Fingerprint)}  {userIds}");
    foreach (PgpKey key in cert.Keys)
    {
        // Valid: its binding signatures check out; not expired or revoked now.
        // That is not the same as "this key belongs to the person".
        PgpKeyState state = key.StateAt(now);
        string status = state.IsValid ? "valid" : $"not valid ({state.Reason})";
        string role = key.IsPrimary ? "primary" : "subkey ";
        Console.WriteLine($"  {role} {key.Algorithm} {status}, {state.Usage}");
    }
    // A subkey this library can't decode is skipped and listed; the rest is kept
    foreach (var skipped in cert.SkippedSubkeys)
        Console.WriteLine($"  skipped v{skipped.Version} subkey: {skipped.Reason}");
    File.WriteAllBytes($"{address}.asc", cert.ExportPublic(armor: true));
}
Text
Advanced: FetchFailed, the name openpgpkey.kernel.org did not resolve
  https://openpgpkey.kernel.org/.well-known/openpgpkey/kernel.org/hu/pf113mfnx1f3eb1yiwhsipa91xfc7o4x?l=torvalds
Direct: Found
  https://kernel.org/.well-known/openpgpkey/hu/pf113mfnx1f3eb1yiwhsipa91xfc7o4x?l=torvalds
1 certificate(s), 0 dropped, 0 third-party signature(s) removed
ABAF11C65A2970B130ABE3C479BE3E4300411886  Linus Torvalds <[email protected]>
  primary Rsa valid, Certify, Sign
  subkey  Rsa valid, EncryptCommunications, EncryptStorage

This ran on 2 November 2026 against kernel.org’s published directory. WKD has two URL forms. The client always tries the “advanced” one on the openpgpkey. subdomain first, and falls back to the “direct” one on the domain itself only when that name doesn’t resolve, as happened here. A server that is down or answers 404 counts as an answer, as the draft requires. The path is the SHA-1 of the lower-cased local part in z-base-32. An address with no key ends like this:

Text
Advanced: FetchFailed, the name openpgpkey.kernel.org did not resolve
  https://openpgpkey.kernel.org/.well-known/openpgpkey/kernel.org/hu/4kwkd9sts53os8uh3a7xka43cjhwokym?l=no-such-user
Direct: NotFound, the server answered 404
  https://kernel.org/.well-known/openpgpkey/hu/4kwkd9sts53os8uh3a7xka43cjhwokym?l=no-such-user
No key: the server answered 404

A key with a subkey OneCrypto can’t decode still loads: the subkey is skipped and listed in SkippedSubkeys, and the primary key and the other subkeys stay usable. GnuPG’s own address carries a LibrePGP version 5 Kyber subkey:

Terminal
dotnet run -- [email protected]
Text
Advanced: Found
  https://openpgpkey.gnupg.org/.well-known/openpgpkey/gnupg.org/hu/nq6t9teux7edsnwdksswydu4o9i5es3f?l=wk
1 certificate(s), 0 dropped, 1 third-party signature(s) removed
AEA84EDCF01AD86C4701C85C63113AE866587D0A  [email protected]
  primary EdDsaLegacy valid, Certify, Sign
  subkey  EdDsaLegacy valid, Sign
  subkey  Ecdh valid, EncryptCommunications, EncryptStorage
  skipped v5 subkey: The input is truncated: curve oid length (offset 0).

The saved [email protected] is a PgpCertificate like any other, ready for Recipients in Encrypt and decrypt a file with PGP or Certificates in Sign and verify with PGP, once you have decided to rely on it.

What a WKD lookup proves

WKD tells you which key the address’s domain publishes for it, served over https. That is all. OneCrypto checks what it can:

  • every request and redirect stays on https, and the address guard refuses private addresses, because the domain comes from outside;
  • the answer is not trusted to be what was asked for: ForAddress keeps only a certificate with a User ID for this exact address, drops every other User ID, removes third-party certifications and never accepts secret key material;
  • valid means the key’s own binding signatures check out and it is not expired or revoked at the time you pass.

None of that says the key belongs to the person. Whoever runs the domain’s web server decides what it serves. Before you rely on a key fetched by WKD (or from a keyserver), compare its fingerprint with the person through another channel, or authenticate it from certifications by keys you already trust: WebOfTrust and ClassicTrust do that in code, and onecrypto pgp authenticate on the command line (OpenPGP trust models).

Check it with GnuPG

GnuPG 2.4.8, with WKD as its only key source, finds the same key, and reads the file the program saved. --locate-keys imports what it finds, so GNUPGHOME points gpg at a new, empty home for this terminal session and your own keyring is left alone.

Terminal
export GNUPGHOME="$(mktemp -d)"
gpg --batch --locate-keys --auto-key-locate clear,wkd [email protected]
gpg --show-keys [email protected]
Text
gpg: keybox '/tmp/tmp.gOqoWDSOM7/pubring.kbx' created
gpg: /tmp/tmp.gOqoWDSOM7/trustdb.gpg: trustdb created
gpg: key 79BE3E4300411886: public key "Linus Torvalds <[email protected]>" imported
gpg: Total number processed: 1
gpg:               imported: 1
pub   rsa2048 2011-09-20 [SC]
      ABAF11C65A2970B130ABE3C479BE3E4300411886
uid           [ unknown] Linus Torvalds <[email protected]>
sub   rsa2048 2011-09-20 [E]
 
pub   rsa2048 2011-09-20 [SC]
      ABAF11C65A2970B130ABE3C479BE3E4300411886
uid                      Linus Torvalds <[email protected]>
sub   rsa2048 2011-09-20 [E]

GnuPG marks the User ID [ unknown] for the same reason: it fetched the key, and nothing it holds vouches for it yet.

Limits

  • Not audited. OneCrypto (cryptography, not cryptocurrency) has not been independently audited. It is a preview: 0.3.0-preview.2, and the API can change.
  • Skipped subkeys are kept, not used. A subkey that doesn’t decode, or has a key version OneCrypto doesn’t read, is left out of Keys and listed in SkippedSubkeys with the reason; its packets are kept, so the key writes back byte for byte. A primary key that doesn’t decode still fails with OpenPgpParseException.
  • Lookup only. Publishing through the Web Key Directory Update Protocol (sending the key by mail) is not implemented; GetSubmissionAddressAsync only reads the address. Responses are capped at 2 MiB, and nothing is cached unless WkdOptions.UseCache is set.
  • WkdOptions.RequirePolicy checks the domain’s policy file first and stops when it has none; FallbackOnAnyAdvancedFailure tries the direct form after any advanced failure, not only an unresolved name.
  • HkpClient (keyservers, legacy HKP API only) and VksClient (keys.openpgp.org) fetch the same way; pass their responses through KeyDiscovery too. A key from either proves no more than one from WKD.

The WKD, HKP and VKS clients in full: Online clients and transport. Filtering: OpenPGP, Key discovery.