← Blog

No Passwords. No TLS Certs.
No Bearer Tokens.
ML-DSA-65 in 400 Lines of C.

How we replaced Kubernetes kubeconfig tokens with post-quantum digital signatures. Built and verified on a single Arch Linux workstation. The signing key never leaves the operator machine.

SB
Scott Baker
Systems engineer — C23, Rust, NixOS, post-quantum security

Kubernetes stores credentials in a kubeconfig file. The token field in that file is base64-encoded — a reversible encoding, not encryption. Anyone who can read the file can run base64 -d and have a working credential. These files routinely end up on developer laptops, in CI environment variables, in Slack messages, and in accidental git commits. It is the dominant credential exfiltration pattern in k8s breach post-mortems.

We wanted something categorically different. Not "better password storage" — no passwords at all. The auth model we built for Skr8tr is: every mutating command is a digital signature. The private key never leaves the operator's machine. The server only knows the public key. There is nothing to steal from the server side.

Why ML-DSA-65

ML-DSA (Module-Lattice Digital Signature Algorithm) is NIST FIPS 204, finalized in 2024. It is post-quantum secure — resistant to attacks from both classical and quantum computers. The Level 3 variant (ML-DSA-65) gives us:

We use liboqs 0.15 — the Open Quantum Safe reference implementation. One function call to sign, one to verify. The C API is clean.

The Implementation

The entire auth layer is two files: src/core/skrauth.h (the API contract) and src/core/skrauth.c (the implementation). Roughly 400 lines total including comments. Here is the core of what happens when you run skr8tr --key ~/.skr8tr/signing.sec up app.skr8tr:

skrauth.c — sign path (simplified)
/* payload = "SUBMIT|/path/to/app.skr8tr|1743890400" */ int skrauth_sign(const char *cmd, const char *seckey_path, char *out, size_t out_len, ...) { /* 1. Read 4032-byte secret key from disk */ load_key(seckey_path, sk, SK_LEN); /* 2. Form payload: cmd + unix timestamp (replay protection) */ snprintf(payload, sizeof(payload), "%s|%ld", cmd, time(NULL)); /* 3. Sign with ML-DSA-65 */ OQS_SIG_ml_dsa_65_sign(sig, &sig_len, payload, strlen(payload), sk); /* 4. Hex-encode the 3309-byte signature */ hex_encode(sig, sig_len, hexsig); /* 5. Output: "SUBMIT|/path/...|1743890400|3a4f8b2c...6618 hex chars" */ snprintf(out, out_len, "%s|%s", payload, hexsig); }
skrauth.c — verify path in the Conductor
int skrauth_verify(const char *signed_cmd, const char *pubkey_path, char *cmd_out, size_t cmd_out_len) { /* Signature is always the last 6618 hex chars */ size_t payload_len = strlen(signed_cmd) - (HEXSIG_LEN + 1); /* Extract and decode the hex signature */ hex_decode(signed_cmd + payload_len + 1, sig); /* Check timestamp — reject if outside ±30s window */ long ts = extract_ts(signed_cmd); if (llabs(time(NULL) - ts) > 30) return -1; /* replay rejected */ /* Load 1952-byte public key, verify signature */ load_key(pubkey_path, pk, PK_LEN); if (OQS_SIG_ml_dsa_65_verify(signed_cmd, payload_len, sig, sig_len, pk) != OQS_SUCCESS) return -1; /* invalid signature */ /* Copy bare command (without ts and sig) to output */ extract_bare_cmd(signed_cmd, cmd_out, cmd_out_len); return 0; }

Key Generation

One-time setup per operator machine. The skrtrkey tool wraps OQS_SIG_keypair:

generating a keypair
$ bin/skrtrkey keygen generated public key: ./skrtrview.pub (1952 bytes) generated secret key: /home/sbaker/.skr8tr/signing.sec (4032 bytes, chmod 600) # skrtrview.pub — copy this to every Conductor host # signing.sec — stays on the operator machine, never moves

The Rust CLI Side

The CLI (cli/src/main.rs) calls liboqs directly via extern "C" FFI — no Rust oqs crate, no cmake build step, no new dependencies beyond the system liboqs. build.rs locates the library via pkg-config or a Nix store search and links it at compile time.

cli/src/main.rs — signing a command before sending
extern "C" { fn OQS_SIG_ml_dsa_65_sign( sig: *mut u8, siglen: *mut usize, msg: *const u8, mlen: usize, sk: *const u8, ) -> i32; } fn sign_command(cmd: &str, key_path: &str) -> Result<String, String> { let sk = std::fs::read(key_path)?; // 4032 bytes let ts = SystemTime::now()...as_secs(); let payload = format!("{}|{}", cmd, ts); // "SUBMIT|path|ts" let mut sig = vec![0u8; 3309]; let mut sig_len = 3309usize; unsafe { OQS_SIG_ml_dsa_65_sign(...) }; let hex: String = sig.iter().map(|b| format!("{:02x}", b)).collect(); Ok(format!("{}|{}", payload, hex)) // signed wire command }

End-to-End Test — What We Actually Ran

We tested this on one machine: an Arch Linux workstation (20-core, NixOS package set, liboqs 0.15 from the Nix store). Single-node cluster running locally.

verified end-to-end on 2026-04-06
# Unsigned command — Conductor rejects it $ skr8tr up examples/my-server.skr8tr ERR|UNAUTHORIZED — sign commands with: skr8tr --key ~/.skr8tr/signing.sec # Signed command — accepted and placed $ skr8tr --key ~/.skr8tr/signing.sec up examples/my-server.skr8tr submitting /home/sbaker/skr8tr/examples/my-server.skr8tr... ok app my-server node 638eb13ea59264dc5ae812fdecb59019 # Verify round-trip with skrtrkey $ SIGNED=$(skrtrkey sign ~/.skr8tr/signing.sec "SUBMIT|/tmp/test.skr8tr") $ skrtrkey verify ./skrtrview.pub "$SIGNED" VALID — bare command: SUBMIT|/tmp/test.skr8tr
The Conductor has no concept of "logged in" state. There is no session. There is no token to steal from the server. Each command is self-contained: it carries its own timestamp, its own signature, and is valid for exactly 30 seconds. A replayed command from 31 seconds ago is silently rejected.

What This Is Not

This is operator-to-conductor auth only. It does not protect node-to-node mesh traffic — that runs on a trusted internal network. It does not provide multi-user RBAC — there is one operator key per cluster in the open-source version. Enterprise RBAC with per-team keys and namespace isolation is a separate layer.

The source: src/core/skrauth.c and src/tools/skrtrkey.c.


Questions or security reports: scott.bakerphx@gmail.com

← All posts Next: Rolling Updates →