aethel-sdk 0.9.1

Ergonomic Rust SDK over aethel-core's post-quantum identity primitives: generate, sign, verify, project, recover
Documentation

aethel-sdk — Rust SDK for Post-Quantum Identity

Tier: Preview · MSRV: 1.95 · License: Apache-2.0 · Not independently audited, see the crate family

License: Apache-2.0

⚠️ This project ships 0.x. Nothing here is stable yet, and breaking changes are expected between minor versions until 1.0. See STABILITY.md for exactly what that means.

aethel-sdk is an ergonomic Rust surface over aethel-core's post-quantum identity primitives. Where aethel-core exposes the cryptographic building blocks (Polymorphic Lattice Projection, Selective Attribute Attestation, Shamir 3-of-5 threshold secret sharing) as a no_std/WASM component, this crate gives an application developer the everyday verbs on top of it:

  • Generate an identity and persist it
  • Sign a message and verify a signature
  • Project an identity into a caller-supplied context (PLP), with fresh per-projection randomness so repeated projections are independent
  • Recover an identity from threshold shares (HTSS)

Credentials (selective disclosure) are off by default, behind the experimental-credentials feature, and should not be relied on yet. See Experimental: credentials.

[dependencies]

aethel-sdk = "0.9"

Before you add it: this crate embeds a WebAssembly runtime, so cargo add aethel-sdk pulls wasmtime and Cranelift, roughly 120 crates. A cold debug build takes several minutes and leaves a target directory over a gigabyte. That is a one-time cost, and a build that looks stuck at four minutes is not stuck. Host platforms only: wasmtime needs mmap and cannot compile to wasm32-unknown-unknown, so this crate will not build for a browser.

What runs today vs. what is designed

Runs today:

  • The aethel:core component is embedded in the crate, built from a pinned aethel-core revision, with its SHA-256 declared in the package and checked by tests and by CI. See The embedded component below, including how to rebuild it yourself and compare.

  • The embedded component loads and executes. aethel_sdk::component::load() checks the hash, refuses to instantiate an artifact that fails it, and returns bindings generated from the WIT world. Tests call it and compare the results against aethel-core's native API at the same pinned revision, coefficient for coefficient.

  • Generate an identity, sign a message, verify a signature. Identity::generate(), Identity::sign() and verify(). The signing key never exists in this process: it is derived inside the component and stays there.

  • Persist an identity and load it again, on the same machine or another. Identity::export_sealed() and Identity::open_sealed().

  • Contextual projection. Identity::project_at(context) obtains fresh OS randomness for each call and returns public context-bound material without exposing the identity's master secret.

  • HTSS recovery. Identity::split_for_recovery(sealing_key) creates authenticated, recovery-sensitive 3-of-5 shares over the canonical sealed identity representation, and Identity::recover_from_shares(shares, expected_root, sealing_key) restores it.

  • Offline generation. Creating an identity reaches nothing. Proven in CI rather than asserted; see Generation is offline.

  • Interoperable public keys and signatures. Identity::public_key_multibase() emits a W3C Multikey, public_key_from_multibase() decodes it, and Identity::sign_typed() returns a pqc-sig algorithm-labelled Signature that transports through JSON or base64url.

  • Nothing cryptographic is implemented in this crate, and nothing ever will be. Every cryptographic operation lives inside the component. That is the charter's L1 boundary: one artifact, embedded by every language, and adding a language never adds crypto.

  • Repo scaffolding and program artifacts: LICENSE, NOTICE, SECURITY.md, SECURITY-MODEL.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, STABILITY.md, CI. Last in this list on purpose: shipping a LICENSE is not a capability, and a reader scanning for what they can call should not have to step over it.

Designed, not yet implemented:

The 0.7.x quickstart, which also exercised credentials, was followed cold on 2026-09-14 against the published crate by two blind testers given only this README, crates.io and docs.rs (automated agents, not people): 3m33s on a clean Linux container and 3m31s on Windows. The identity-only quickstart below replaces it and gets the same check at the next release.

See ROADMAP.md for the milestone sequence this is built in.

Quickstart

[dependencies]

aethel-sdk = "0.9"

use aethel_sdk::{public_key_from_multibase, verify, verify_typed, Identity, Signature};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Entropy comes from the OS. The signing key is derived from it inside the
    // embedded component and never enters this process.
    let mut identity = Identity::generate()?;

    // PROCESS A — signer: create public verification material and a typed,
    // algorithm-labelled signature that can be transported independently.
    let message = b"the message that was actually signed";
    let multikey = identity.public_key_multibase();
    let signature_json = identity.sign_typed(message)?.to_json()?;

    // PROCESS B — verifier: these are values received from the signer. This
    // process has no private identity or sealing key.
    let received_key = public_key_from_multibase(&multikey)?;
    let received_signature = Signature::from_json(&signature_json)?;
    assert!(verify_typed(&received_key, message, &received_signature)?);
    assert!(!verify_typed(
        &received_key,
        b"something else",
        &received_signature
    )?);

    // Existing byte-oriented calls continue to work unchanged.
    let signature = identity.sign(message)?;
    assert!(verify(identity.public_key(), message, &signature)?);

    // Persist it. `key` must be high-entropy key material, NOT a password.
    let key = b"a sealing key of thirty-two byte";
    let sealed = identity.export_sealed(key)?;
    let mut identity = Identity::open_sealed(&sealed, key)?;

    // Project the identity into a context. Each call uses fresh secret
    // randomness, so two projections at the same context are independent, and
    // neither exposes the master secret.
    let first = identity.project_at(b"checkout-session")?;
    let second = identity.project_at(b"checkout-session")?;
    assert_ne!(first.salt(), second.salt());

    println!("ok: signed, verified, sealed, reopened and projected {multikey:.16}...");
    Ok(())
}

No network access is involved, and nothing is fetched at install time: the component is compiled into the crate. JSON preserves the signature's algorithm label; base64url is the raw signature-byte encoding and must be accompanied by its algorithm when decoded with Signature::from_base64url. A valid signature only proves control of the corresponding private key over this message. Your application must independently establish whether that public identity is trusted or authorized for the action.

Running an agent identity

docs/AGENT-IDENTITY.md covers keeping an identity across restarts, deciding whom to trust, and offline receipts. Two runnable examples go with it: cargo run --example trusted_signer and cargo run --example offline_receipt -- sign ./receipt.

Threshold recovery

The embedded core currently provides authenticated, fixed-policy 3-of-5 HTSS. The SDK splits the identity's canonical sealed representation, rather than exposing its ML-DSA-65 signing key or PLP master seed: those raw secrets remain inside the component.

Recovery requires three things: a valid threshold of authenticated shares, the trusted Merkle root for that recovery set, and the original high-entropy sealing key. HTSS protects availability of the sealed identity blob; it does not replace the sealing key. Three shares alone cannot open a recovered identity.

use aethel_sdk::{Identity, RecoveryShareSet};

let mut identity = Identity::generate()?;
let recovery = identity.split_for_recovery(sealing_key)?;
// Retain this root in an independent trusted location before accepting shares.
let trusted_root = *recovery.merkle_root();

// Save or transport the versioned recovery material over protected channels.
let encoded = recovery.to_bytes();
let decoded = RecoveryShareSet::from_bytes(&encoded)?;

// Any three valid shares can restore the identity.
let restored =
    Identity::recover_from_shares(&decoded.shares()[..3], &trusted_root, sealing_key)?;
assert_eq!(restored.public_key(), identity.public_key());

The version-1 serialized format includes a claimed Merkle root for compatibility, but serialized shares are untrusted recovery inputs. Retain the authentication root separately, in an independent trusted location or channel, and pass that root explicitly to recovery. A caller-supplied share set cannot authenticate itself by supplying its own root: storing the root and every share in the same untrusted store removes substitution protection.

Recovery shares and their encoded bytes are recovery-sensitive material: protect them with access control and transport encryption, and do not publish them or print them in logs. Do not mix shares from different aethel-core versions in one reconstruction: the V2 coefficient derivation changed every share value, so recovery sets are not interoperable across that change.

Contextual projection

Use [Identity::project_at] for the safe, ordinary path:

let projection = identity.project_at(b"checkout-session")?;
println!("public projection salt: {:02x?}", projection.salt());

Every call obtains fresh secret OS randomness. Consequently, calls for the same identity and context produce different salts and public projection material; this prevents projections at one context from sharing the matrix needed by the historical averaging attack.

Identity::project_at_with_randomness(context, randomness) exists only when reproducibility is needed, such as tests or an advanced protocol flow. Its randomness must be secret and freshly sampled for each projection, must be at least 32 bytes, and must never be derived deterministically from context or identity data. Reusing it at the same context reproduces the same projection byte-for-byte, so it contributes no new independent sample. Prefer project_at unless the caller can meet and retain that obligation.

The projection exposes only padded context, public salt, and public coefficients; the component keeps the master secret. That non-exposure is an API property, not a standalone proof of the underlying construction's security.

Under aethel-core's stated M-LWE security assumptions, the master secret is not derivable from any number of projections produced with fresh, secret randomness. Each projection's salt-derived context matrix prevents same-context projections from sharing the matrix required by the historical averaging attack. This is a cryptographic property of aethel-core's construction; SDK tests cover observable non-exposure proxies, not non-derivability.

Run the complete worked example with:

cargo run --example projection

What you are trusting

verify returns Ok(false) for a signature that does not verify and an error only for input it could not process. Those are different answers on purpose. Treating an error as "invalid" is the mistake that makes malformed input look like a failed check.

What is and is not claimed about timing

aethel-core compares authentication-bearing bytes in constant time, in its ct_verify.rs. Every comparison that decides whether something verifies happens down there, inside the component: this crate passes the signature, the proof and the key across the boundary and returns the answer the component gives it. It never compares them itself.

That is the whole of the claim. This is not a statement that the SDK, the host runtime, or your application is constant-time end to end. wasmtime's compilation and execution, your allocator, and anything you do with the result are all outside it, and a serious side-channel posture would have to account for them. What is claimed is narrower and worth stating on its own: the ergonomic layer does not undo the work the core already did.

Keeping it that way is enforced rather than intended. scripts/check-comparisons.sh runs in CI and fails on any equality comparison in src/ that is not in scripts/allowed-comparisons.txt with a written reason. The three currently listed are a build-metadata key name, the embedded component's published hash, and a public attribute name. A new == on a signature, a proof, or key material fails the build until somebody looks at it.

Generation is offline, and that is proven

Identity::generate() makes no network call. Neither does anything under it: the component is compiled into the crate rather than fetched, and the key is derived inside it.

That is a claim worth more than an assurance, so CI proves it by denying the capability rather than by asserting about it. The offline generation (network-isolated) job builds the test binaries with network available, then runs the suite again inside a network namespace with no interface. Generation has to keep working there.

The half that makes it evidence is the negative control. tests/network_isolation_negative_control.rs deliberately opens a TCP connection to 1.1.1.1:443, and the job requires that test to fail inside the isolated step. An in-process "am I offline?" assertion can only see the paths it knows to instrument, and this crate has a WebAssembly runtime underneath it; if the isolation ever silently stopped being applied, a suite that merely passes would look identical. A control that must fail is what tells the two apart.

Verifying more than occasionally

The free functions above are the convenient path: a script, a test, a one-off verification. They already share one compiled runtime across the whole process, so calling verify or verify_presentation repeatedly does not recompile anything.

What they do not give you is control over when that first compile happens. The first call through either path — whichever request lands first — pays a 230 ms cost. On a request path, that means some unlucky caller pays it.

Verifier owns the same compiled runtime, but you construct it explicitly:

use aethel_sdk::Verifier;

// Once, at startup:
let verifier = Verifier::new()?;

// Per request, as many times as you like:
let ok = verifier.verify(public_key, message, &signature)?;
let ok = verifier.verify_presentation(&issuer_params, &presentation, expected_context)?;

Per-verification cost is identical to the free functions — both paths instantiate the same compiled component per call. The only difference Verifier buys is moving the 230 ms compile to construction time instead of a caller's first request. SAGP, a gateway that verifies on the request path, holds one Verifier built at startup for exactly this reason. Anything verifying more than occasionally should do the same.

The embedded component

Everything cryptographic happens inside aethel_core.component.wasm, a WebAssembly Component Model component built from aethel-core. It is embedded in this crate at compile time, not downloaded at install time, so what you build against is what you audited.

Three files under core/ are meant to be read together:

File What it is
core/aethel_core.component.wasm the artifact itself
core/component.sha256 the hash this package claims those bytes have
core/pin.toml the aethel-core revision and toolchain that produced them

Rebuild it yourself and compare

Shipping a compiled binary inside a source package is only defensible if you do not have to take our word for what is in it. You do not:

git clone https://github.com/0x307/aethel-sdk && cd aethel-sdk

scripts/sync-core.sh

git diff --stat core/

A clean git diff means the committed artifact is exactly what the pinned aethel-core revision builds. The script clones aethel-core at the pinned revision, builds it twice, requires the two builds to be byte-identical, validates the component, checks that every operation the WIT world declares is actually present in it, and writes the result with its hash. It needs Docker and network access, and nothing else. The same comparison runs in CI on every push, along with a positive control that a modified artifact is detected.

To do it without the script, .github/workflows/component.yml is the same sequence written out step by step.

The hash is platform-specific

rustc embeds platform paths and links a different std, so the same source built on Windows or macOS produces different bytes from the same revision. That is expected and is not a reproducibility failure. The canonical platform and toolchain are recorded in core/pin.toml, and scripts/sync-core.sh reproduces that environment in a container so the canonical bytes can be built and checked from any host.

Updating to a newer aethel-core

scripts/sync-core.sh <revision>
cargo test

That re-vendors the WIT world, rebuilds the artifact, rewrites the declared hash, and moves the pin. Bindings are generated from core/wit/ at compile time, so a reshaped world is picked up by the next cargo build with nothing hand-written to update.

Experimental: credentials

Selective disclosure (issue a credential over named attributes, present it revealing only some of them, verify the presentation) is compiled only with the experimental-credentials feature:

[dependencies.aethel-sdk]

version = "0.7"

features = ["experimental-credentials"]

It is off by default because it should not be relied on yet:

  • The commitment does not hide. aethel-core's SECURITY.md records that a presentation reveals every attribute it commits to, disclosed or not, and that two presentations of one credential are linkable. The defect is in the specified commitment shape, and fixing it is the first piece of the credential work.
  • Disclosed attributes are self-asserted. A presentation proves the credential opens under the issuer's public parameters, not that an issuer authorised the values. See SECURITY-MODEL.md.
  • A presentation cannot leave the process that made it. It has no serialised form yet.
  • You cannot prove a threshold over a hidden value. "My age is at least 21, and I am not telling you my age" cannot be built on this proof system. The planned answer is attributes the issuer attests to directly, such as an over_21 flag.

The feature exists so the credential work can continue in public, with its tests running in CI. Credentials come back on by default once they are sound, and identity plus credentials is the expected 1.0.

Stability and support

This project ships 0.x. See STABILITY.md for what counts as a breaking change, deprecation notice, release cadence, and support posture.

Security

SECURITY-MODEL.md states what this crate claims, how each claim is checked, what you are responsible for, and what is out of scope. It says up front that there has been no third-party audit. Read it before depending on this for anything that matters.

See SECURITY.md to report a vulnerability.

Contributing

See CONTRIBUTING.md, including the current external-contribution posture.

License

Apache-2.0

Maintainer

Ed Johnson


The 0x307 crate family

aethel-sdk is one of six open-source crates from 0x307, held to one audit and stability standard.

Crate Tier What it does
pqc-sig Production ML-DSA, SLH-DSA and FN-DSA signatures (FIPS 204/205/206)
pqc-kem Production ML-KEM (FIPS 203), the X25519 + ML-KEM-768 hybrid, X-Wing, and sealed boxes
aethel-core Production Post-quantum anonymous identity: a separate identifier per context, context-bound ML-DSA signing
aethel-sdk (this crate) Preview The SDK over aethel-core, and the place to start
aethel-vault Preview Agent-held wallet: policy-gated x402 / EIP-3009 signing with ML-DSA-65 spend records
pqc-privacy Lab Research bundle, kept off every identity and payment path

Production crates are thin, standards-bound libraries meant to be depended on today. Preview crates work and are published, with APIs still settling. Lab crates are research, never on an identity or payment path.

Runnable examples: 0x307/examples, one program per crate, pinned to the published versions.

Audit status: None of these crates has been independently audited, and none holds a CMVP / FIPS 140-3 validation. "FIPS 203/204/205/206" means the algorithms follow those standards, not that the code is certified. Known issues for this crate are in SECURITY.md. Versioning and yanks: STABILITY.md.