use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MysqlCachingSha2Verifier {
stage2: [u8; 32],
}
impl MysqlCachingSha2Verifier {
pub fn from_password(password: &str) -> Self {
let stage1 = Sha256::digest(password.as_bytes());
Self {
stage2: Sha256::digest(stage1).into(),
}
}
pub fn verify(&self, nonce: &[u8], proof: &[u8]) -> bool {
if proof.is_empty() {
return bool::from(self.stage2.ct_eq(&Self::from_password("").stage2));
}
let Ok(proof) = <&[u8; 32]>::try_from(proof) else {
return false;
};
let mask = Sha256::new()
.chain_update(self.stage2)
.chain_update(nonce)
.finalize();
let mut stage1 = [0_u8; 32];
for (output, (proof, mask)) in stage1.iter_mut().zip(proof.iter().zip(mask)) {
*output = proof ^ mask;
}
let candidate: [u8; 32] = Sha256::digest(stage1).into();
bool::from(candidate.ct_eq(&self.stage2))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserEntry {
pub id: u64,
pub username: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub password_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scram_sha_256: Option<crate::security_hardening::ScramVerifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mysql_caching_sha2: Option<MysqlCachingSha2Verifier>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub is_admin: bool,
pub created_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleEntry {
pub name: String,
#[serde(default)]
pub permissions: Vec<Permission>,
pub created_epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Permission {
All,
Select { table: String },
Insert { table: String },
Update { table: String },
Delete { table: String },
SelectColumns { table: String, columns: Vec<String> },
InsertColumns { table: String, columns: Vec<String> },
UpdateColumns { table: String, columns: Vec<String> },
Ddl,
Admin,
}
impl Permission {
pub fn satisfies(&self, required: &Permission) -> bool {
match (self, required) {
(Permission::All, Permission::Admin) => false,
(Permission::All, _) => true,
(Permission::Admin, Permission::Admin) => true,
(Permission::Ddl, Permission::Ddl) => true,
(Permission::Select { table: a }, Permission::Select { table: b }) => a == b,
(Permission::Insert { table: a }, Permission::Insert { table: b }) => a == b,
(Permission::Update { table: a }, Permission::Update { table: b }) => a == b,
(Permission::Delete { table: a }, Permission::Delete { table: b }) => a == b,
(
Permission::SelectColumns {
table: a,
columns: granted,
},
Permission::SelectColumns {
table: b,
columns: required,
},
)
| (
Permission::InsertColumns {
table: a,
columns: granted,
},
Permission::InsertColumns {
table: b,
columns: required,
},
)
| (
Permission::UpdateColumns {
table: a,
columns: granted,
},
Permission::UpdateColumns {
table: b,
columns: required,
},
) => a == b && required.iter().all(|column| granted.contains(column)),
_ => false,
}
}
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Permission::All => write!(f, "ALL"),
Permission::Admin => write!(f, "ADMIN"),
Permission::Ddl => write!(f, "DDL"),
Permission::Select { table } => write!(f, "SELECT ON {table}"),
Permission::Insert { table } => write!(f, "INSERT ON {table}"),
Permission::Update { table } => write!(f, "UPDATE ON {table}"),
Permission::Delete { table } => write!(f, "DELETE ON {table}"),
Permission::SelectColumns { table, columns } => {
write!(f, "SELECT ({}) ON {table}", columns.join(", "))
}
Permission::InsertColumns { table, columns } => {
write!(f, "INSERT ({}) ON {table}", columns.join(", "))
}
Permission::UpdateColumns { table, columns } => {
write!(f, "UPDATE ({}) ON {table}", columns.join(", "))
}
}
}
}
#[derive(Debug, Clone)]
pub struct Principal {
pub user_id: u64,
pub created_epoch: u64,
pub username: String,
pub is_admin: bool,
pub roles: Vec<String>,
pub permissions: Vec<Permission>,
}
impl Principal {
pub fn has_permission(&self, required: &Permission) -> bool {
if self.is_admin {
return true;
}
self.permissions.iter().any(|p| p.satisfies(required))
}
pub fn column_access(&self, table: &str, operation: ColumnOperation) -> ColumnAccess {
if self.is_admin
|| self
.permissions
.iter()
.any(|permission| matches!(permission, Permission::All))
{
return ColumnAccess::All;
}
let full = self
.permissions
.iter()
.any(|permission| match (operation, permission) {
(ColumnOperation::Select, Permission::Select { table: granted })
| (ColumnOperation::Insert, Permission::Insert { table: granted })
| (ColumnOperation::Update, Permission::Update { table: granted }) => {
granted == table
}
_ => false,
});
if full {
return ColumnAccess::All;
}
let mut columns = Vec::new();
for permission in &self.permissions {
let grant = match (operation, permission) {
(
ColumnOperation::Select,
Permission::SelectColumns {
table: granted,
columns,
},
)
| (
ColumnOperation::Insert,
Permission::InsertColumns {
table: granted,
columns,
},
)
| (
ColumnOperation::Update,
Permission::UpdateColumns {
table: granted,
columns,
},
) if granted == table => Some(columns),
_ => None,
};
if let Some(grant) = grant {
for column in grant {
if !columns.contains(column) {
columns.push(column.clone());
}
}
}
}
if columns.is_empty() {
ColumnAccess::Denied
} else {
ColumnAccess::Columns(columns)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnOperation {
Select,
Insert,
Update,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnAccess {
All,
Columns(Vec<String>),
Denied,
}
pub fn hash_password(password: &str) -> Result<String, String> {
use argon2::{
password_hash::{PasswordHasher, SaltString},
Algorithm, Argon2, Version,
};
use getrandom::getrandom;
let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut salt_bytes = [0u8; 32];
getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| e.to_string())?;
Ok(hash.to_string())
}
pub fn scram_verifier(password: &str) -> Result<crate::security_hardening::ScramVerifier, String> {
let mut salt = [0_u8; 16];
getrandom::getrandom(&mut salt).map_err(|error| error.to_string())?;
crate::security_hardening::ScramVerifier::from_password(
password,
&salt,
crate::security_hardening::SCRAM_SHA_256_MIN_ITERATIONS,
)
.map_err(|error| error.to_string())
}
pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
use argon2::{password_hash::PasswordVerifier, Argon2};
let parsed_hash =
argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_hash_round_trip() {
let password = "correct horse battery staple";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
assert!(!verify_password("wrong password", &hash).unwrap());
}
#[test]
fn caching_sha2_verifier_accepts_only_matching_proof() {
let verifier = MysqlCachingSha2Verifier::from_password("password");
let nonce = b"12345678901234567890";
let stage1 = Sha256::digest(b"password");
let stage2 = Sha256::digest(stage1);
let mask = Sha256::new()
.chain_update(stage2)
.chain_update(nonce)
.finalize();
let proof = stage1
.iter()
.zip(mask)
.map(|(left, right)| left ^ right)
.collect::<Vec<_>>();
assert!(verifier.verify(nonce, &proof));
assert!(!verifier.verify(nonce, &[0; 32]));
}
#[test]
fn permission_satisfies() {
assert!(Permission::All.satisfies(&Permission::Ddl));
assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
assert!(Permission::All.satisfies(&Permission::Insert { table: "t".into() }));
assert!(!Permission::All.satisfies(&Permission::Admin));
assert!(Permission::Select { table: "t".into() }
.satisfies(&Permission::Select { table: "t".into() }));
assert!(
!Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
table: "other".into()
})
);
assert!(!Permission::Select { table: "t".into() }
.satisfies(&Permission::Insert { table: "t".into() }));
assert!(!Permission::Ddl.satisfies(&Permission::Admin));
}
#[test]
fn principal_admin_bypasses_checks() {
let principal = Principal {
user_id: 0,
created_epoch: 0,
username: "admin".into(),
is_admin: true,
roles: vec![],
permissions: vec![],
};
assert!(principal.has_permission(&Permission::Admin));
assert!(principal.has_permission(&Permission::Select {
table: "anything".into()
}));
}
}