[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
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:
193
crates/common/src/auth/crypto_provider.rs
Normal file
193
crates/common/src/auth/crypto_provider.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¬_encrypted, TEST_KEY).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user