Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ target/
*.db
*.sqlite3
*.sqlite3-journal

# VSCode
.vscode/
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ mime_guess = { version = "2", default-features = false }
mockall = "0.13"
multer = "3"
password-auth = { version = "1", default-features = false }
secure-string = "0.3.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secure-string looks kinda dead. Why not use zeroize directly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zeroize is great at what it does, but it doesn't do enough. secure-string, apart from zeroing the memory, provides constant time equality (although as we looked before, it might just be claim not backed by code, I'll have to double check it) and protections against leaking into swap and core dumps. I believe that PR is good enough to merge it as is and we can always do some additional research and improve that part of the code later on, what do you think?

Copy link
Member

@m4tx m4tx Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provides constant time equality

@seqre this is untrue, as you (and I) pointed out. The PR uses subtle for this functionality.

I somewhat agree with @Kijewski that secure-string is not very well maintained. zeroize provides a good portion of the functionality, but mlocking the memory would be nice. We could implement this ourselves (which shouldn't be very difficult, looking at the code), so perhaps it would be good to get this right from the beginning (or just ignore the mlocking part for now). WDYT?

Copy link
Member

@seqre seqre Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a few solutions:

  1. As I mentioned above, we can merge it as is and improve in the future.
  2. We can fork secure-string, update it, use that version, and try to upstream the improvements - it ain't perfect, but something like this: ISibboI/secure-string@main...seqre-contrib:secure-string:main
  3. Its codebase is small, and it's licensed with UNLICENSE, so we can just pull that code into Cot.

The 2nd might be the best, we get what we want, and we might even improve the ecosystem by upstreaming improvements. This way, we can fairly quickly get what we want, and the only change required here is to change the source of the dependency. Would you agree @m4tx?

Copy link
Member

@m4tx m4tx Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seqre ah fair, I guess we could use a fork. Do you mind transferring it to the cot-rs organization, so that it's clear for people that we definitely have the will to maintain it? Similarly to https://github.com/cot-rs/openapi-guis

If we want to use the fork, we'll probably want to publish it on crates.io - at this point I don't think we should use any git dependencies. Of course, ideally, as you say, we should upstream the changes, but if we don't get a response for say, a week or two, we should probably stick to our fork.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repository has been transferred: https://github.com/cot-rs/secure-string

However, I think that using a git dependency, especially for a repo in our control, is fine for now, @m4tx. If we can't upstream the changes promptly, then sure, we can publish it to crates.io. Similarly to what we did before with other crates that we had to fix ourselves.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues were disabled on the forked repo, so I have copy comment here:

<SecureVec<T> as PartialEq>::eq is not sane

I don't think the change in cot-rs/secure-string@004f3da is sane. For a T which contains any internal or trailing padding, e.g. #[repr(C)] struct T { x: u8, y: u16 }, the bytes in the padding cannot be relied on, and should not be read. Also, generic Ts might not have the same order as its transmuted [u8] slice.

The simplest solution would be to remove the generic SecureVec<T>, and invariantly set T = u8. Then you could concentrate on secure strings, without having to care about any and all types there could be.

Copy link
Member

@m4tx m4tx Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I think that using a git dependency, especially for a repo in our control, is fine for now, @m4tx.

@seqre we can't release the crate to crates.io with a git dependency. When published, the git dependency will be replaced with a reference to a crates.io-published version, so it just won't work.

@Kijewski yeah, I agree that the implementation is not correct, thanks for vigilance! I think we can just use [T]::ct_eq (it's a Vec<T> underneath anyway) and remove the overcomplicated implementation and the unsafe code. Setting T = u8 would also work. @seqre is there a reason you opted for this solution?

I also re-enabled Issues on the repo.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was the quickest thing I could do to show a proof of concept in a few minutes; it wasn't intended to be the final solution. If we want to go forward with that fork, then we should spend more time on it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sure, that makes sense!

petgraph = { version = "0.8", default-features = false }
pin-project-lite = "0.2"
prettyplease = "0.2"
Expand Down
1 change: 1 addition & 0 deletions cot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ indexmap.workspace = true
mime_guess.workspace = true
multer.workspace = true
password-auth = { workspace = true, features = ["std", "argon2"] }
secure-string.workspace = true
pin-project-lite.workspace = true
schemars = { workspace = true, optional = true }
sea-query = { workspace = true, optional = true }
Expand Down
97 changes: 81 additions & 16 deletions cot/src/common_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ use cot::db::impl_postgres::PostgresValueRef;
#[cfg(feature = "sqlite")]
use cot::db::impl_sqlite::SqliteValueRef;
use email_address::EmailAddress;
#[cfg(not(miri))]
use secure_string::SecureString;
use subtle::ConstantTimeEq;

#[cfg(feature = "db")]
use crate::db::{ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, ToDbValue};
Expand Down Expand Up @@ -45,30 +48,50 @@ const MAX_EMAIL_LENGTH: u32 = 254;
/// compare it with the other password. This method uses constant-time
/// equality comparison, which protects against timing attacks.
///
/// 2. An alternative is to use the [`Password::as_str`] method and compare the
/// strings directly. This approach uses non-constant-time comparison, which
/// is less secure but may be acceptable in certain legitimate use cases
/// where the security tradeoff is understood, e.g., when you're creating a
/// user registration form with the "retype your password" field, where both
/// passwords come from the same source anyway.
/// 2. An alternative is to compare 2 instances of the [`Password`] type
/// directly because this password struct implements the [`PartialEq`] trait
/// which also uses constant-time comparison. Comparing 2 instances of the
/// [`Password`] type is less secure than using [`PasswordHash::verify`], but
/// may be acceptable in certain legitimate use cases where the security
/// tradeoff is understood, e.g., when you're creating a user registration
/// form with the "retype your password" field, in this case this approach
/// might save on hashing costs.
///
/// # Examples
///
/// ```
/// use cot::auth::Password;
///
/// let password = Password::new("pass");
/// assert_eq!(&format!("{:?}", password), "Password(\"**********\")");
/// assert_eq!(&format!("{:?}", password), "Password(***SECRET***)");
///
/// let another_password = Password::new("pass");
/// assert_eq!(password, another_password);
/// ```
#[derive(Clone)]
pub struct Password(String);

impl Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Password").field(&"**********").finish()
#[derive(Clone, Debug)]
#[cfg(not(miri))]
pub struct Password(SecureString);

impl PartialEq for Password {
fn eq(&self, other: &Self) -> bool {
self.as_str()
.as_bytes()
.ct_eq(other.as_str().as_bytes())
.into()
}
}

impl Eq for Password {}

/// A password - simplified version for Miri testing.
///
/// When running under Miri, we use a simple String wrapper instead of
/// SecureString to avoid the mlock system call that Miri doesn't support.
#[derive(Clone)]
#[cfg(miri)]
pub struct Password(String);

#[cfg(not(miri))]
impl Password {
/// Creates a new password object.
///
Expand All @@ -81,7 +104,7 @@ impl Password {
/// ```
#[must_use]
pub fn new<T: Into<String>>(password: T) -> Self {
Self(password.into())
Self(SecureString::from(password.into()))
}

/// Returns the password as a string.
Expand All @@ -96,7 +119,7 @@ impl Password {
/// ```
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
self.0.unsecure()
}

/// Consumes the object and returns the password as a string.
Expand All @@ -110,6 +133,27 @@ impl Password {
/// assert_eq!(password.into_string(), "password");
/// ```
#[must_use]
pub fn into_string(self) -> String {
self.0.into_unsecure()
}
}

#[cfg(miri)]
impl Password {
/// Creates a new password object - Miri version.
#[must_use]
pub fn new<T: Into<String>>(password: T) -> Self {
Self(password.into())
}

/// Returns the password as a string - Miri version.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}

/// Consumes the object and returns the password as a string - Miri version.
#[must_use]
pub fn into_string(self) -> String {
self.0
}
Expand All @@ -133,6 +177,13 @@ impl From<String> for Password {
}
}

#[cfg(miri)]
impl Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Password(***SECRET***)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using non-rustic outputs like trailing * can make things more complicated for downstream users. I would rather use write!(f, "Password(_)"), write!(f, "Password(..)"), or (my preference, because that's what you are supposed to used, if I'm not mistaken) f.debug_struct("Password").finish_non_exhaustive().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing that out, TIL about it! @eibrahim95 could you change it, please?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made it like this so it is the same as secure-string, if you notice this one is for miri, the other implementation derives Debug, and depends on SecureString, which implements Debug similarly to the above If that is important, I could change it for both, miri and not(miri)

}
}

/// A validated email address.
///
/// This is a newtype wrapper around
Expand Down Expand Up @@ -422,7 +473,21 @@ mod tests {
#[test]
fn password_debug() {
let password = Password::new("password");
assert_eq!(format!("{password:?}"), "Password(\"**********\")");
assert_eq!(format!("{password:?}"), "Password(***SECRET***)");
}

#[test]
fn password_eq() {
let password1 = Password::new("password");
let password2 = Password::new("password");
assert_eq!(password1, password2);
}

#[test]
fn password_ne() {
let password1 = Password::new("password");
let password2 = Password::new("password2");
assert_ne!(password1, password2);
}

#[test]
Expand Down
77 changes: 64 additions & 13 deletions cot/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use std::time::Duration;

use derive_builder::Builder;
use derive_more::with_trait::{Debug, From};
#[cfg(not(miri))]
use secure_string::SecureBytes;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;

Expand Down Expand Up @@ -819,11 +821,12 @@ impl SessionMiddlewareConfigBuilder {
///
/// # Security
///
/// The implementation of the [`PartialEq`] trait for this type is constant-time
/// to prevent timing attacks.
/// The implementation of the [`PartialEq`] trait for this type uses
/// constant-time comparison to prevent timing attacks.
///
/// The implementation of the [`Debug`] trait for this type hides the secret key
/// to prevent it from being leaked in logs or other debug output.
/// The implementation of the [`Debug`] trait for this type is inherited from
/// [`SecureBytes`], which hides the secret key to prevent it from being leaked
/// in logs or other debug output.
///
/// # Examples
///
Expand All @@ -834,10 +837,30 @@ impl SessionMiddlewareConfigBuilder {
/// assert_eq!(key.as_bytes(), &[1, 2, 3]);
/// ```
#[repr(transparent)]
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Deserialize, Debug)]
#[serde(from = "String")]
#[cfg(not(miri))]
pub struct SecretKey(SecureBytes);

/// A secret key - simplified version for Miri testing.
///
/// When running under Miri, we use a simple Box<[u8]> wrapper instead of
/// SecureBytes to avoid the mlock system call that Miri doesn't support.
#[repr(transparent)]
#[derive(Clone, Deserialize, Debug)]
#[serde(from = "String")]
#[cfg(miri)]
pub struct SecretKey(Box<[u8]>);

impl Serialize for SecretKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(self.as_bytes())
}
}

impl SecretKey {
/// Create a new [`SecretKey`] from a byte array.
///
Expand All @@ -850,6 +873,14 @@ impl SecretKey {
/// assert_eq!(key.as_bytes(), &[1, 2, 3]);
/// ```
#[must_use]
#[cfg(not(miri))]
pub fn new(hash: &[u8]) -> Self {
Self(SecureBytes::new(hash.to_vec()))
}

/// Create a new [`SecretKey`] from a byte array - Miri version.
#[must_use]
#[cfg(miri)]
pub fn new(hash: &[u8]) -> Self {
Self(Box::from(hash))
}
Expand All @@ -865,6 +896,14 @@ impl SecretKey {
/// assert_eq!(key.as_bytes(), &[1, 2, 3]);
/// ```
#[must_use]
#[cfg(not(miri))]
pub fn as_bytes(&self) -> &[u8] {
self.0.unsecure()
}

/// Get the byte array stored in the [`SecretKey`] - Miri version.
#[must_use]
#[cfg(miri)]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
Expand All @@ -880,6 +919,15 @@ impl SecretKey {
/// assert_eq!(key.into_bytes(), Box::from([1, 2, 3]));
/// ```
#[must_use]
#[cfg(not(miri))]
pub fn into_bytes(self) -> Box<[u8]> {
self.0.unsecure().to_vec().into_boxed_slice()
}

/// Consume the [`SecretKey`] and return the byte array stored in it - Miri
/// version.
#[must_use]
#[cfg(miri)]
pub fn into_bytes(self) -> Box<[u8]> {
self.0
}
Expand All @@ -905,19 +953,12 @@ impl From<&str> for SecretKey {

impl PartialEq for SecretKey {
fn eq(&self, other: &Self) -> bool {
self.0.ct_eq(&other.0).into()
self.as_bytes().ct_eq(other.as_bytes()).into()
}
}

impl Eq for SecretKey {}

impl Debug for SecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// write in single line, regardless whether alternate mode was used or not
write!(f, "SecretKey(\"**********\")")
}
}

impl Default for SecretKey {
fn default() -> Self {
Self::new(&[])
Expand Down Expand Up @@ -1004,6 +1045,8 @@ impl Debug for DatabaseUrl {

#[cfg(test)]
mod tests {
use serde_json;

use super::*;

#[test]
Expand Down Expand Up @@ -1078,4 +1121,12 @@ mod tests {
StaticFilesPathRewriteMode::QueryParam
);
}

#[test]
fn secret_key_serialize_json() {
let key = SecretKey::from("abc123");
let serialized = serde_json::to_string(&key).unwrap();
// Should serialize as a byte array
assert_eq!(serialized, "[97,98,99,49,50,51]");
}
}
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ allow = [
"0BSD",
"BSD-3-Clause",
"Zlib",
"Unlicense",
]
Loading