Skip to content

Commit 69ab83d

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 69ab83d

File tree

10 files changed

+690
-21
lines changed

10 files changed

+690
-21
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

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
rand = { workspace = true }
17+
serde_json = { workspace = true }
18+
streambed = { workspace = true }
19+
streambed-confidant = { workspace = true }
20+
tokio = { workspace = true, features = ["full"] }
21+
tokio-stream = { workspace = true }
22+
23+
[[bin]]
24+
name = "confidant"
25+
path = "src/main.rs"

streambed-confidant-cli/README.md

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 confidant --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+
Before you can use `confidant`, you must provide it with a root secret and a "secret id" (password)
31+
to authenticate the session. Here's an example with some dummy data:
32+
33+
```
34+
echo "1800af9b273e4b9ea71ec723426933a4" > /tmp/root-secret
35+
echo "unused-id" > /tmp/ss-secret-id
36+
```
37+
38+
We also need to create a directory for `confidant` to read and write its secrets. A security feature
39+
of the `confidant` library is that the directory must have a permission of `600` for the owner user.
40+
ACLs should then be used to control access for individual processes. Here's how the directory can be
41+
created:
42+
43+
```
44+
mkdir /tmp/confidant
45+
chmod 700 /tmp/confidant
46+
```
47+
48+
You would normally source the above secrets from your production system, preferably without
49+
them leaving your production system.
50+
51+
Given the root secret, encrypt some data :
52+
53+
```
54+
echo '{"value":"SGkgdGhlcmU="}' | \
55+
confidant --root-path=/tmp/confidant encrypt --file - --path="default/my-secret-path"
56+
```
57+
58+
...which would output:
59+
60+
```
61+
{"value":"EZy4HLnFC4c/W63Qtp288WWFj8U="}
62+
```
63+
64+
That value is now encrypted with a salt.
65+
66+
We can also decrypt in a similar fashion:
67+
68+
```
69+
echo '{"value":"EZy4HLnFC4c/W63Qtp288WWFj8U="}' | \
70+
confidant --root-path=/tmp/confidant decrypt --file - --path="default/my-secret-path"
71+
```
72+
73+
...which will yield the original BASE64 value that we encrypted.
74+
75+
Use `--help` to discover all of the options.
+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use std::{
2+
collections::HashMap,
3+
io::{self, Write},
4+
time::Duration,
5+
};
6+
7+
use rand::RngCore;
8+
use serde_json::Value;
9+
use streambed::{
10+
crypto::{self, SALT_SIZE},
11+
get_secret_value,
12+
secret_store::{SecretData, SecretStore},
13+
};
14+
use tokio::{sync::mpsc::channel, time};
15+
16+
use crate::errors::Errors;
17+
18+
const FLUSH_DELAY: Duration = Duration::from_millis(100);
19+
const OUTPUT_QUEUE_SIZE: usize = 10;
20+
21+
pub async fn process_records(
22+
ss: impl SecretStore,
23+
mut line_reader: impl FnMut() -> Result<Option<String>, io::Error>,
24+
mut output: impl Write,
25+
path: &str,
26+
process: fn(Vec<u8>, Vec<u8>) -> Option<Vec<u8>>,
27+
select: &str,
28+
) -> Result<(), Errors> {
29+
let (output_tx, mut output_rx) = channel(OUTPUT_QUEUE_SIZE);
30+
31+
let processor = async move {
32+
while let Some(line) = line_reader()? {
33+
let mut record = serde_json::from_str::<Value>(&line).map_err(Errors::from)?;
34+
let Some(value) = record.get_mut(select) else {
35+
return Err(Errors::CannotSelectValue);
36+
};
37+
let Some(str_value) = value.as_str() else {
38+
return Err(Errors::CannotGetValue);
39+
};
40+
let Ok(bytes) = base64::decode(str_value) else {
41+
return Err(Errors::CannotDecodeValue);
42+
};
43+
let decrypted_bytes = get_secret_value(&ss, path)
44+
.await
45+
.and_then(|secret_value| {
46+
let key = hex::decode(secret_value).ok()?;
47+
process(key, bytes)
48+
})
49+
.ok_or(Errors::CannotDecryptValue)?;
50+
let encoded_decrypted_str = base64::encode(decrypted_bytes);
51+
*value = Value::String(encoded_decrypted_str);
52+
let _ = output_tx.send(record).await;
53+
}
54+
55+
Ok(())
56+
};
57+
58+
let outputter = async move {
59+
let mut timeout = FLUSH_DELAY;
60+
loop {
61+
tokio::select! {
62+
record = output_rx.recv() => {
63+
if let Some(record) = record {
64+
let buf = serde_json::to_string(&record)?;
65+
output.write_all(buf.as_bytes())?;
66+
output.write_all(b"\n")?;
67+
timeout = FLUSH_DELAY;
68+
} else {
69+
break;
70+
}
71+
}
72+
_ = time::sleep(timeout) => {
73+
output.flush()?;
74+
timeout = Duration::MAX;
75+
}
76+
}
77+
}
78+
Ok(())
79+
};
80+
81+
tokio::try_join!(processor, outputter).map(|_| ())
82+
}
83+
84+
pub async fn decrypt(
85+
ss: impl SecretStore,
86+
line_reader: impl FnMut() -> Result<Option<String>, io::Error>,
87+
output: impl Write,
88+
path: &str,
89+
select: &str,
90+
) -> Result<(), Errors> {
91+
fn process(key: Vec<u8>, mut bytes: Vec<u8>) -> Option<Vec<u8>> {
92+
let (salt, data_bytes) = bytes.split_at_mut(crypto::SALT_SIZE);
93+
crypto::decrypt(data_bytes, &key.try_into().ok()?, &salt.try_into().ok()?);
94+
Some(data_bytes.to_vec())
95+
}
96+
process_records(ss, line_reader, output, path, process, select).await
97+
}
98+
99+
pub async fn encrypt(
100+
ss: impl SecretStore,
101+
line_reader: impl FnMut() -> Result<Option<String>, io::Error>,
102+
output: impl Write,
103+
path: &str,
104+
select: &str,
105+
) -> Result<(), Errors> {
106+
// As a convenience, we create the secret when encrypting if there
107+
// isn't one.
108+
if get_secret_value(&ss, path).await.is_none() {
109+
let mut key = vec![0; 16];
110+
rand::thread_rng().fill_bytes(&mut key);
111+
let data = HashMap::from([("value".to_string(), hex::encode(key))]);
112+
ss.create_secret(path, SecretData { data })
113+
.await
114+
.map_err(Errors::SecretStore)?;
115+
}
116+
117+
fn process(key: Vec<u8>, mut data_bytes: Vec<u8>) -> Option<Vec<u8>> {
118+
let salt = crypto::salt(&mut rand::thread_rng());
119+
crypto::encrypt(&mut data_bytes, &key.try_into().ok()?, &salt);
120+
let mut buf = Vec::with_capacity(SALT_SIZE + data_bytes.len());
121+
buf.extend(salt);
122+
buf.extend(data_bytes);
123+
Some(buf)
124+
}
125+
process_records(ss, line_reader, output, path, process, select).await
126+
}

streambed-confidant-cli/src/errors.rs

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
CannotDecodeValue,
13+
CannotDecryptValue,
14+
CannotEncryptValue,
15+
CannotGetValue,
16+
CannotSelectValue,
17+
EmptyRootSecretFile,
18+
EmptySecretIdFile,
19+
InvalidRootSecret,
20+
InvalidSaltLen,
21+
Io(io::Error),
22+
RootSecretFileIo(io::Error),
23+
SecretIdFileIo(io::Error),
24+
SecretStore(secret_store::Error),
25+
Serde(serde_json::Error),
26+
}
27+
28+
impl From<io::Error> for Errors {
29+
fn from(value: io::Error) -> Self {
30+
Self::Io(value)
31+
}
32+
}
33+
34+
impl From<serde_json::Error> for Errors {
35+
fn from(value: serde_json::Error) -> Self {
36+
Self::Serde(value)
37+
}
38+
}
39+
40+
impl fmt::Display for Errors {
41+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42+
match self {
43+
Self::CannotDecodeRootSecretAsHex => {
44+
f.write_str("Cannot decode the root-secret as hex")
45+
}
46+
Self::CannotDecodeValue => {
47+
f.write_str("Cannot decode the selected value of the JSON object")
48+
}
49+
Self::CannotDecryptValue => {
50+
f.write_str("Cannot decrypt the selected value of the JSON object")
51+
}
52+
Self::CannotEncryptValue => {
53+
f.write_str("Cannot encrypt the selected value of the JSON object")
54+
}
55+
Self::CannotGetValue => {
56+
f.write_str("Cannot get the selected value of the selected field")
57+
}
58+
Self::CannotSelectValue => {
59+
f.write_str("Cannot select the value within the JSON object")
60+
}
61+
Self::EmptyRootSecretFile => f.write_str("Empty root-secret file"),
62+
Self::EmptySecretIdFile => f.write_str("Empty secret-id file"),
63+
Self::InvalidRootSecret => {
64+
f.write_str("Invalid root secret - must be a hex string of 32 characters")
65+
}
66+
Self::InvalidSaltLen => f.write_str("Invalid salt length - must be 12 bytes"),
67+
Self::Io(e) => fmt::Display::fmt(&e, f),
68+
Self::SecretStore(_) => f.write_str("Unauthorised access"),
69+
Self::RootSecretFileIo(_) => f.write_str("root-secret file problem"),
70+
Self::SecretIdFileIo(_) => f.write_str("secret-id file problem"),
71+
Self::Serde(e) => fmt::Display::fmt(&e, f),
72+
}
73+
}
74+
}
75+
76+
impl Error for Errors {
77+
fn source(&self) -> Option<&(dyn Error + 'static)> {
78+
match self {
79+
Self::CannotDecodeValue
80+
| Self::CannotDecodeRootSecretAsHex
81+
| Self::CannotDecryptValue
82+
| Self::CannotEncryptValue
83+
| Self::CannotGetValue
84+
| Self::CannotSelectValue
85+
| Self::EmptyRootSecretFile
86+
| Self::EmptySecretIdFile
87+
| Self::InvalidRootSecret
88+
| Self::InvalidSaltLen
89+
| Self::SecretStore(_) => None,
90+
Self::Io(e) => e.source(),
91+
Self::RootSecretFileIo(e) => e.source(),
92+
Self::SecretIdFileIo(e) => e.source(),
93+
Self::Serde(e) => e.source(),
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)