|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +//! Gateway-minted JWT signing-key generation. |
| 5 | +//! |
| 6 | +//! The gateway mints per-sandbox identity tokens (see PR 2 of the |
| 7 | +//! per-sandbox identity series, issue #1354) signed with an Ed25519 |
| 8 | +//! keypair generated once at gateway init and persisted alongside the |
| 9 | +//! existing PKI bundle. The signing key never leaves the gateway; the |
| 10 | +//! public key plus a stable `kid` are consumed by the gateway's own |
| 11 | +//! validator and any future external verifiers. |
| 12 | +
|
| 13 | +use miette::{IntoDiagnostic, Result, WrapErr}; |
| 14 | +use rcgen::{KeyPair, PKCS_ED25519}; |
| 15 | +use sha2::{Digest, Sha256}; |
| 16 | + |
| 17 | +/// All PEM-encoded material needed to mint and validate sandbox JWTs. |
| 18 | +/// |
| 19 | +/// The signing key stays in the gateway process. The public key is shared |
| 20 | +/// across gateway replicas (so any replica can validate a JWT minted by |
| 21 | +/// any other replica). The `kid` is published in every minted JWT's |
| 22 | +/// header so the validator can pick the right key after a future rotation. |
| 23 | +pub struct JwtKeyMaterial { |
| 24 | + /// PKCS#8 PEM-encoded Ed25519 private key. |
| 25 | + pub signing_key_pem: String, |
| 26 | + /// `SubjectPublicKeyInfo` PEM-encoded Ed25519 public key. |
| 27 | + pub public_key_pem: String, |
| 28 | + /// Stable identifier derived from the public key (SHA-256 hex prefix). |
| 29 | + /// Embedded in every minted JWT's `kid` header so future rotation can |
| 30 | + /// be performed in-place by adding a second key without breaking |
| 31 | + /// in-flight tokens. |
| 32 | + pub kid: String, |
| 33 | +} |
| 34 | + |
| 35 | +/// Generate a fresh Ed25519 JWT signing key. |
| 36 | +/// |
| 37 | +/// Output PEM is in the formats `jsonwebtoken` consumes via |
| 38 | +/// `EncodingKey::from_ed_pem` (signing) and `DecodingKey::from_ed_pem` |
| 39 | +/// (validation), so the gateway can round-trip its own tokens with no |
| 40 | +/// further conversion. |
| 41 | +pub fn generate_jwt_key() -> Result<JwtKeyMaterial> { |
| 42 | + let keypair = KeyPair::generate_for(&PKCS_ED25519) |
| 43 | + .into_diagnostic() |
| 44 | + .wrap_err("failed to generate Ed25519 JWT signing key")?; |
| 45 | + let signing_key_pem = keypair.serialize_pem(); |
| 46 | + let public_key_pem = keypair.public_key_pem(); |
| 47 | + let kid = kid_from_public_key_der(&keypair.public_key_der()); |
| 48 | + Ok(JwtKeyMaterial { |
| 49 | + signing_key_pem, |
| 50 | + public_key_pem, |
| 51 | + kid, |
| 52 | + }) |
| 53 | +} |
| 54 | + |
| 55 | +/// Stable `kid` derived from the SHA-256 of the public-key DER. |
| 56 | +/// |
| 57 | +/// First 16 bytes hex-encoded — collision-resistant for the small N of |
| 58 | +/// signing keys a single deployment ever has, while staying short enough |
| 59 | +/// to keep JWT headers compact. |
| 60 | +fn kid_from_public_key_der(public_key_der: &[u8]) -> String { |
| 61 | + let digest = Sha256::digest(public_key_der); |
| 62 | + hex_encode_prefix(&digest, 16) |
| 63 | +} |
| 64 | + |
| 65 | +fn hex_encode_prefix(bytes: &[u8], n: usize) -> String { |
| 66 | + use std::fmt::Write as _; |
| 67 | + let mut out = String::with_capacity(n * 2); |
| 68 | + for byte in bytes.iter().take(n) { |
| 69 | + let _ = write!(out, "{byte:02x}"); |
| 70 | + } |
| 71 | + out |
| 72 | +} |
| 73 | + |
| 74 | +#[cfg(test)] |
| 75 | +mod tests { |
| 76 | + use super::*; |
| 77 | + |
| 78 | + #[test] |
| 79 | + fn generate_jwt_key_produces_parseable_pem() { |
| 80 | + let material = generate_jwt_key().expect("generate_jwt_key"); |
| 81 | + assert!(material.signing_key_pem.contains("BEGIN PRIVATE KEY")); |
| 82 | + assert!(material.public_key_pem.contains("BEGIN PUBLIC KEY")); |
| 83 | + assert_eq!(material.kid.len(), 32, "kid is 16 bytes hex-encoded"); |
| 84 | + assert!(material.kid.chars().all(|c| c.is_ascii_hexdigit())); |
| 85 | + } |
| 86 | + |
| 87 | + #[test] |
| 88 | + fn kid_is_stable_for_identical_public_keys() { |
| 89 | + // Same input -> same kid. Hash of a fixed byte string. |
| 90 | + let kid_a = kid_from_public_key_der(b"abc"); |
| 91 | + let kid_b = kid_from_public_key_der(b"abc"); |
| 92 | + assert_eq!(kid_a, kid_b); |
| 93 | + } |
| 94 | + |
| 95 | + #[test] |
| 96 | + fn kid_differs_for_different_public_keys() { |
| 97 | + let kid_a = kid_from_public_key_der(b"first"); |
| 98 | + let kid_b = kid_from_public_key_der(b"second"); |
| 99 | + assert_ne!(kid_a, kid_b); |
| 100 | + } |
| 101 | + |
| 102 | + #[test] |
| 103 | + fn generated_keys_are_unique() { |
| 104 | + let a = generate_jwt_key().expect("generate_jwt_key"); |
| 105 | + let b = generate_jwt_key().expect("generate_jwt_key"); |
| 106 | + assert_ne!( |
| 107 | + a.kid, b.kid, |
| 108 | + "fresh keypairs must produce distinct public keys" |
| 109 | + ); |
| 110 | + assert_ne!(a.signing_key_pem, b.signing_key_pem); |
| 111 | + } |
| 112 | +} |
0 commit comments