A cryptographic library for ProxyAuth to securely generate and verify authentication tokens.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
vBlackOut 3258f797d5
All checks were successful
audit / RUSTSEC advisories (push) Successful in 1m2s
audit / Token integration (push) Successful in 26s
Optimized ZeroCrypt Performance (x2) cache per thread
2026-11-03 07:31:42 +01:00
.cargo Initial commit 2026-09-06 07:53:30 +02:00
.github/workflows Add ci/CD 2026-09-06 08:58:40 +02:00
examples Remove deplucated version & upgrade rust 1.92 2026-09-07 17:13:53 +02:00
src Optimized ZeroCrypt Performance (x2) cache per thread 2026-11-03 07:31:42 +01:00
tests Add micro caching safe 2026-09-06 12:31:45 +02:00
.gitignore Initial commit 2026-09-06 07:53:30 +02:00
Cargo.toml Optimized ZeroCrypt Performance (x2) cache per thread 2026-11-03 07:31:42 +01:00
LICENSE Initial commit 2026-09-06 07:53:30 +02:00
README.md Optimized ZeroCrypt Performance (x2) cache per thread 2026-11-03 07:31:42 +01:00

zerocrypt

Bearer tokens bound to two independent secrets, so that leaking the file holding one of them is not enough to forge or read a token.

let vault = zerocrypt::Vault::new("operator secret");

let token = vault.issue("alice")?;
let session = vault.verify(&token)?;

assert_eq!(session.user(), "alice");

That is the whole API for the common case.

The idea

The operator secret is the one in your configuration file. The build key is the one that is not — compiled into your binary, installed separately, or held in a secrets manager. Both are needed.

HKDF-Extract computes HMAC-SHA256(key = salt, msg = ikm). This crate puts the build key in the salt position and the operator secret in the input keying material position. Someone who obtains the operator secret alone therefore faces an unknown HMAC key and cannot derive the sealing key at all: they decrypt nothing and forge nothing.

The sealing key feeds XChaCha20-Poly1305. Tokens are encrypted, not merely signed: the username and any attached data are unreadable without the key, and the Poly1305 tag makes any alteration fail. The 24-byte nonce is drawn fresh per token, which is what removes the need for a counter and the state that goes with it.

Surviving restarts, and sharing across instances

Vault::new generates a build key for you. That is fine for a single process, but the key lives only in memory, so a restart invalidates every token that process issued. Persist it to keep tokens working:

use zerocrypt::{BuildKey, Vault};

// Once, ever. Store this somewhere your config file is not.
let key = BuildKey::generate();
let saved = key.to_hex();

// On every start, and on every instance that shares these tokens.
let vault = Vault::with_key("operator secret", BuildKey::from_hex(&saved)?);

Two instances accept each other's tokens exactly when they hold the same operator secret and the same build key.

Deriving the key from several build values

If your build already produces a handful of constants rather than one key, fold them together:

let key = zerocrypt::BuildKey::derive("myapp.build.v1", &[
    env!("CARGO_PKG_VERSION").as_bytes(),
    build_timestamp,
    build_random,
]);

Fields are length-prefixed, so no rearrangement of the parts collides, and context keeps one application's keys unrelated to another's.

The output is only as unpredictable as the input. Hashing low-entropy material to 32 bytes does not create entropy — a build timestamp an attacker can guess contributes almost nothing. Include at least one value from a CSPRNG, or use BuildKey::generate and carry the result.

Lifetimes

An hour by default. Adjust per vault or per token:

let vault = zerocrypt::Vault::builder("operator secret")
    .ttl(86_400)                      // a day
    .build();

let short = vault.issue_for("alice", 60)?;
let exact = vault.issue_until("alice", 4_102_444_800)?;

Carrying your own data

A token can carry an application payload, sealed and authenticated with everything else:

let token = vault.token("alice")
    .ttl(900)
    .data("index=42;tenant=acme")
    .issue()?;

assert_eq!(vault.verify(&token)?.data(), "index=42;tenant=acme");

Any bytes are accepted, separators included — the field is base64url-encoded inside the payload, so you do not have to learn our escaping rules. It is encrypted, not hashed: anyone holding the key can read it.

Rotation

rotate_every derives a fresh sealing key on a fixed period. A token sealed in one period cannot be opened in another, so none outlives roughly two periods whatever expiry it claims — a second, time-based bound underneath the expiry field.

let vault = zerocrypt::Vault::builder("operator secret")
    .ttl(900)              // tokens last fifteen minutes
    .rotate_every(86_400)  // keys last a day
    .build();

Verification accepts the current period and the previous one, so a token issued just before a boundary is not rejected just after it. Keep the period comfortably longer than your longest token lifetime, or tokens will fail before their stated expiry.

The optional obfuscation pass

A faithful port of ProxyAuth's process_string / calcul_factorhash, off by default:

use zerocrypt::{Factor, Vault};

let vault = Vault::builder("operator secret")
    .factor(Factor::new(57)?)        // or Factor::generate()
    .build();

Leaving it unset corresponds to ProxyAuth's fast: true; setting it corresponds to fast: false. The keystream domain string is unchanged, so the transform produces output identical to the original — zerocrypt::factor::process_string is public if you want it on its own.

The factor is constrained to 10..=99, enforced by the type: Factor::new rejects anything outside, so a vault either has a valid factor or has the pass switched off. There is no third state.

What this gives you is a third input on the signature path: someone holding both the operator secret and the build key still cannot produce a valid signature without it. What it does not give you is much more than that — 90 values is under 6.5 bits, the transform is deterministic and invertible, and the attack it faces is offline with nothing to rate-limit. Worth having; not worth relying on.

One deliberate difference from the original: digit accumulation uses wrapping arithmetic. The original overflows past nineteen digits, which panics in a debug build, and a library should not hand a caller that lever.

Why the build key is 32 bytes

The attack it has to withstand is offline. Someone holding the operator secret and a single issued token can test candidate build keys on their own hardware, with the authentication tag confirming a correct guess, never touching your server. No rate limiting applies, so size is the only defence.

BuildKey::generate draws 32 bytes straight from the OS CSPRNG rather than hashing a smaller seed — hashing low-entropy input to a longer output does not create entropy, it only makes the shortfall harder to notice. from_bytes and from_hex refuse anything under 16 bytes, so a stored value that predates a size change cannot silently weaken a fresh instance.

What this does not protect against

Host compromise. Anyone who can read your process memory or your disk image recovers the build key like any other constant, at any size. This addresses the leaked-config-file case — an exposed backup, a misconfiguration, a file read from another service on the same host — which is the more common one. It is not a defence against an attacker already inside the machine.

Scope

In: key derivation, sealing, and the signature.

Out: revocation, storage, user lookup, password verification, logging. Every token carries a unique session.id() so you can keep your own revocation list; where that lives is your business — and it is usually async, which is why verification here is not.

examples/proxyauth_adapter.rs shows the split concretely: a validate function keeping its original (username, token_id) signature, with the crypto delegated and revocation, lookup and logging still in the application.

Token layout

base64url( 0x01 || nonce[24] || XChaCha20-Poly1305(signature | expiry | id | data | user) )

URL-safe and unpadded, so a token drops into a header, a cookie or a query string without escaping.

Design notes

  • #![forbid(unsafe_code)], no panicking paths on token handling, no unwrap on attacker-controlled input.
  • Signature comparison is constant-time. A byte-wise != would leak, through timing, how many leading bytes an attacker had guessed correctly.
  • Signature fields are length-prefixed rather than joined, so no combination of values can be made to hash like a different combination.
  • The user field is last in the payload and read with a bounded split, so a username containing the separator is handled rather than forbidden.
  • BuildKey's Debug is redacted, so it cannot end up in a log line that formats the surrounding struct.
  • Vault is immutable after construction and therefore Send + Sync — one instance shared across every worker, no lock, no cache.
  • Error does not distinguish a failed authentication tag from a bad signature. Reporting them separately would hand an attacker a decision oracle, and no legitimate caller benefits.
  • verify_at takes the clock as an argument, so expiry behaviour is testable without sleeping.

Benchmarking on your own hardware

Install the benchmark binary:

cargo install zerocrypt --features bench
zerocrypt-bench --threads 6

Or, from a source checkout:

cargo run --release --features bench --bin zerocrypt-bench -- --threads 6

--threads N defaults to the detected core count, --iters N to 100000 per thread. The feature is off by default, so nothing extra is compiled for library users. --release matters: a debug build measures the absence of optimisation rather than the design.

The scaling section shares one &Vault across every thread and reports aggregate throughput as a percentage of linear. Close to 100% means no contention — the vault holds no lock and no cache, so threads never wait on each other. Well under 100% usually means fewer real cores than threads requested: four threads on one core lands at 25%, as it should.

To measure inside your own project instead, the public API is all you need:

use std::time::Instant;
use zerocrypt::Vault;

let vault = Vault::new("operator secret");
let token = vault.issue("alice")?;

for _ in 0..10_000 {
    let _ = vault.verify(&token);          // warm up
}

let start = Instant::now();
for _ in 0..100_000 {
    std::hint::black_box(vault.verify(&token)?);
}
println!("{} ns/verify", start.elapsed().as_nanos() / 100_000);

One vault per thread (thread-share-vault)

zerocrypt = { version = "0.1.9", features = ["thread-share-vault", "cache"] }
let vault = zerocrypt::Vault::builder(secret)
    .key(build_key)
    .cache(10_000)
    .build_per_thread();          // ThreadVault: Send + Sync, cheap to clone

let session = vault.verify(&token)?;              // this thread's own Vault
let token = vault.with(|v| v.token("bob").data("idx=2").issue())?;

Every thread works on its own copy of the vault, created on first use, with its own verification cache. Tokens are interchangeable with a plain Vault built from the same settings.

Without the cache, a shared Vault already takes no lock, so the two perform the same. With the cache, a shared cache is one structure all threads write to; per-thread caches never contend, at the cost of up to threads × capacity entries and a lower hit rate when a client's requests move between threads.

Compare the two on your hardware:

cargo run --release --features bench-shared-thread --bin zerocrypt-bench-threads -- --threads 32
cargo run --release --features bench-shared-thread,bench-mimalloc --bin zerocrypt-bench-threads -- --threads 32

Status

Not audited. Treat it the way you would treat any young cryptographic dependency: read it before you rely on it.

Licence

Apache-2.0.

Last Versions

Show All Versions