---
title: "Sign and verify with PGP in C#"
url: https://recursiveai.org/onecrypto/cookbook/pgp-sign-verify/
description: "Sign and verify with PGP (OpenPGP) in C#: detached .sig and cleartext signatures, verified against a public key, with GnuPG and Sequoia checking ours and ours checking theirs. Runnable code with OneCrypto 0.3.0-preview.2."
version: "0.3.0-preview.2"
---

# Sign and verify with PGP in C#

`PgpMessageWriter.SignDetached` writes a detached signature (a `.sig` or `.asc` next to the file) and `SignCleartext` writes the readable `-----BEGIN PGP SIGNED MESSAGE-----` form; `PgpMessageReader.VerifyDetached` and `VerifyCleartext` check them against a certificate (public key). The program below makes a test key on its first run, signs a small file both ways, verifies both, and shows a changed file failing.

```shell
dotnet new console -o PgpSign && cd PgpSign
dotnet add package RecursiveAI.OneCrypto.OpenPgp --version 0.3.0-preview.2
```

OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`:

```csharp
using System.Text;
using RecursiveAI.OneCrypto.OpenPgp;

DateTimeOffset now = DateTimeOffset.UtcNow;
// The key's password: read it from your secret store
byte[] password = Encoding.UTF8.GetBytes("demo password");

// A test key, made on the first run: version 4 Curve25519, a key GnuPG 2.4 can read
if (!File.Exists("alice.key.asc"))
{
    PgpCertificate created = PgpKeyGenerator.Generate(new PgpKeyGenerationOptions
    {
        Now = now,
        UserIds = ["Alice <alice@example.org>"],
        Profile = PgpKeyProfile.Rfc4880Curve25519,
        Password = password,
    });
    // The secret key stays private; the certificate (public key) is what you share
    File.WriteAllBytes("alice.key.asc", created.Export(armor: true));
    File.WriteAllBytes("alice.pub.asc", created.ExportPublic(armor: true));
}
PgpCertificate certificate = PgpCertificate.Parse(File.ReadAllBytes("alice.pub.asc"));
PgpCertificate secretKey = PgpCertificate.Parse(File.ReadAllBytes("alice.key.asc"));

File.WriteAllText("notes.txt", "Release 2.1 is approved for Friday.\n");

// Sign: unlock the signing key, then write a detached and a cleartext signature
PgpKey signingKey = secretKey.FindSigningKey(now) ?? throw new InvalidOperationException("no signing key");
using (PgpSecretKey signer = PgpSecretKey.Unlock(signingKey, [password]))
{
    var options = new PgpWriteOptions { Now = now, Signers = [signer], Armor = true };
    using (FileStream data = File.OpenRead("notes.txt"))
    using (FileStream sig = File.Create("notes.txt.sig"))
        PgpMessageWriter.SignDetached(data, sig, options);

    using (FileStream text = File.OpenRead("notes.txt"))
    using (FileStream signedText = File.Create("notes.txt.asc"))
        PgpMessageWriter.SignCleartext(text, signedText, options);
}
Console.WriteLine("Signed: notes.txt.sig (detached), notes.txt.asc (cleartext)");

// Verify with the certificate alone: our two files, or the files named on the command line
var verify = new PgpReadOptions { Now = now, Certificates = [certificate] };
foreach (string path in args.Length > 0 ? args : ["notes.txt.sig", "notes.txt.asc"])
{
    PgpReadResult result;
    if (File.ReadAllText(path).StartsWith("-----BEGIN PGP SIGNED MESSAGE-----"))
    {
        using FileStream input = File.OpenRead(path);
        result = PgpMessageReader.VerifyCleartext(input, Stream.Null, verify);
    }
    else
    {
        using FileStream data = File.OpenRead("notes.txt");
        using FileStream sig = File.OpenRead(path);
        result = PgpMessageReader.VerifyDetached(data, sig, verify);
    }
    foreach (PgpSignatureResult s in result.Signatures)
        Console.WriteLine($"{path}: {s.Status}, key {Convert.ToHexString(s.Signer?.Fingerprint ?? [])}, {s.CreatedAt:u}");
}

// A changed file no longer matches the detached signature
using (var changed = new MemoryStream(Encoding.UTF8.GetBytes("Release 2.1 is approved for Monday.\n")))
using (FileStream sig = File.OpenRead("notes.txt.sig"))
    Console.WriteLine($"Changed file: {PgpMessageReader.VerifyDetached(changed, sig, verify).Signatures[0].Status}");
```

```text
Signed: notes.txt.sig (detached), notes.txt.asc (cleartext)
notes.txt.sig: Valid, key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274, 2026-11-02 06:53:35Z
notes.txt.asc: Valid, key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274, 2026-11-02 06:53:35Z
Changed file: Invalid
```

A detached signature leaves the file untouched; a cleartext signature wraps the text so it stays readable, which suits emails and release notes. `notes.txt.asc` looks like this:

```text
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Release 2.1 is approved for Friday.
-----BEGIN PGP SIGNATURE-----

wnUEARYKAB0FAmroGCsWIQQ6eH3bqs4+LATms5QTP7ulwAqjEgAKCRATP7ulwAqj
Ej9uAP0U0dVXSGoV9ZK78bS3WF55m0yuTfJbja7Xj1G06PMsCAEAxwgx53wc9V0P
M+Mlu6vC3Xt3BQd5/cIkCkeebiz79AE=
=0thh
-----END PGP SIGNATURE-----
```

`Valid` means three things: the signature matches the data, the key was valid when it signed (bound, not expired, not revoked), and the policy accepts its hash and key size. Otherwise the status says which failed: `Invalid`, `UnknownKey` (no certificate you passed has the key), `KeyNotValid`, `Rejected` or `Unsupported`, with `Reasons`. To sign and encrypt in one message, set both `Signers` and `Recipients` on `PgpMessageWriter.Write`.

## Check it with GnuPG

GnuPG 2.4.8, with only the certificate imported, verifies both signatures. `GNUPGHOME` points gpg at a new, empty home for this terminal session, so nothing here touches your own keyring; the later gpg commands on this page use the same home.

```shell
export GNUPGHOME="$(mktemp -d)"
gpg --batch --import alice.pub.asc
gpg --verify notes.txt.sig notes.txt
gpg --verify - < notes.txt.asc
```

```text
gpg: keybox '/tmp/tmp.lDgjuE16rT/pubring.kbx' created
gpg: /tmp/tmp.lDgjuE16rT/trustdb.gpg: trustdb created
gpg: key B038DBD58D86E274: public key "Alice <alice@example.org>" imported
gpg: Total number processed: 1
gpg:               imported: 1
gpg: Signature made Mon 02 Nov 2026 06:53:35 AM UTC
gpg:                using EDDSA key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274
gpg: Good signature from "Alice <alice@example.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 45D7 DAFC 79B0 B842 B5CC  8535 B038 DBD5 8D86 E274
gpg: Signature made Mon 02 Nov 2026 06:53:35 AM UTC
gpg:                using EDDSA key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274
gpg: Good signature from "Alice <alice@example.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 45D7 DAFC 79B0 B842 B5CC  8535 B038 DBD5 8D86 E274
```

GnuPG’s warning is the honest one, and it applies to the program as well: the signature is good for this certificate, and nothing yet says the certificate is Alice’s. And the other way: with the secret key imported, GnuPG signs the file both ways and the program verifies its signatures.

```shell
gpg --batch --pinentry-mode loopback --passphrase 'demo password' --import alice.key.asc
gpg --batch --pinentry-mode loopback --passphrase 'demo password' --armor \
  --output notes.txt.gpg.sig --detach-sign notes.txt
gpg --batch --pinentry-mode loopback --passphrase 'demo password' \
  --output notes.gpg.asc --clearsign notes.txt
dotnet run -- notes.txt.gpg.sig notes.gpg.asc
```

```text
gpg: key B038DBD58D86E274: "Alice <alice@example.org>" not changed
gpg: key B038DBD58D86E274: secret key imported
gpg: Total number processed: 1
gpg:              unchanged: 1
gpg:       secret keys read: 1
gpg:   secret keys imported: 1
Signed: notes.txt.sig (detached), notes.txt.asc (cleartext)
notes.txt.gpg.sig: Valid, key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274, 2026-11-02 06:53:35Z
notes.gpg.asc: Valid, key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274, 2026-11-02 06:53:36Z
Changed file: Invalid
```

Sequoia’s `sqop` 0.38.0 agrees in both directions:

```shell
printf 'demo password' > password.txt
sqop verify notes.txt.sig alice.pub.asc < notes.txt
sqop sign --with-key-password=password.txt alice.key.asc < notes.txt > notes.txt.sqop.sig
dotnet run -- notes.txt.sqop.sig
```

```text
2026-11-02T06:53:38Z 45D7DAFC79B0B842B5CC8535B038DBD58D86E274 45D7DAFC79B0B842B5CC8535B038DBD58D86E274 mode:binary {"signers":["alice.pub.asc"]}
Signed: notes.txt.sig (detached), notes.txt.asc (cleartext)
notes.txt.sqop.sig: Valid, key 45D7DAFC79B0B842B5CC8535B038DBD58D86E274, 2026-11-02 06:53:38Z
Changed file: Invalid
```

The key is version 4 Curve25519 because GnuPG 2.4 reads neither version 6 keys nor the native Ed25519 algorithm; Sequoia reads all of them. The table is in [Encrypt and decrypt a file with PGP](https://recursiveai.org/onecrypto/cookbook/pgp-encrypt-decrypt-file/#which-keys-work-with-which-tools).

## 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.
- **A valid signature is not an identity.** It proves the holder of the key behind the certificate you passed signed the data. Whether that key is Alice’s is a separate check: compare the fingerprint with her over another channel, or compute it from certifications with the trust models ([OpenPGP trust models](https://recursiveai.org/onecrypto/docs/openpgp-trust/)).
- **Old hashes.** Under the default `CryptoPolicy.Standard`, SHA-1 data signatures made before 2031 verify with a flag in `Policy` (GnuPG accepts them too); pass `CryptoPolicy.Strict` in `Policy` to reject them. SHA-224 and RIPEMD-160 signatures verify but are never written.
- **Not yet:** SLH-DSA signatures. The post-quantum ML-DSA composites (RFC 9980) sign and verify where the platform has ML-DSA.

Every signature version, algorithm and rule: [OpenPGP](https://recursiveai.org/onecrypto/docs/openpgp/). To encrypt instead: [Encrypt and decrypt a file with PGP](https://recursiveai.org/onecrypto/cookbook/pgp-encrypt-decrypt-file/).
