langacorp / kemproof
Attest that an ML-KEM-768 key exchange really happened, with key confirmation, and record it with an expiry. It does not protect a channel.
Requires
- php: >=8.0
- ext-ffi: *
Requires (Dev)
None
Suggests
- liboqs: Runtime dependency: the system library that performs the ML-KEM-768 operations
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-04 18:58:41 UTC
README
Attest that an ML-KEM-768 key exchange really happened — and record it in a way anyone can check later.
Post-quantum key exchange in production is easy to claim and hard to prove. This is the smallest thing that turns the claim into a record: an exchange took place, with this algorithm, at this time, valid until then.
What this proves
- A party completed an ML-KEM-768 encapsulation against a fresh public key
- Both sides reached the same shared secret. The prover shows it with a key confirmation, and the verifier refuses the exchange — and stores nothing — when the confirmation does not match the secret it decapsulated
- The exchange belongs to this subject and this session: the confirmation is bound to both, and to the public key and the ciphertext, so it cannot be moved to another subject or replayed against another keypair
- It happened at a known time, and the record expires
What this does not prove
Read this part twice. It is the reason the project exists.
- It does not encrypt anything. The shared secret is used to derive a fingerprint and is then discarded. No traffic is protected by it.
- It is not a tunnel, and not a replacement for TLS. Your transport security is whatever it was before.
- It is not hybrid. One KEM, not a KEM combined with a classical exchange.
- It says nothing about today's traffic. It says an exchange was possible.
- It does not say who the prover is. Anyone who can reach your handshake
endpoint can run an exchange for the subject they name. Deciding who may
attest a subject is your endpoint's job; the reference verifier in
examples/does not do it.
Why version 2 exists
Version 1 could not tell a real exchange from a forged one. ML-KEM
decapsulation uses implicit rejection: a ciphertext that was never produced
against the public key decapsulates without error to an unrelated secret.
Measured with liboqs on 1.x: 1088 random bytes were attested, and the record
read valid => true. The verifier had no way to tell, because the prover's
secret never came back to it.
Version 2 makes the prover show it. Next to the ciphertext it sends
confirmation = HMAC-SHA256(key = shared secret,
"kemproof/2 key confirmation" | session id | subject
| SHA-256(public key) | SHA-256(ciphertext))
with every field length-prefixed (4 bytes, big-endian). The verifier recomputes it from the secret it decapsulated and compares in constant time. A random ciphertext, a flipped bit, a confirmation made for another subject, session or keypair: each is refused, and each case is a test that runs against real liboqs in CI.
If you need protected traffic, you need a protocol, not an attestation. If you need to show an auditor that post-quantum key exchange runs in your estate and when it last ran, that is this.
How it works
verifier prover
| handshake |
|----- session id, public key ------>|
| | encapsulate (liboqs)
| | confirmation = HMAC(secret, transcript)
|<--- ciphertext, confirmation ------|
| decapsulate |
| check confirmation, or refuse |
| fingerprint = HMAC(secret, sid) |
| store: alg, protocol, time, expiry|
| discard the secret |
Three steps, and the secret survives none of them. The secret key of the
handshake stays on the verifier and is used once: delete it after attest(),
whatever the outcome. examples/verifier.php shows one way to do that.
Requirements
liboqs— the crypto is Open Quantum Safe, not ours. This project is the record around it.- PHP 8.0+ with the
ffiextension, for the verifier — the same floorcomposer.jsondeclares, and the self-test runs on it. 8.0 is a floor, not a recommendation: it no longer receives security fixes upstream. The code runs on it. Run something newer. - Python 3.8+ for the reference client
Install
composer require langacorp/kemproof
The package is the PHP verifier under src/. liboqs is a system library and
is not installed by Composer: it has to be present on the machine already.
Use
$kem = new KemProof\KemAttestation('/path/to/liboqs.so', $yourStore); $hs = $kem->handshake(); // hand out session_id and public_key, keep secret_key // ... the prover returns $ct and $confirmation (32 raw bytes) $record = $kem->attest($subject, $hs['session_id'], $hs['secret_key'], $ct, $confirmation); // throws RuntimeException('key confirmation failed: ...') and stores nothing // when the prover did not reach the same secret $status = $kem->status($subject); // null if never attested
status() returns null when a subject was never attested, and a record with
valid => false when it expired. Those are different answers and the caller
should not collapse them: never measured is not the same as measured and stale.
Storage is yours. Implement StoreInterface over a table, a file, a cache.
python3 client/attest.py https://example.org/kem/v1 my-subject /path/to/liboqs.so
A reference endpoint for that client, with single-use sessions, a session lifetime and the subject checked against the handshake:
KEMPROOF_LIBOQS=/path/to/liboqs.so KEMPROOF_DATA=/var/lib/kemproof \ php -d ffi.enable=true -S 127.0.0.1:8080 examples/verifier.php
Outside the CLI, PHP's default ffi.enable=preload forbids loading liboqs at
run time: set ffi.enable=true for the pool that runs the verifier, or
preload the library. kemproof says so in its error instead of failing inside
FFI.
Upgrading from 1.x
attest()takes a fifth argument, the 32-byte confirmation. A 1.x client sends none and is refused: update the client and the verifier together.- Records carry
protocol => 'kemproof/2'andconfirmed => true. A 1.x record has neither; it was never confirmed, and should be read that way. - The fingerprint is computed as before.
Tests
php tests/sizes.php # constants match FIPS 203 php tests/autoload.php # every public name resolves through PSR-4 php tests/lengths.php # wrong key, ciphertext and confirmation lengths are refused php tests/vector.php # the confirmation is pinned, the same bytes as the client KEMPROOF_LIBOQS=/path/to/liboqs.so php tests/exchange.php KEMPROOF_LIBOQS=/path/to/liboqs.so python3 -m unittest discover -s tests -v
The client tests include an end-to-end run: the Python client against
examples/verifier.php under php -S, with real liboqs on both sides.
Without KEMPROOF_LIBOQS, the real-exchange tests say they did not run; they
do not pass in its place. The self-test builds liboqs from a pinned tag and
runs all of them.
The other tools
Each came out of a defect measured on our own estate. Each one is standalone and depends on none of the others.
- realroute — checks that a route really exists, by content and not by status code.
- leakform — finds secrets in a git repository by shape, across every ref.
- samecheck — measures whether the copies that should be identical still are, and never says which one is right.
- provenreal — compares what a system claims with what can be measured, from independent sources.
- countdrift — finds numbers written by hand that no longer match their source.
The set is kept on the organisation profile. It is not written here as a count, because a number typed by hand is the thing countdrift exists to find.
Where this comes from
LANGA runs an ecosystem of digital services on its own infrastructure, and this is the part that records ML-KEM-768 exchanges within it.
Two layers are involved and they are not the same. Connections to the sites
served on our Galaxy infrastructure negotiate ML-KEM-768 at the TLS layer:
65 hosts out of 65, measured on 3 September 2026 by reading the negotiated group
in the ServerHello, querying the server address rather than the public name. Not
yet enabled on LINK, our client-hosting infrastructure, at that layer. This tool
records something else — an application-level exchange, run on top of
whatever transport is already in place. Anyone checking with openssl is
measuring the first and never the second, which is why each claim here says
which layer it means.
Writing the honest version of the claim was the harder half: our own public pages said encryption where the code did key exchange, and the two are not the same promise. That is written up here: Post-quantum key exchange in production, and what it is not.
The ecosystem it runs in:
- LANGA — the ecosystem
- LINK — monitoring and security for any website
- easy LANGA — client management, reports, support
- LANGA Tools — WordPress toolkit for developers
See How we work.
Licence
MIT — see LICENSE. Copyright LANGA Corporation S.r.l.