v0.4.11: Hardware Keys, End to End

The v0.4.10 post ended with a short, honest list of three things that release did not ship: the PivKeyProvider binding to real hardware tokens, the --recipient-hw CLI flag, and Python parity for the new suite. v0.4.11 closes one of those hard, and it adds a third surface neither list mentioned. The OSGT-HW-P256-v1 suite now runs end to end in every reference implementation Oversight has: the Rust core, the Python core, and the public browser inspector. Every layer of the protocol ships every suite.

What is still not here is the actual hardware. PivKeyProvider and the CLI flag remain the next bounded session. But the shape of the work has changed. In v0.4.10 the suite was real in one implementation and a software-only test path. After v0.4.11, three independent implementations agree on the same wire bytes for suite_id = 3, and the conformance tests between them are the thing that will catch any drift when the PKCS#11 binding lands. The hard part of the curve plumbing is done in three places at once instead of one.

Python parity, byte for byte

The Python reference implementation is the protocol's source of truth, so a suite that only exists in Rust is a suite the conformance harness cannot see. v0.4.11 brings Python up. New wrap_dek_for_recipient_p256 and unwrap_dek_p256 in oversight_core.crypto mirror the Rust reference exactly, and "exactly" is a testable claim here: same HKDF info string, same AEAD AAD, same 65-byte SEC1 uncompressed ephemeral public key on the wire, same wrapped-envelope JSON shape including the explicit "suite" field.

HKDF info:  "oversight-hw-p256-v1-dek-wrap"
AEAD AAD:   "oversight-hw-p256-dek"
ephemeral:  SEC1 uncompressed, 65 bytes
container:  suite_id = 3  ->  OSGT-HW-P256-v1

oversight_core.container now recognizes suite_id = 3 and maps it to OSGT-HW-P256-v1 in SUITE_ID_TO_NAME, so inspecting a hardware-suite file in Python reports the right name instead of an unknown-suite error. On the unwrap side, unwrap_dek_p256 accepts the recipient key in three forms: an EllipticCurvePrivateKey, PKCS#8-encoded bytes, or a raw integer scalar. That is not convenience typing. It is the on-ramp for the eventual PIV / PKCS#11 binding, which will hand the function a scalar recovered from a token operation without forcing the caller through a particular serialization.

The new tests/test_hw_p256.py is ten tests, and the negative paths matter more than the happy path. Round trips run across all three private-key input forms. The on-wire envelope shape is checked against SPEC.md § 5.2. Then the rejection set: wrong recipient, wrong ephemeral key length, missing fields, and the one I care about most, AAD binding. A classic envelope's bytes must not silently decrypt through the hardware path. The AAD tags the envelope to its suite, so an attacker who swaps a classic wrapped DEK into a hardware container gets an AEAD failure, not a plaintext. That is the class of bug a parity pass is supposed to surface before a real deployment does.

The Rust container closes the loop

The v0.4.10 post described seal_hw_p256 and open_sealed_with_provider as follow-up commits on main past the tag. v0.4.11 is the release where they ride into a tagged version. seal_hw_p256 mirrors seal but consumes a P-256 SEC1 uncompressed recipient public key and writes a container with suite_id = 3. open_sealed_with_provider is the polymorphic open path: it dispatches on the container's suite_id and delegates the recipient-side ECDH to a KeyProvider.

Today that dispatcher supports the classic suite through FileKeyProvider and the hardware suite through SoftwareP256KeyProvider, with a future PivKeyProvider dropping into the same slot. A hybrid-aware provider extension lands later. Cross-suite mismatches are refused explicitly: hand an X25519 provider to a hardware-P-256 container and you get an error, not a garbage shared secret that happens to decrypt. That refusal is the same posture as the Python AAD-binding test, enforced at the dispatch boundary instead of the AEAD boundary.

The manifest schema gains an optional p256_pub field on Recipient, gated by serde(default, skip_serializing_if = "Option::is_none"). A classic recipient never serializes it; a hardware recipient leaves x25519_pub empty and fills p256_pub instead. Existing JSON manifests parse unchanged, which is the migration guarantee that matters: sealed files written before this release stay readable, and the container's existing rule that the unsigned suite_id header must match the signed manifest.suite keeps the two from being mixed. Five new round-trip and negative tests land with it; oversight-container is 17 of 17 and the workspace builds clean.

The browser inspector

The third surface is the public browser inspector. It now decrypts the OSGT-HW-P256-v1 sample fixture end to end, backed by a pinned, vendored P-256 ECDH from @noble/curves. A recipient can open a hardware-suite file in a browser tab without installing the CLI, and the answer is bit-identical to the Rust core and the Python core on the same input.

Pinning a vendored curve implementation in the viewer is deliberate. The browser is the surface where I have the least control over the runtime, and the least ability to debug a recipient who got a different answer. A single, auditable implementation, shipped with the page, removes "whichever WebCrypto the browser exposed" as a variable. It is heavier on the wire, and it is the right tradeoff for a page whose entire job is to give the same cryptographic answer as the desktop.

What is still not in v0.4.11

PivKeyProvider remains the obvious miss: the PKCS#11 binding to actual YubiKey, Nitrokey, and OnlyKey tokens through PIV. The trait it plugs into has shipped, has a passing software reference, and now has passing parity tests on both sides of the Python / Rust conformance line. The binding itself brings PIN UX and a softhsm-backed CI harness with it, and that is the next bounded session.

CLI plumbing for hardware recipients is still absent. No oversight seal --recipient-hw, no oversight open --recipient-hw piv:9d. Those land with the provider, so the CLI is asking the token to do something I have confirmed it supports on the user's device, rather than guessing.

Everything in this release is reachable through library and test surfaces today. The token binding turns the software path into a hardware path without changing the seal, the manifest, or the wire format.

What I want from this release

The cross-language conformance test is the ground truth this project is built on. Before v0.4.11, the hardware suite existed in one implementation and was invisible to it. After v0.4.11, three implementations agree on suite_id = 3 down to the HKDF info string and the AEAD AAD, and the negative tests on both sides enforce that the suites cannot be quietly crossed. When PivKeyProvider lands, the only thing it changes is where one ECDH runs. Everything else, the manifest, the container, the policy, the transparency-log entry, is already decided in code that three runtimes agree on.

The release is on GitHub, the protocol detail is in the hardware-keys guide and SPEC.md, and you can open a hardware-suite sample in the browser inspector without installing anything.