#attestation #privacy #ed25519

ironseal

Lightweight identity-free attestation protocol

6 releases

Uses new Rust 2024

0.3.3 Apr 7, 2026
0.3.2 Apr 7, 2026
0.3.0 Mar 27, 2026
0.2.0 Mar 24, 2026
0.1.0 Mar 8, 2026

#1077 in Cryptography

Download history 14/week @ 2026-03-26 73/week @ 2026-04-02 8/week @ 2026-04-09 48/week @ 2026-04-30

56 downloads per month

MPL-2.0 license

2MB
2K SLoC

Ironseal

Sign once, trusted forever — anonymous attestation over HTTPS

Ironseal is a lightweight, identity-free attestation protocol. A client proves ownership of a cryptographic keypair to a server — no email address, no username, no password. The keypair is the identity.

Ironseal logo

Ironseal logo was generated using Pixella


Table of Contents


Overview

Most authentication systems tie identity to a human-readable attribute — an email, a username, a phone number. Ironseal does not. A client generates an ed25519 keypair locally, registers the public key with the server, and completes a challenge–response handshake to prove they hold the corresponding private key. After that, every submission is signed and verifiable without any further interaction.

There is no account to create. There is no password to forget. There is no email to leak.


Protocol Flow

Client                                        Server
  │                                              │
  │  1. Generate ed25519 keypair locally         │
  │     (private key never leaves the device)    │
  │                                              │
  │──── POST /register  { public_key } ─────────▶│
  │                                              │  2. Validate key format (32 bytes, Base64)
  │                                              │  3. Check key is not already registered
  │                                              │  4. Generate random 32-byte challenge string
  │                                              │  5. Store challenge with 15-minute TTL
  │                                              │
  │◀─── 200  { challenge_id,               ──────│
  │           challenge_string,                  │
  │           challenge_url,   (optional)        │
  │           expires_at }                       │
  │                                              │
  │  6. Sign challenge_string with private key   │
  │                                              │
  │──── POST /verify  { challenge_id,  ─────────▶│
  │                     signature }              │
  │                                              │  7. Atomically retrieve & remove challenge
  │                                              │     (prevents replay — one use only)
  │                                              │  8. Check challenge has not expired
  │                                              │  9. Verify ed25519 signature
  │                                              │  10. Store public key as verified
  │                                              │
  │◀─── 200  { status: "verified",  ─────────────│
  │           public_key,                        │
  │           message }                          │
  │                                              │
  │  — Future submissions —                      │
  │                                              │
  │──── POST /submit  { payload,       ─────────▶│
  │                     signature }              │
  │                                              │  11. Look up public key in verified store
  │                                              │  12. Verify signature over payload
  │                                              │  13. Accept or reject submission

Step-by-step

Step 1 — Keypair generation The client generates a fresh ed25519 keypair using the OS cryptographic random number generator (OsRng). The private key is encrypted with AES-256-GCM and stored locally. It never leaves the device in any form.

Steps 2–5 — Registration The server receives only the Base64-encoded public key. It validates the format, rejects duplicates, then generates a cryptographically random 32-byte challenge string (hex-encoded) and stores it with a 15-minute expiry window.

Step 6 — Signing The client signs the challenge_string bytes using its ed25519 private key and sends back the Base64-encoded signature alongside the challenge_id.

Steps 7–10 — Atomic verification The server removes the challenge from storage in the same lock operation as retrieval — preventing any race condition where two concurrent requests could both succeed with the same challenge. It then checks expiry, verifies the ed25519 signature, and promotes the public key to the verified store.

Steps 11–13 — Ongoing use All future payloads are accompanied by a signature over the payload bytes. The server verifies the signature against the stored public key. No session token, no cookie, no bearer token.


Security Guarantees

Challenge replay protection

Challenges are removed atomically when retrieved for verification. A challenge can be used exactly once. A second request with the same challenge_id — even with a valid signature — receives ChallengeNotFound.

Challenges expire

Every challenge has a hard 15-minute TTL. Expired challenges are rejected and evicted from memory. A background cleanup task purges them periodically even if never presented for verification.

Signatures are bound to the challenge string

The client signs the raw bytes of challenge_string. An attacker who intercepts a challenge response cannot forge a valid signature without the private key.

Private key never leaves the device

The signing key is used in memory and immediately zeroed after use (via the zeroize crate). It is never serialised, logged, or transmitted. The on-disk representation is AES-256-GCM encrypted under an Argon2id–derived key.

Key storage is hardened at rest

The on-disk keypair file uses:

  • Argon2id key derivation from a user passphrase (unique random salt per key)
  • AES-256-GCM authenticated encryption (integrity + confidentiality)
  • Atomic writes — key file is written via a temp-file-then-rename sequence so a crash mid-write cannot produce a corrupt or partial file
  • Restrictive file permissions0600 on Unix (owner read/write only); Windows ACL hardening strips inheritable access

No brute-force surface on the server

The server stores only public keys and challenge strings. Neither is secret. There are no passwords or password hashes to attack.


Privacy Guarantees

No personally identifiable information required

Registration requires only a public key — 32 bytes of opaque data. No name, email, IP-to-identity mapping, or any human-readable attribute is collected by the protocol itself.

Pseudonymity by default

A keypair is a pseudonym. The same client can generate multiple keypairs to produce multiple independent identities with no linkage between them. Key rotation (delete old keypair, generate new one, re-register) severs the pseudonymous identity chain entirely.

The server cannot identify who owns a key

The server knows a public key has been verified. It does not know, and has no way to learn, which human being controls it. Attribution requires external information not present in the protocol.

Signatures do not leak private key material

Ed25519 is a deterministic signature scheme. Signatures produced over different messages are unlinkable to each other beyond confirming they share the same key. They reveal nothing about the private key.


Threat Model

TBD

Key Storage

Keys are stored in ~/.config/ironseal/seal.json. The file format is:

{
  "argon2_salt": "<Base64>",
  "nonce":       "<Base64, 12 bytes>",
  "encrypted_private_key": "<Base64, AES-256-GCM ciphertext>",
  "public_key":  "<Base64, 32 bytes>",
  "created_at":  1234567890
}

The public key is stored in plaintext for fast access (e.g. to prepare a RegistrationRequest) without requiring the passphrase. The private key is never stored in plaintext.


Cryptographic Primitives

Primitive Usage Crate
ed25519 Challenge signing, payload signing ed25519-dalek
AES-256-GCM Private key encryption at rest aes-gcm
Argon2id Passphrase → encryption key derivation argon2
OsRng All randomness (keys, challenges, nonces, salts) rand_core
zeroize Zeroing private key bytes after use zeroize

Getting Started

Full Integration Example

See the entire protocol in action with a single command:

cargo run --example integration --features "client,server"

This runs an end-to-end test showing:

  • Keypair generation
  • Registration request
  • Challenge signing
  • Signature verification
  • Message signing and verification

Client Example

Explore the client-side API:

cargo run --example client --features client

This demonstrates:

  • Generating and storing a keypair in ~/.config/ironseal/
  • Unlocking the keypair with a passphrase
  • Preparing registration requests
  • Signing challenges and messages

Note: This creates a real keypair. Clean up with:

rm -rf ~/.config/ironseal/

Server Example (SQLite)

Run the SQLite server backend:

# Generate a secure database encryption key
DATABASE_KEY=$(openssl rand -hex 32) cargo run --example server_sqlite --features server

This demonstrates:

  • Opening an encrypted SQLite database (using SQLCipher)
  • Handling registration requests
  • Generating challenges
  • Verifying signatures
  • Listing verified keys

Note: This creates example_attestations.db. Clean up with:

rm example_attestations.db

Server Example (MariaDB/MySQL)

For production deployments with MariaDB or MySQL:

# Prerequisites: MariaDB/MySQL server running with database created
# CREATE DATABASE ironseal;

DATABASE_URL="mysql://user:password@localhost/ironseal" \
  cargo run --example server_mysql --features "server,mariadb"

Using Ironseal in Your Project

Client Application

Add to your Cargo.toml:

[dependencies]
ironseal = { version = "0.1.0", features = ["client"] }
tokio = { version = "1", features = ["full"] }

Basic usage:

use ironseal::EncryptedClient;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let encrypted_client = EncryptedClient::new()?;

    // Generate keypair (first time only)
    let public_key = encrypted_client.generate_keypair("passphrase")?;

    // Unlock and use
    let client = encrypted_client.unlock("passphrase")?;
    let signature = client.sign_challenge("challenge_string")?;

    Ok(())
}

See examples/client.rs for the complete flow.

Optional: Enable the keychain feature to store passphrases in the system keychain (Linux only):

ironseal = { version = "0.1.0", features = ["client", "keychain"] }

Server Application

Add to your Cargo.toml:

[dependencies]
ironseal = { version = "0.1.0", features = ["server"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "sqlite"] }

Basic usage:

use ironseal::AttestationService;
use std::env;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    env::set_var("DATABASE_KEY", "64-char-hex-key");
    let service = AttestationService::open_sqlite("attestations.db").await?;

    // Handle registration
    let challenge = service.start_registration(registration_req).await?;

    // Verify signature
    let result = service.verify_challenge(verification_req).await?;

    Ok(())
}

See examples/server_sqlite.rs for the complete flow.

For MariaDB/MySQL:

ironseal = { version = "0.1.0", features = ["server", "mariadb"] }
let service = AttestationService::open_mysql("mysql://user:pass@host/db").await?;

See examples/server_mysql.rs for details.


Hardening with Rate Limits

Ironseal's AttestationService exposes two rate-limiting primitives that integrators are expected to call before touching protocol state:

// Limit how many registration attempts a single IP can make per day.
service.check_and_increment_ip_limit(ip, "register", 10).await?;

// Limit how many submissions a verified key can make per day.
service.check_and_increment_key_limit(key_b64, "submit", 100).await?;

Both methods are atomic check-and-increment operations wrapped in a database transaction: the count is read, compared against the caller-supplied limit, and incremented in one committed unit. A request that would push the count over the limit is rejected before any protocol state changes. Counters are keyed on (identifier, bucket, day) — the bucket string lets you enforce independent quotas for different operations (e.g. "register" vs. "verify" vs. "submit") on the same IP or key without them interfering with each other. Stale rows (yesterday and older) are purged automatically by cleanup_expired.

Why not netfilter (iptables / nftables)?

A natural first instinct is to use the kernel's packet-filtering layer — it is fast, sits before any userspace code runs, and requires no changes to the application. For this protocol, kernel-level filtering cannot provide the guarantees that matter:

Key-based limits are impossible in the kernel. The most important throttle in Ironseal is per public key, not per IP. A public key is a 32-byte value inside a JSON body carried over an encrypted TLS connection. netfilter sees only IP headers and TCP segments; it has no access to application-layer content, and even if TLS were terminated at the kernel boundary, parsing JSON and extracting a Base64 field is not something nftables was designed to do.

IP-based limits are too coarse for this use case. Ironseal clients are often embedded systems or mobile devices sitting behind corporate NAT, a shared mobile carrier NAT, or a VPN exit node. Hundreds of distinct clients may share a single egress IP. A hard connection-rate limit at the firewall would block legitimate clients wholesale the moment any one of them is misbehaving. The application-layer limit here is per-IP-per-bucket-per-day, which gives operators a sensible daily envelope without disrupting unrelated clients at the same address.

Limits need to survive across restarts and scale across instances. netfilter state lives in the kernel of a single host and is lost on reboot or failover. Because Ironseal's counters are stored in the same database that holds challenges and verified keys, they are durable and — when using MariaDB/MySQL — naturally shared across all server instances behind a load balancer with no additional coordination.

Application context is required to pick the right limit. The bucket parameter lets the integrator apply different thresholds to different operations: a registration attempt is far more expensive (generates a challenge, writes to the database) than a signature verification. netfilter has no concept of "this packet is a registration request" versus "this packet is a submission"; the application does.

In short, netfilter is the right tool for dropping obviously malicious traffic (port scans, SYN floods, known-bad IP ranges) and should still be used as a first line of defence. Ironseal's application-level limits handle the semantically richer constraints that the kernel cannot express.


Known Limitations

TBD

Contributing

Contributions are welcome. Please open an issue before starting significant work so the approach can be discussed first.

When submitting a pull request:

  • All existing tests must pass
  • New behaviour must be covered by tests
  • Security-relevant changes should include a brief explanation of the threat model impact in the PR description

License

Ironseal is licensed under the Mozilla Public License 2.0.

MPL-2.0 is a file-level copyleft licence. You may use Ironseal in proprietary projects, but any modifications to Ironseal's own source files must be made available under the same licence. Larger works combining Ironseal with other code may be distributed under different terms.

Dependencies

~44–66MB
~1M SLoC