Skip to content

Commit 745386c

Browse files
committed
Introduces the confidant CLI
Introduces a CLI around the confidant library so that we can conveniently encrypt/decrypt streams of base64 values.
1 parent 5a94c34 commit 745386c

File tree

8 files changed

+407
-4
lines changed

8 files changed

+407
-4
lines changed

Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ resolver = "2"
44
members = [
55
"streambed",
66
"streambed-confidant",
7+
"streambed-confidant-cli",
78
"streambed-kafka",
89
"streambed-logged",
910
"streambed-logged-cli",
@@ -59,5 +60,6 @@ tokio-util = "0.7.4"
5960
warp = "0.3"
6061

6162
streambed = { path = "streambed", version = "0.10.2" }
63+
streambed-confidant = { path = "streambed-confidant", version = "0.10.2" }
6264
streambed-logged = { path = "streambed-logged", version = "0.10.2" }
6365
streambed-test = { path = "streambed-test", version = "0.10.2" }

streambed-confidant-cli/Cargo.toml

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "streambed-confidant-cli"
3+
version.workspace = true
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
description = "A CLI for a file-system-based secret store that applies streambed-crypto to data"
9+
10+
[dependencies]
11+
base64 = { workspace = true }
12+
clap = { workspace = true }
13+
env_logger = { workspace = true }
14+
hex = { workspace = true }
15+
humantime = { workspace = true }
16+
streambed = { workspace = true }
17+
streambed-confidant = { workspace = true }
18+
tokio = { workspace = true, features = ["full"] }
19+
tokio-stream = { workspace = true }
20+
21+
[[bin]]
22+
name = "confidant"
23+
path = "src/main.rs"

streambed-confidant-cli/README.md

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
confidant
2+
===
3+
4+
The `confidant` command provides a utility for conveniently operating on file-based secret store. It is
5+
often used in conjunction `logged` to encrypt payloads as a pre-stage, or decrypt as a post-stage. No
6+
assumption is made regarding the nature of a payload beyond it being encrypted using streambed crypto
7+
functions.
8+
9+
Running with an example encrypt followed by a decrypt
10+
---
11+
12+
First get the executable:
13+
14+
```
15+
cargo install streambed-confidant-cli
16+
```
17+
18+
...or build it from this repo:
19+
20+
```
21+
cargo build --bin loggconfidanted --release
22+
```
23+
24+
...and make a build available on the PATH (only for `cargo build` and just once per shell session):
25+
26+
```
27+
export PATH="$PWD/target/release":$PATH
28+
```
29+
30+
Use `--help` to discover all of the options.
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use std::io::{self, Write};
2+
3+
use streambed::secret_store::SecretStore;
4+
5+
use crate::errors::Errors;
6+
7+
pub async fn decrypt(
8+
ss: impl SecretStore,
9+
line_reader: impl FnMut() -> Result<Option<String>, io::Error>,
10+
output: impl Write,
11+
) -> Result<(), Errors> {
12+
encrypt(ss, line_reader, output).await
13+
}
14+
pub async fn encrypt(
15+
_ss: impl SecretStore,
16+
mut line_reader: impl FnMut() -> Result<Option<String>, io::Error>,
17+
mut _output: impl Write,
18+
) -> Result<(), Errors> {
19+
loop {
20+
if let Some(_line) = line_reader()? {
21+
} else {
22+
break;
23+
}
24+
}
25+
// let lines = input.lines();
26+
// for record in lines {
27+
// // let record = ProducerRecord {
28+
// // topic: record.topic,
29+
// // headers: record.headers,
30+
// // timestamp: record.timestamp,
31+
// // key: record.key,
32+
// // value: record.value,
33+
// // partition: record.partition,
34+
// // };
35+
// // cl.produce(record).await.map_err(Errors::Producer)?;
36+
// }
37+
Ok(())
38+
}

streambed-confidant-cli/src/errors.rs

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
use std::{
2+
error::Error,
3+
fmt::{self, Debug},
4+
io,
5+
};
6+
7+
use streambed::secret_store;
8+
9+
#[derive(Debug)]
10+
pub enum Errors {
11+
CannotDecodeRootSecretAsHex,
12+
EmptyRootSecretFile,
13+
EmptySecretIdFile,
14+
Io(io::Error),
15+
RootSecretFileIo(io::Error),
16+
SecretIdFileIo(io::Error),
17+
SecretStore(secret_store::Error),
18+
}
19+
20+
impl From<io::Error> for Errors {
21+
fn from(value: io::Error) -> Self {
22+
Self::Io(value)
23+
}
24+
}
25+
26+
impl fmt::Display for Errors {
27+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28+
match self {
29+
Self::CannotDecodeRootSecretAsHex => {
30+
f.write_str("Cannot decode the root-secret as hex")
31+
}
32+
Self::EmptyRootSecretFile => f.write_str("Empty root-secret file"),
33+
Self::EmptySecretIdFile => f.write_str("Empty secret-id file"),
34+
Self::Io(e) => fmt::Display::fmt(&e, f),
35+
Self::SecretStore(_) => f.write_str("Unauthorised access"),
36+
Self::RootSecretFileIo(_) => f.write_str("root-secret file problem"),
37+
Self::SecretIdFileIo(_) => f.write_str("secret-id file problem"),
38+
}
39+
}
40+
}
41+
42+
impl Error for Errors {
43+
fn source(&self) -> Option<&(dyn Error + 'static)> {
44+
match self {
45+
Self::CannotDecodeRootSecretAsHex
46+
| Self::EmptyRootSecretFile
47+
| Self::EmptySecretIdFile
48+
| Self::SecretStore(_) => None,
49+
Self::Io(e) => e.source(),
50+
Self::RootSecretFileIo(e) => e.source(),
51+
Self::SecretIdFileIo(e) => e.source(),
52+
}
53+
}
54+
}

streambed-confidant-cli/src/main.rs

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
use std::{
2+
error::Error,
3+
fs::{self, File},
4+
io::{self, BufRead, BufReader, BufWriter, Write},
5+
path::PathBuf,
6+
time::Duration,
7+
};
8+
9+
use clap::{Args, Parser, Subcommand};
10+
use errors::Errors;
11+
use streambed::secret_store::SecretStore;
12+
use streambed_confidant::FileSecretStore;
13+
14+
pub mod cryptor;
15+
pub mod errors;
16+
17+
/// The `confidant` command provides a utility for conveniently operating on file-based secret store. It is
18+
/// often used in conjunction `logged` to encrypt payloads as a pre-stage, or decrypt as a post-stage. No
19+
/// assumption is made regarding the nature of a value passed in beyond it to be encrypted/decrypted using streambed crypto
20+
/// functions. This value is expected to be encoded as BASE64 and will output as encoded BASE64.
21+
#[derive(Parser, Debug)]
22+
#[clap(author, about, long_about = None, version)]
23+
struct ProgramArgs {
24+
/// In order to initialise the secret store, a root secret is also required. A credentials-directory path can be provided
25+
/// where a `root-secret`` file is expected. This argument corresponds conveniently with systemd's CREDENTIALS_DIRECTORY
26+
/// environment variable and is used by various services we have written.
27+
/// Also associated with this argument is the `secret_id` file` for role-based authentication with the secret store.
28+
/// This secret is expected to be found in a ss-secret-id file of the directory.
29+
#[clap(env, long, default_value = "/tmp")]
30+
pub credentials_directory: PathBuf,
31+
32+
/// The max number of Vault Secret Store secrets to retain by our cache at any time.
33+
/// Least Recently Used (LRU) secrets will be evicted from our cache once this value
34+
/// is exceeded.
35+
#[clap(env, long, default_value_t = 10_000)]
36+
pub max_secrets: usize,
37+
38+
/// The Secret Store role_id to use for approle authentication.
39+
#[clap(env, long, default_value = "streambed-confidant-cli")]
40+
pub role_id: String,
41+
42+
/// The location of all secrets belonging to confidant. The recommendation is to
43+
/// create a user for confidant and a requirement is to remove group and world permissions.
44+
/// Then, use ACLs to express further access conditions.
45+
#[clap(env, long, default_value = "/var/lib/confidant")]
46+
pub root_path: PathBuf,
47+
48+
/// A data field to used in place of Vault's lease_duration field. Time
49+
/// will be interpreted as a humantime string e.g. "1m", "1s" etc. Note
50+
/// that v2 of the Vault server does not appear to populate the lease_duration
51+
/// field for the KV secret store any longer. Instead, we can use a "ttl" field
52+
/// from the data.
53+
#[clap(env, long)]
54+
pub ttl_field: Option<String>,
55+
56+
/// How long we wait until re-requesting the Secret Store for an
57+
/// unauthorized secret again.
58+
#[clap(env, long, default_value = "1m")]
59+
pub unauthorized_timeout: humantime::Duration,
60+
61+
#[command(subcommand)]
62+
pub command: Command,
63+
}
64+
65+
#[derive(Subcommand, Debug)]
66+
enum Command {
67+
Decrypt(DecryptCommand),
68+
Encrypt(EncryptCommand),
69+
}
70+
71+
/// Consume a BASE64 value followed by a newline character, from a stream until EOF and decrypt it.
72+
#[derive(Args, Debug)]
73+
struct DecryptCommand {
74+
/// The file to consume BASE64 records with newlines from, or `-` to indicate STDIN.
75+
#[clap(env, short, long)]
76+
pub file: PathBuf,
77+
78+
/// By default, records are output to STDOUT as a BASE64 values followed newlines.
79+
/// This option can be used to write to a file.
80+
#[clap(env, short, long)]
81+
pub output: Option<PathBuf>,
82+
}
83+
84+
/// Consume a BASE64 value followed by a newline character, from a stream until EOF and encrypt it.
85+
#[derive(Args, Debug)]
86+
struct EncryptCommand {
87+
/// The file to consume BASE64 records with newlines from, or `-` to indicate STDIN.
88+
#[clap(env, short, long)]
89+
pub file: PathBuf,
90+
91+
/// By default, records are output to STDOUT as a BASE64 values followed newlines.
92+
/// This option can be used to write to a file.
93+
#[clap(env, short, long)]
94+
pub output: Option<PathBuf>,
95+
}
96+
97+
async fn secret_store(
98+
credentials_directory: PathBuf,
99+
max_secrets: usize,
100+
root_path: PathBuf,
101+
role_id: String,
102+
ttl_field: Option<&str>,
103+
unauthorized_timeout: Duration,
104+
) -> Result<impl SecretStore, Errors> {
105+
let ss = {
106+
let (root_secret, ss_secret_id) = {
107+
let f = File::open(credentials_directory.join("root-secret"))
108+
.map_err(|e| Errors::RootSecretFileIo(e))?;
109+
let f = BufReader::new(f);
110+
let root_secret = f
111+
.lines()
112+
.next()
113+
.ok_or(Errors::EmptyRootSecretFile)?
114+
.map_err(|e| Errors::RootSecretFileIo(e))?;
115+
116+
let f = File::open(credentials_directory.join("ss-secret-id"))
117+
.map_err(|e| Errors::SecretIdFileIo(e))?;
118+
let f = BufReader::new(f);
119+
let ss_secret_id = f
120+
.lines()
121+
.next()
122+
.ok_or(Errors::EmptyRootSecretFile)?
123+
.map_err(|e| Errors::SecretIdFileIo(e))?;
124+
125+
(root_secret, ss_secret_id)
126+
};
127+
128+
let root_secret =
129+
hex::decode(root_secret).map_err(|_| Errors::CannotDecodeRootSecretAsHex)?;
130+
131+
let ss = FileSecretStore::new(
132+
root_path,
133+
&root_secret.try_into().unwrap(),
134+
unauthorized_timeout,
135+
max_secrets,
136+
ttl_field,
137+
);
138+
139+
ss.approle_auth(&role_id, &ss_secret_id)
140+
.await
141+
.map_err(|e| Errors::SecretStore(e))?;
142+
143+
ss
144+
};
145+
Ok(ss)
146+
}
147+
148+
fn pipeline(
149+
file: PathBuf,
150+
output: Option<PathBuf>,
151+
) -> Result<
152+
(
153+
Box<dyn FnMut() -> Result<Option<String>, io::Error> + Send>,
154+
Box<dyn Write + Send>,
155+
),
156+
Errors,
157+
> {
158+
let line_reader: Box<dyn FnMut() -> Result<Option<String>, io::Error> + Send> =
159+
if file.as_os_str() == "-" {
160+
let stdin = io::stdin();
161+
let mut line = String::new();
162+
Box::new(move || {
163+
line.clear();
164+
if stdin.read_line(&mut line)? > 0 {
165+
Ok(Some(line.clone()))
166+
} else {
167+
Ok(None)
168+
}
169+
})
170+
} else {
171+
let mut buf = BufReader::new(fs::File::open(file).map_err(Errors::from)?);
172+
let mut line = String::new();
173+
Box::new(move || {
174+
line.clear();
175+
if buf.read_line(&mut line)? > 0 {
176+
Ok(Some(line.clone()))
177+
} else {
178+
Ok(None)
179+
}
180+
})
181+
};
182+
let output: Box<dyn Write + Send> = if let Some(output) = output {
183+
Box::new(BufWriter::new(
184+
fs::File::create(output).map_err(Errors::from)?,
185+
))
186+
} else {
187+
Box::new(io::stdout())
188+
};
189+
Ok((line_reader, output))
190+
}
191+
192+
#[tokio::main]
193+
async fn main() -> Result<(), Box<dyn Error>> {
194+
let args = ProgramArgs::parse();
195+
196+
env_logger::builder().format_timestamp_millis().init();
197+
198+
let ss = secret_store(
199+
args.credentials_directory,
200+
args.max_secrets,
201+
args.root_path,
202+
args.role_id,
203+
args.ttl_field.as_deref(),
204+
args.unauthorized_timeout.into(),
205+
)
206+
.await?;
207+
208+
let task = tokio::spawn(async move {
209+
match args.command {
210+
Command::Decrypt(command) => {
211+
let (line_reader, output) = pipeline(command.file, command.output)?;
212+
cryptor::decrypt(ss, line_reader, output).await
213+
}
214+
Command::Encrypt(command) => {
215+
let (line_reader, output) = pipeline(command.file, command.output)?;
216+
cryptor::encrypt(ss, line_reader, output).await
217+
}
218+
}
219+
});
220+
221+
task.await
222+
.map_err(|e| e.into())
223+
.and_then(|r: Result<(), Errors>| r.map_err(|e| e.into()))
224+
}

0 commit comments

Comments
 (0)