Skip to content

Commit 2981fc8

Browse files
committed
feat: [#243] introduce SecretString-based secret types with folder module structure
1 parent 174eebc commit 2981fc8

File tree

21 files changed

+449
-58
lines changed

21 files changed

+449
-58
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ parking_lot = "0.12"
5151
reqwest = "0.12"
5252
rust-embed = "8.0"
5353
schemars = "1.1"
54+
secrecy = { version = "0.10", features = [ "serde" ] }
5455
serde = { version = "1.0", features = [ "derive" ] }
5556
serde_json = "1.0"
5657
tempfile = "3.0"

docs/issues/243-introduce-secret-type-for-sensitive-data.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55

66
## Overview
77

8-
Replace all primitive `String` types used for sensitive data (API tokens, passwords, database credentials) with the industry-standard `secrecy` crate's `Secret<String>` type. This enhances security by preventing accidental exposure through logging/debug output and enabling automatic memory zeroing.
8+
Replace all primitive `String` types used for sensitive data (API tokens, passwords, database credentials) with wrapper types based on the industry-standard `secrecy` crate's `SecretString` type. This enhances security by preventing accidental exposure through logging/debug output and enabling automatic memory zeroing.
9+
10+
**Implementation Approach**: We use `secrecy::SecretString` as the foundation (which is a type alias for `SecretBox<str>`) and wrap it in domain-specific types (`ApiToken`, `Password`) that add:
11+
12+
- Serialization support (secrecy intentionally doesn't serialize secrets by default)
13+
- PartialEq/Eq for config comparison in tests
14+
- Domain-specific type safety
915

1016
## Refactor Plan
1117

@@ -20,9 +26,9 @@ The plan includes:
2026

2127
## Goals
2228

23-
- [ ] Replace 16 string-based secret fields with `Secret<String>` type
24-
- [ ] Prevent accidental secret exposure in logs and debug output
25-
- [ ] Enable secure memory zeroing for sensitive data
29+
- [ ] Replace 16 string-based secret fields with `ApiToken`/`Password` wrapper types
30+
- [ ] Leverage `SecretString` for automatic debug redaction and memory zeroing
31+
- [ ] Add serialization support for config file generation (deployment tool needs this)
2632
- [ ] Update documentation with secret handling guidelines
2733

2834
## Acceptance Criteria
@@ -35,7 +41,8 @@ The plan includes:
3541

3642
**Task-Specific Criteria**:
3743

38-
- [ ] All 16 secret fields converted to `Secret<String>` (tracked in refactor plan)
44+
- [ ] All 16 secret fields converted to `ApiToken`/`Password` types (tracked in refactor plan)
45+
- [ ] Wrapper types use `SecretString` internally for debug redaction and memory zeroing
3946
- [ ] No secrets appear in debug/display output
4047
- [ ] All unit tests pass with updated secret types
4148
- [ ] All E2E tests pass with secret handling

docs/refactors/plans/secret-type-introduction.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
## 📋 Overview
44

5-
Introduce the `secrecy` crate's `Secret<T>` type to replace primitive `String` types for sensitive data throughout the codebase. This refactoring enhances security by clearly identifying secret values, preventing accidental exposure through logging or debugging, and ensuring secrets are securely wiped from memory when dropped.
5+
Introduce wrapper types based on the `secrecy` crate's `SecretString` to replace primitive `String` types for sensitive data throughout the codebase. This refactoring enhances security by clearly identifying secret values, preventing accidental exposure through logging or debugging, and ensuring secrets are securely wiped from memory when dropped.
66

7-
**Decision**: After evaluating custom implementation vs industry-standard solutions, we chose the `secrecy` crate (see [ADR](../../decisions/secrecy-crate-for-sensitive-data.md)).
7+
**Decision**: After evaluating custom implementation vs industry-standard solutions, we chose to use `secrecy::SecretString` as the foundation with thin wrapper types (see [ADR](../../decisions/secrecy-crate-for-sensitive-data.md)).
8+
9+
**Implementation Note**: `SecretString` (which is `SecretBox<str>`) provides debug redaction and memory zeroing but cannot implement `Serialize` (requires `SerializableSecret` trait which `str` doesn't implement due to orphan rules). Our wrapper types (`ApiToken`, `Password`) add serialization support needed for config file generation in this deployment tool.
810

911
**Target Files:**
1012

@@ -20,12 +22,12 @@ Introduce the `secrecy` crate's `Secret<T>` type to replace primitive `String` t
2022

2123
**Scope:**
2224

23-
- Add `secrecy` crate dependency to project
24-
- Create `src/shared/secret.rs` module (re-exports and type aliases)
25-
- Implement `SerializableSecret` marker trait for `String`
26-
- Replace all `String` fields containing secrets with `Secret<String>` type
27-
- Update all tests to use `Secret::new()` and `expose_secret()`
28-
- Verify debug output redacts secrets
25+
- Add `secrecy` crate dependency to project (with `serde` feature)
26+
- Create `src/shared/secret.rs` module with `ApiToken` and `Password` wrappers around `SecretString`
27+
- Wrappers add: serialization support, PartialEq/Eq for testing, domain-specific types
28+
- Replace all `String` fields containing secrets with `ApiToken`/`Password` types
29+
- Update all tests to use `ApiToken::from()` / `Password::from()` and `.expose_secret()`
30+
- Verify debug output redacts secrets (provided by `SecretString`)
2931
- Document decision in ADR (already completed)
3032
- Update AI agent instructions
3133

schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"type": "string"
6767
},
6868
"password": {
69-
"description": "Database password",
69+
"description": "Database password (plain text during DTO serialization/deserialization)\n\nUses `PlainPassword` type alias to explicitly mark this as a temporarily visible secret.\nConverted to secure `Password` type in `to_database_config()` at the DTO-to-domain boundary.",
7070
"type": "string"
7171
},
7272
"port": {
@@ -118,7 +118,7 @@
118118
"type": "object",
119119
"properties": {
120120
"api_token": {
121-
"description": "Hetzner API token (raw string).",
121+
"description": "Hetzner API token in plain text format (DTO layer).\n\nThis uses [`PlainApiToken`] to mark it as a transparent secret during\ndeserialization. Convert to domain `ApiToken` at the DTO-to-domain boundary.",
122122
"type": "string"
123123
},
124124
"image": {

src/application/command_handlers/create/config/provider/hetzner.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use schemars::JsonSchema;
88
use serde::{Deserialize, Serialize};
99

10+
use crate::shared::PlainApiToken;
11+
1012
/// Hetzner-specific configuration section
1113
///
1214
/// Uses raw `String` fields for JSON deserialization. Convert to domain
@@ -26,8 +28,11 @@ use serde::{Deserialize, Serialize};
2628
/// ```
2729
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2830
pub struct HetznerProviderSection {
29-
/// Hetzner API token (raw string).
30-
pub api_token: String,
31+
/// Hetzner API token in plain text format (DTO layer).
32+
///
33+
/// This uses [`PlainApiToken`] to mark it as a transparent secret during
34+
/// deserialization. Convert to domain `ApiToken` at the DTO-to-domain boundary.
35+
pub api_token: PlainApiToken,
3136

3237
/// Hetzner server type (e.g., "cx22", "cx32", "cpx11").
3338
pub server_type: String,

src/application/command_handlers/create/config/provider/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ use serde::{Deserialize, Serialize};
4646
use crate::application::command_handlers::create::config::CreateConfigError;
4747
use crate::domain::provider::{HetznerConfig, LxdConfig, Provider, ProviderConfig};
4848
use crate::domain::ProfileName;
49+
use crate::shared::ApiToken;
4950

5051
/// Provider-specific configuration section
5152
///
@@ -147,7 +148,7 @@ impl ProviderSection {
147148
Self::Hetzner(hetzner) => {
148149
// Note: Future improvement could add validation for these fields
149150
Ok(ProviderConfig::Hetzner(HetznerConfig {
150-
api_token: hetzner.api_token,
151+
api_token: ApiToken::from(hetzner.api_token),
151152
server_type: hetzner.server_type,
152153
location: hetzner.location,
153154
image: hetzner.image,
@@ -266,7 +267,7 @@ mod tests {
266267
assert_eq!(config.provider_name(), "hetzner");
267268

268269
let hetzner = config.as_hetzner().unwrap();
269-
assert_eq!(hetzner.api_token, "test-token");
270+
assert_eq!(hetzner.api_token.expose_secret(), "test-token");
270271
assert_eq!(hetzner.server_type, "cx22");
271272
assert_eq!(hetzner.location, "nbg1");
272273
assert_eq!(hetzner.image, "ubuntu-24.04");

src/application/command_handlers/create/config/tracker/tracker_core_section.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
99

1010
use crate::application::command_handlers::create::config::errors::CreateConfigError;
1111
use crate::domain::tracker::{DatabaseConfig, MysqlConfig, SqliteConfig, TrackerCoreConfig};
12+
use crate::shared::{Password, PlainPassword};
1213

1314
/// Database configuration section (application DTO)
1415
///
@@ -54,8 +55,11 @@ pub enum DatabaseSection {
5455
database_name: String,
5556
/// Database username
5657
username: String,
57-
/// Database password
58-
password: String,
58+
/// Database password (plain text during DTO serialization/deserialization)
59+
///
60+
/// Uses `PlainPassword` type alias to explicitly mark this as a temporarily visible secret.
61+
/// Converted to secure `Password` type in `to_database_config()` at the DTO-to-domain boundary.
62+
password: PlainPassword,
5963
},
6064
}
6165

@@ -83,7 +87,7 @@ impl DatabaseSection {
8387
port: *port,
8488
database_name: database_name.clone(),
8589
username: username.clone(),
86-
password: password.clone(),
90+
password: Password::from(password.as_str()),
8791
})),
8892
}
8993
}
@@ -222,7 +226,7 @@ mod tests {
222226
port: 3306,
223227
database_name: "tracker".to_string(),
224228
username: "tracker_user".to_string(),
225-
password: "secure_password".to_string(),
229+
password: Password::from("secure_password"),
226230
})
227231
);
228232
assert!(!config.private);

src/application/steps/rendering/docker_compose_templates.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use crate::infrastructure::templating::docker_compose::template::wrappers::env::
3939
use crate::infrastructure::templating::docker_compose::{
4040
DockerComposeProjectGenerator, DockerComposeProjectGeneratorError,
4141
};
42+
use crate::shared::PlainPassword;
4243

4344
/// Step that renders Docker Compose templates to the build directory
4445
///
@@ -118,7 +119,7 @@ impl<S> RenderDockerComposeTemplatesStep<S> {
118119
mysql_config.port,
119120
mysql_config.database_name.clone(),
120121
mysql_config.username.clone(),
121-
mysql_config.password.clone(),
122+
mysql_config.password.expose_secret().to_string(),
122123
),
123124
};
124125

@@ -172,7 +173,7 @@ impl<S> RenderDockerComposeTemplatesStep<S> {
172173
port: u16,
173174
database_name: String,
174175
username: String,
175-
password: String,
176+
password: PlainPassword,
176177
) -> (EnvContext, DockerComposeContextBuilder) {
177178
// For MySQL, generate a secure root password (in production, this should be managed securely)
178179
let root_password = format!("{password}_root");

src/domain/environment/user_inputs.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ mod tests {
257257
use super::*;
258258
use crate::domain::provider::LxdConfig;
259259
use crate::domain::ProfileName;
260-
use crate::shared::Username;
260+
use crate::shared::{ApiToken, Username};
261261
use std::path::PathBuf;
262262

263263
fn create_test_ssh_credentials() -> SshCredentials {
@@ -310,7 +310,7 @@ mod tests {
310310

311311
let env_name = EnvironmentName::new("test-env".to_string()).unwrap();
312312
let provider_config = ProviderConfig::Hetzner(HetznerConfig {
313-
api_token: "test-token".to_string(),
313+
api_token: ApiToken::from("test-token"),
314314
server_type: "cx22".to_string(),
315315
location: "nbg1".to_string(),
316316
image: "ubuntu-24.04".to_string(),
@@ -323,7 +323,7 @@ mod tests {
323323
assert!(user_inputs.provider_config().as_lxd().is_none());
324324

325325
let hetzner_config = user_inputs.provider_config().as_hetzner().unwrap();
326-
assert_eq!(hetzner_config.api_token, "test-token");
326+
assert_eq!(hetzner_config.api_token.expose_secret(), "test-token");
327327
assert_eq!(hetzner_config.server_type, "cx22");
328328
assert_eq!(hetzner_config.location, "nbg1");
329329
assert_eq!(hetzner_config.image, "ubuntu-24.04");

src/domain/provider/config.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,15 @@ impl ProviderConfig {
118118
/// ```rust
119119
/// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, HetznerConfig};
120120
/// use torrust_tracker_deployer_lib::domain::ProfileName;
121+
/// use torrust_tracker_deployer_lib::shared::secrets::ApiToken;
121122
///
122123
/// let lxd_config = ProviderConfig::Lxd(LxdConfig {
123124
/// profile_name: ProfileName::new("test").unwrap(),
124125
/// });
125126
/// assert!(lxd_config.as_lxd().is_some());
126127
///
127128
/// let hetzner_config = ProviderConfig::Hetzner(HetznerConfig {
128-
/// api_token: "token".to_string(),
129+
/// api_token: ApiToken::from("token"),
129130
/// server_type: "cx22".to_string(),
130131
/// location: "nbg1".to_string(),
131132
/// image: "ubuntu-24.04".to_string(),
@@ -167,8 +168,10 @@ mod tests {
167168
}
168169

169170
fn create_hetzner_config() -> ProviderConfig {
171+
use crate::shared::ApiToken;
172+
170173
ProviderConfig::Hetzner(HetznerConfig {
171-
api_token: "test-token".to_string(),
174+
api_token: ApiToken::from("test-token"),
172175
server_type: "cx22".to_string(),
173176
location: "nbg1".to_string(),
174177
image: "ubuntu-24.04".to_string(),
@@ -242,7 +245,7 @@ mod tests {
242245

243246
assert_eq!(config.provider(), Provider::Hetzner);
244247
let hetzner = config.as_hetzner().unwrap();
245-
assert_eq!(hetzner.api_token, "token");
248+
assert_eq!(hetzner.api_token.expose_secret(), "token");
246249
assert_eq!(hetzner.server_type, "cx22");
247250
assert_eq!(hetzner.location, "nbg1");
248251
assert_eq!(hetzner.image, "ubuntu-24.04");

0 commit comments

Comments
 (0)