rk_pki 0.3.1 copy "rk_pki: ^0.3.1" to clipboard
rk_pki: ^0.3.1 copied to clipboard

Certificates, mutual TLS and a per-installation certificate authority for Dart, over a native library built on rustls-webpki, rcgen and aws-lc-rs.

rk_pki #

Machine identity, mutual TLS and a per-installation certificate authority for Dart, over a native library built on rustls-webpki, rcgen and aws-lc-rs.

A machine proves which machine it is with an X.509 certificate signed by the installation's own authority. Two machines that each hold one can raise a mutually authenticated connection. Dart can do neither half of that: no Dart library builds and signs a certificate — pointycastle and cryptography both stop short — and Dart cannot hold a private key outside memory its collector may copy. So the work lives in a native library and this package is the contract over it.

What it replaces #

Hand-written ASN.1/DER. Nothing here writes DER by hand; encoding belongs to rcgen and rustls-webpki, and this package is the policy above them. The same library also carries Argon2id, which is what a PIN check needs — and what the code being replaced was reaching for when it "encrypted" a PIN with an RSA public key and compared the ciphertexts.

What it is built on, and what it is not #

verification rustls-webpki over aws-lc-rs — the verifier rustls itself uses, on rustls_pki_types::CertificateDer
issuance rcgen, same organisation, same crypto provider
secrets argon2 (RustCrypto)
not ring unmaintained; RUSTSEC-2025-0007
not an openssl binding a C build dependency on six platforms, for nothing this needs

One stack, shared with the QUIC transport that presents these certificates, so a certificate has one representation and not two.

The rules it keeps #

  • A failure is a value. Every call answers with PkiResult. A panic inside the native library is caught at the boundary and returns as NativeFault; nothing unwinds out of a foreign stack and nothing aborts the process.

  • Native memory is freed when you say so. Every string the library returns is released on the line that reads it; the key store handle is released by RkPki.close(). A NativeFinalizer is attached as a net, never as the mechanism — it is guaranteed to run, but never at a moment you can name.

  • Enumerations cross by name. Profiles, machine kinds, failure kinds and operations are names on the wire. An index would change meaning the moment a case is inserted, and here that decides whether a machine is trusted.

  • The machine's private key does not cross. The identity every mutual-TLS decision rests on stays inside the library, in any encoding, on every call. The store lives inside a worker isolate that shares nothing, and a test walks the operations asserting that no answer carries a private-key PEM header.

    There is exactly one exception, and it is not the machine identity. serverCredential(CertProfile.browserFacing) returns the browser-facing leaf together with its PKCS#8 key. It exists because a QUIC server has to terminate TLS, which means holding the key, and rk_quic will not mint a certificate of its own — so between the two packages a WebTransport server could not be stood up at all. Ask for CertProfile.machine and the call is refused with badRequest; the same test that walks the other operations walks this one too, for that profile.

    What makes the exception narrow rather than a hole: the browser-facing leaf lives seven days, faces the loopback, and authenticates a session rather than a machine — a browser pinning serverCertificateHashes never walks the chain, so a stolen copy carries no standing anywhere else. The authority is still one and still here: this hands out a leaf already issued, it does not let anyone else issue.

Offline #

An expired certificate degrades exactly like an absent network and never harder. Selling continues, outgoing work queues, only the new sessions that need that certificate fail, and a session already open is not torn down by the wall clock — TLS checks a certificate when a session is established, not continuously.

final status = (await pki.status(CertProfile.machine)).valueOrNull!;
status.stopsSelling;           // always false
status.blocksNewSessions;      // true once expired
status.tearsDownOpenSessions;  // true only for a revocation

and on the failure side:

switch (await pki.current(CertProfile.machine)) {
  case PkiOk(value: final info):    // use it
  case PkiErr(error: final e) when e.degradesLikeOffline:
                                    // carry on selling, queue, retry later
  case PkiErr(error: final e):      // a decision: this peer is not ours
}

Two profiles, one authority #

profile lifetime why
machine 30 days internal mutual TLS: till to shop server to chain server to relay
browserFacing 7 days a browser pinning the hash through WebTransport's serverCertificateHashes refuses anything at or over 14 days, so this sits at half of it rather than on the line

Both are ECDSA P-256 from the same authority. Rotation falls due with a third of the lifetime left — one rule, so a third profile cannot arrive without a rotation policy.

Enrolment and rotation #

Enrolment uses a one-time invite: the owner mints it, the new machine presents it with a signing request, the authority burns it and signs. The invite is burnt on presentation, whatever the outcome — an invite that has been shown is spent.

Rotation uses no invite, because it must happen with no human involved: what authorises the reissue is the certificate the machine already holds. If that certificate has expired, renewal is refused and the machine is back to enrolment — an expired certificate could not open the session a renewal would travel over either.

A single till with no server is its own root, by the same code path a chain server uses. There is no "simple case" implementation.

A signing request contributes exactly one thing that is trusted: the public key, whose possession it proves by signing itself. Subject, alternative names, key usage, basic constraints and validity are all decided by the authority — rcgen would happily sign the requester's own parameters, is_ca included.

Using it #

final opened = await RkPki.open(
  config: PkiConfig(
    storeDirectory: '/var/lib/telepos/pki',
    installationId: 'inst-1',
    machineId: 'till-17',
    machineKind: MachineKind.till,
  ),
);
if (opened case PkiOk(value: final pki)) {
  await pki.initialiseAuthority();            // if this machine is the root
  final invite = await pki.createInvite();
  await pki.enroll(
    invite: invite.valueOrNull!.code,
    profile: CertProfile.machine,
  );
  await pki.close();
}

hasNativeCrypto is a real probe: it loads the library, asks for its ABI version, and answers false for every reason a caller might care about without throwing.

Building the native part #

cargo build --manifest-path rust/Cargo.toml
cargo test  --manifest-path rust/Cargo.toml

The crate is a plain cdylib/staticlib behind a C ABI (rust/include/rk_pki.h, seven symbols).

This package is a Flutter FFI plugin: flutter build runs cargo and puts the library in the application, on Windows, Linux and Android. Nothing has to be pointed anywhere — libraryPath remains for a library you built by hand. The mechanism, the three separate routes to cargo, and what to check first on a Mac are in doc/native-build.md.

Where it has been proved to arrive:

Target State Evidence
Windows arrives rk_pki.dll next to the runner of a built application
Linux arrives librk_pki.so in the application's bundle/lib/
Android arrives found inside the unpacked APK for armeabi-v7a, arm64-v8a, x86_64
macOS, iOS built and linked verified 2026-08-03 on Apple M4 / macOS 26.2 / Xcode 26.2: the archive builds for arm64 and x86_64 on macOS, arm64 on device and both on the simulator; a C probe links against it with -force_load in Release and Debug, and the macOS binaries run through the C ABI. Gated by CI from that day.

Bindings are generated, not written:

dart run ffigen --config ffigen.yaml     # needs LLVM

The generated file is committed, so a consumer never needs LLVM.

What it is not #

Not a general certificate authority. No ACME, no OCSP responder, no HSM integration, no subjects outside the till / server / relay-client model. An installation that wants a full authority of its own points this at one: the authority is an address, not a compiled-in choice.

Not a TLS stack. It issues and judges the certificates a QUIC transport presents; it runs no session of its own.

Known gaps #

  • On Windows a key file inherits the store directory's ACL. On Unix it is created 0600. Platform key holders — CNG/DPAPI, Keychain, TPM 2.0 — are the next step and are not pretended to exist here.
  • Revocation is a local list of fingerprints, consulted on every check. There is no CRL distribution and no OCSP: a short lifetime is the passive mechanism, this list is the active one.

License #

MIT, Rob Kim. See LICENSE.

0
likes
160
points
375
downloads

Documentation

API reference

Publisher

verified publisherspherex.kz

Weekly Downloads

Certificates, mutual TLS and a per-installation certificate authority for Dart, over a native library built on rustls-webpki, rcgen and aws-lc-rs.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

ffi

More

Packages that depend on rk_pki

Packages that implement rk_pki