Skip to content
Clone
163952e
135 lines 4.4 KB Rust
use std::time::{SystemTime, UNIX_EPOCH};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
use rand::RngExt;
use rand::distr::{Alphanumeric, SampleString};
use rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
use crate::errors::{AuthError, AuthResult};
const NONCE_LEN: usize = 12;
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
pub fn random_bytes(len: usize) -> Vec<u8> {
let mut bytes = vec![0u8; len];
// 密钥材料统一走 OS 熵源;ThreadRng 仅用于非安全场景。
OsRng.fill_bytes(&mut bytes);
bytes
}
pub fn random_alnum(len: usize) -> String {
Alphanumeric.sample_string(&mut rand::rng(), len)
}
pub fn random_hex(len: usize) -> String {
to_hex(&random_bytes(len))
}
pub fn random_b64url(len: usize) -> String {
URL_SAFE_NO_PAD.encode(random_bytes(len))
}
pub fn random_numeric_code(len: usize) -> String {
let mut rng = rand::rng();
(0..len)
.map(|_| char::from_digit(rng.random_range(0..10u32), 10).unwrap_or('0'))
.collect()
}
pub fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
}
out
}
pub fn sha256(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
pub fn sha256_hex(bytes: &[u8]) -> String {
to_hex(&sha256(bytes))
}
pub fn sha256_b64url(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(sha256(bytes))
}
/// OpenSSH fingerprints use standard Base64 without padding.
pub fn sha256_b64_standard(bytes: &[u8]) -> String {
STANDARD_NO_PAD.encode(sha256(bytes))
}
pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
crypt::constant_time_eq(left, right)
}
pub fn decode_base64_key(material: &str) -> AuthResult<Vec<u8>> {
let trimmed = material.trim();
if trimmed.is_empty() {
return Err(AuthError::internal("encryption key is not configured"));
}
STANDARD
.decode(trimmed)
.or_else(|_| URL_SAFE_NO_PAD.decode(trimmed))
.map_err(|err| AuthError::internal(format!("encryption key is not valid base64: {err}")))
}
fn derive_key(material: &[u8], purpose: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"auth.kdf.v1\n");
hasher.update((material.len() as u64).to_be_bytes());
hasher.update(material);
hasher.update(b"\n");
hasher.update(purpose.as_bytes());
hasher.finalize().into()
}
pub fn aes_encrypt(material: &[u8], purpose: &str, plaintext: &str) -> AuthResult<String> {
let key = derive_key(material, purpose);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| AuthError::internal("invalid encryption key length"))?;
let nonce_bytes = random_bytes(NONCE_LEN);
let nonce = Nonce::try_from(nonce_bytes.as_slice())
.map_err(|_| AuthError::internal("invalid nonce"))?;
let sealed = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|_| AuthError::internal("encryption failed"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&sealed);
Ok(URL_SAFE_NO_PAD.encode(out))
}
pub fn aes_decrypt(material: &[u8], purpose: &str, stored: &str) -> AuthResult<String> {
let trimmed = stored.trim();
let raw = URL_SAFE_NO_PAD
.decode(trimmed)
.or_else(|_| STANDARD.decode(trimmed))
.map_err(|err| AuthError::internal(format!("ciphertext is not valid base64: {err}")))?;
if raw.len() <= NONCE_LEN {
return Err(AuthError::internal("ciphertext is truncated"));
}
let (nonce_bytes, sealed) = raw.split_at(NONCE_LEN);
let key = derive_key(material, purpose);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| AuthError::internal("invalid encryption key length"))?;
let nonce = Nonce::try_from(nonce_bytes).map_err(|_| AuthError::internal("invalid nonce"))?;
let plaintext = cipher
.decrypt(&nonce, sealed)
.map_err(|_| AuthError::internal("decryption failed"))?;
String::from_utf8(plaintext)
.map_err(|err| AuthError::internal(format!("plaintext is not utf8: {err}")))
}