[wip] cli capability parity
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s

This commit is contained in:
2026-03-06 16:58:50 -06:00
parent 48b6ca6bd7
commit 87d830f952
94 changed files with 3694 additions and 734 deletions

View File

@@ -0,0 +1,193 @@
//! HMAC-only CryptoProvider for jsonwebtoken v10.
//!
//! The `jsonwebtoken` crate v10 requires a `CryptoProvider` to be installed
//! before any signing/verification operations. The built-in `rust_crypto`
//! feature pulls in the `rsa` crate, which has an unpatched advisory
//! (RUSTSEC-2023-0071 — Marvin Attack timing sidechannel).
//!
//! Since Attune only uses HMAC-SHA2 (HS256/HS384/HS512) for JWT signing,
//! this module provides a minimal CryptoProvider that supports only those
//! algorithms, avoiding the `rsa` dependency entirely.
//!
//! Call [`install()`] once at process startup (before any JWT operations).
use hmac::{Hmac, Mac};
use jsonwebtoken::crypto::{CryptoProvider, JwkUtils, JwtSigner, JwtVerifier};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
use sha2::{Sha256, Sha384, Sha512};
use signature::{Signer, Verifier};
use std::sync::Once;
type HmacSha256 = Hmac<Sha256>;
type HmacSha384 = Hmac<Sha384>;
type HmacSha512 = Hmac<Sha512>;
// ---------------------------------------------------------------------------
// Signers
// ---------------------------------------------------------------------------
macro_rules! define_hmac_signer {
($name:ident, $alg:expr, $hmac_type:ty) => {
struct $name($hmac_type);
impl $name {
fn new(key: &EncodingKey) -> jsonwebtoken::errors::Result<Self> {
let inner = <$hmac_type>::new_from_slice(key.try_get_hmac_secret()?)
.map_err(|_| jsonwebtoken::errors::ErrorKind::InvalidKeyFormat)?;
Ok(Self(inner))
}
}
impl Signer<Vec<u8>> for $name {
fn try_sign(&self, msg: &[u8]) -> std::result::Result<Vec<u8>, signature::Error> {
let mut mac = self.0.clone();
mac.reset();
mac.update(msg);
Ok(mac.finalize().into_bytes().to_vec())
}
}
impl JwtSigner for $name {
fn algorithm(&self) -> Algorithm {
$alg
}
}
};
}
define_hmac_signer!(Hs256Signer, Algorithm::HS256, HmacSha256);
define_hmac_signer!(Hs384Signer, Algorithm::HS384, HmacSha384);
define_hmac_signer!(Hs512Signer, Algorithm::HS512, HmacSha512);
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
macro_rules! define_hmac_verifier {
($name:ident, $alg:expr, $hmac_type:ty) => {
struct $name($hmac_type);
impl $name {
fn new(key: &DecodingKey) -> jsonwebtoken::errors::Result<Self> {
let inner = <$hmac_type>::new_from_slice(key.try_get_hmac_secret()?)
.map_err(|_| jsonwebtoken::errors::ErrorKind::InvalidKeyFormat)?;
Ok(Self(inner))
}
}
impl Verifier<Vec<u8>> for $name {
fn verify(
&self,
msg: &[u8],
sig: &Vec<u8>,
) -> std::result::Result<(), signature::Error> {
let mut mac = self.0.clone();
mac.reset();
mac.update(msg);
mac.verify_slice(sig).map_err(signature::Error::from_source)
}
}
impl JwtVerifier for $name {
fn algorithm(&self) -> Algorithm {
$alg
}
}
};
}
define_hmac_verifier!(Hs256Verifier, Algorithm::HS256, HmacSha256);
define_hmac_verifier!(Hs384Verifier, Algorithm::HS384, HmacSha384);
define_hmac_verifier!(Hs512Verifier, Algorithm::HS512, HmacSha512);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
fn hmac_signer_factory(
algorithm: &Algorithm,
key: &EncodingKey,
) -> jsonwebtoken::errors::Result<Box<dyn JwtSigner>> {
match algorithm {
Algorithm::HS256 => Ok(Box::new(Hs256Signer::new(key)?)),
Algorithm::HS384 => Ok(Box::new(Hs384Signer::new(key)?)),
Algorithm::HS512 => Ok(Box::new(Hs512Signer::new(key)?)),
_other => Err(jsonwebtoken::errors::ErrorKind::InvalidAlgorithm.into()),
}
}
fn hmac_verifier_factory(
algorithm: &Algorithm,
key: &DecodingKey,
) -> jsonwebtoken::errors::Result<Box<dyn JwtVerifier>> {
match algorithm {
Algorithm::HS256 => Ok(Box::new(Hs256Verifier::new(key)?)),
Algorithm::HS384 => Ok(Box::new(Hs384Verifier::new(key)?)),
Algorithm::HS512 => Ok(Box::new(Hs512Verifier::new(key)?)),
_other => Err(jsonwebtoken::errors::ErrorKind::InvalidAlgorithm.into()),
}
}
/// HMAC-only [`CryptoProvider`]. Supports HS256, HS384, HS512 only.
/// JWK utility functions (RSA/EC key extraction) are stubbed out since
/// Attune never uses asymmetric JWKs.
static HMAC_PROVIDER: CryptoProvider = CryptoProvider {
signer_factory: hmac_signer_factory,
verifier_factory: hmac_verifier_factory,
jwk_utils: JwkUtils::new_unimplemented(),
};
static INIT: Once = Once::new();
/// Install the HMAC-only crypto provider for jsonwebtoken.
///
/// Safe to call multiple times — only the first call takes effect.
/// Must be called before any JWT encode/decode operations.
pub fn install() {
INIT.call_once(|| {
// install_default returns Err if already installed (e.g., by a feature-based
// provider). That's fine — we only care that *some* provider is present.
let _ = HMAC_PROVIDER.install_default();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_install_idempotent() {
install();
install(); // second call should not panic
}
#[test]
fn test_hmac_sign_and_verify() {
install();
let secret = b"test-secret-key";
let encoding_key = EncodingKey::from_secret(secret);
let decoding_key = DecodingKey::from_secret(secret);
let message = b"hello world";
let signer =
hmac_signer_factory(&Algorithm::HS256, &encoding_key).expect("should create signer");
let sig = signer.try_sign(message).expect("should sign");
let verifier = hmac_verifier_factory(&Algorithm::HS256, &decoding_key)
.expect("should create verifier");
verifier
.verify(message, &sig)
.expect("signature should verify");
}
#[test]
fn test_unsupported_algorithm_rejected() {
install();
let key = EncodingKey::from_secret(b"key");
let result = hmac_signer_factory(&Algorithm::RS256, &key);
assert!(result.is_err());
}
}

View File

@@ -248,8 +248,10 @@ pub fn extract_token_from_header(auth_header: &str) -> Option<&str> {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::crypto_provider;
fn test_config() -> JwtConfig {
crypto_provider::install();
JwtConfig {
secret: "test_secret_key_for_testing".to_string(),
access_token_expiration: 3600,
@@ -260,6 +262,7 @@ mod tests {
#[test]
fn test_generate_and_validate_access_token() {
let config = test_config();
let token =
generate_access_token(123, "testuser", &config).expect("Failed to generate token");
@@ -293,6 +296,7 @@ mod tests {
#[test]
fn test_token_with_wrong_secret() {
let config = test_config();
let token = generate_access_token(789, "user", &config).expect("Failed to generate token");
let wrong_config = JwtConfig {
@@ -306,6 +310,7 @@ mod tests {
#[test]
fn test_expired_token() {
crypto_provider::install();
let now = Utc::now().timestamp();
let expired_claims = Claims {
sub: "999".to_string(),

View File

@@ -4,8 +4,10 @@
//! that are used by the API (for all token types), the worker (for execution-scoped
//! tokens), and the sensor service (for sensor tokens).
pub mod crypto_provider;
pub mod jwt;
pub use crypto_provider::install as install_crypto_provider;
pub use jwt::{
extract_token_from_header, generate_access_token, generate_execution_token,
generate_refresh_token, generate_sensor_token, generate_token, validate_token, Claims,

View File

@@ -2,6 +2,14 @@
//!
//! This module provides functions for encrypting and decrypting secret values
//! using AES-256-GCM encryption with randomly generated nonces.
//!
//! ## JSON value encryption
//!
//! [`encrypt_json`] / [`decrypt_json`] operate on [`serde_json::Value`] values.
//! The JSON value is serialised to its compact string form before encryption,
//! and the resulting ciphertext is stored as a JSON string (`Value::String`).
//! This means the JSONB column always holds a plain JSON string when encrypted,
//! and the original structured value is recovered after decryption.
use crate::{Error, Result};
use aes_gcm::{
@@ -9,6 +17,7 @@ use aes_gcm::{
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
/// Size of the nonce in bytes (96 bits for AES-GCM)
@@ -55,6 +64,33 @@ pub fn encrypt(plaintext: &str, encryption_key: &str) -> Result<String> {
Ok(BASE64.encode(&result))
}
/// Encrypt a [`JsonValue`] using AES-256-GCM.
///
/// The value is first serialised to its compact JSON string representation,
/// then encrypted with [`encrypt`]. The returned value is a
/// [`JsonValue::String`] containing the base64 ciphertext, suitable for
/// storage in a JSONB column.
pub fn encrypt_json(value: &JsonValue, encryption_key: &str) -> Result<JsonValue> {
let plaintext = serde_json::to_string(value)
.map_err(|e| Error::encryption(format!("Failed to serialise JSON for encryption: {e}")))?;
let ciphertext = encrypt(&plaintext, encryption_key)?;
Ok(JsonValue::String(ciphertext))
}
/// Decrypt a [`JsonValue`] that was previously encrypted with [`encrypt_json`].
///
/// The input must be a [`JsonValue::String`] containing a base64 ciphertext.
/// After decryption the JSON string is parsed back into the original
/// structured [`JsonValue`].
pub fn decrypt_json(value: &JsonValue, encryption_key: &str) -> Result<JsonValue> {
let ciphertext = value
.as_str()
.ok_or_else(|| Error::encryption("Encrypted JSON value must be a string"))?;
let plaintext = decrypt(ciphertext, encryption_key)?;
serde_json::from_str(&plaintext)
.map_err(|e| Error::encryption(format!("Failed to parse decrypted JSON: {e}")))
}
/// Decrypt a ciphertext value using AES-256-GCM
///
/// The ciphertext should be base64-encoded and contain: nonce || encrypted_data || tag
@@ -226,4 +262,61 @@ mod tests {
assert_eq!(key1, key2);
assert_eq!(key1.len(), 32); // 256 bits
}
// ── JSON encryption tests ──────────────────────────────────────
#[test]
fn test_encrypt_decrypt_json_string() {
let value = serde_json::json!("my_secret_token");
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
assert!(encrypted.is_string(), "encrypted JSON should be a string");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_object() {
let value = serde_json::json!({"user": "admin", "password": "s3cret", "port": 5432});
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_array() {
let value = serde_json::json!(["token1", "token2", 42, true, null]);
let encrypted = encrypt_json(&value, TEST_KEY).expect("encrypt_json should succeed");
let decrypted = decrypt_json(&encrypted, TEST_KEY).expect("decrypt_json should succeed");
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_number() {
let value = serde_json::json!(42);
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let decrypted = decrypt_json(&encrypted, TEST_KEY).unwrap();
assert_eq!(value, decrypted);
}
#[test]
fn test_encrypt_decrypt_json_bool() {
let value = serde_json::json!(true);
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let decrypted = decrypt_json(&encrypted, TEST_KEY).unwrap();
assert_eq!(value, decrypted);
}
#[test]
fn test_decrypt_json_wrong_key_fails() {
let value = serde_json::json!({"secret": "data"});
let encrypted = encrypt_json(&value, TEST_KEY).unwrap();
let wrong = "wrong_key_that_is_also_32_chars_long!!!";
assert!(decrypt_json(&encrypted, wrong).is_err());
}
#[test]
fn test_decrypt_json_non_string_fails() {
let not_encrypted = serde_json::json!(42);
assert!(decrypt_json(&not_encrypted, TEST_KEY).is_err());
}
}

View File

@@ -1232,7 +1232,7 @@ pub mod key {
pub name: String,
pub encrypted: bool,
pub encryption_key_hash: Option<String>,
pub value: String,
pub value: JsonValue,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}

View File

@@ -2,6 +2,7 @@
use crate::models::{key::*, Id, OwnerType};
use crate::Result;
use serde_json::Value as JsonValue;
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, List, Repository, Update};
@@ -48,13 +49,13 @@ pub struct CreateKeyInput {
pub name: String,
pub encrypted: bool,
pub encryption_key_hash: Option<String>,
pub value: String,
pub value: JsonValue,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateKeyInput {
pub name: Option<String>,
pub value: Option<String>,
pub value: Option<JsonValue>,
pub encrypted: Option<bool>,
pub encryption_key_hash: Option<String>,
}