Initial commit

master
Dustin 2024-01-10 13:53:21 -06:00
commit 0355e24e23
8 changed files with 2860 additions and 0 deletions

3
.containerignore Normal file
View File

@ -0,0 +1,3 @@
*
!src
!Cargo.*

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/age-keys
/target
key-map.yml
master.key
trusted-ca.keys

2428
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "keyserv"
version = "0.1.0"
description = "age key dispenser"
homepage = "https://git.pyrocufflink.net/dustin/keyserv"
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
edition = "2021"
[dependencies]
age = { version = "0.9.2", features = ["ssh", "armor"] }
argh = "0.1.12"
axum = { version = "0.7.3", default-features = false, features = ["http1", "tracing", "tokio"] }
secrecy = "0.8.0"
ssh-key = { version = "0.6.3", features = ["ed25519"] }
thiserror = "1.0.56"
tokio = { version = "1.35.1", features = ["rt", "macros", "net"] }
tracing = { version = "0.1.40", features = ["log"] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
yaml-rust = "0.4.5"

28
Containerfile Normal file
View File

@ -0,0 +1,28 @@
FROM registry.fedoraproject.org/fedora-minimal:39 AS build
RUN --mount=type=cache,target=/var/cache \
microdnf install -y \
--setopt install_weak_deps=0 \
cargo \
&& :
COPY . /src
WORKDIR /src
RUN cargo build --release
FROM registry.fedoraproject.org/fedora-minimal:39
RUN --mount=type=cache,target=/var/cache \
microdnf install -y \
--setopt install_weak_deps=0 \
tini \
&& :
COPY --from=build /src/target/release/keyserv /usr/local/bin/keyserv
USER 602:602
ENTRYPOINT ["/usr/bin/tini", "/usr/local/bin/keyserv", "--"]

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
max_width = 79

85
src/main.rs Normal file
View File

@ -0,0 +1,85 @@
mod server;
use std::path::PathBuf;
use tokio::net::TcpListener;
use tracing::{error, info};
/// Key dispenser service
#[derive(argh::FromArgs)]
struct Args {
/// path to encrypted keys
#[argh(option, short = 'k', default = "default_key_dir()")]
key_dir: PathBuf,
/// path to master key
#[argh(option, short = 'm', default = "default_master_key()")]
master_key: PathBuf,
/// path to host-key map
#[argh(option, short = 'H', default = "default_key_map()")]
key_map: PathBuf,
/// path to trusted SSH CA list file
#[argh(option, short = 't', default = "default_trusted_cas()")]
trusted_cas: PathBuf,
/// listen address
#[argh(option, short = 'l', default = "default_listen_address()")]
listen_address: String,
}
/// Application context
struct Context {
key_dir: PathBuf,
master_key: PathBuf,
key_map: PathBuf,
trusted_cas: PathBuf,
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let args: Args = argh::from_env();
let ctx = Context {
key_dir: args.key_dir,
master_key: args.master_key,
key_map: args.key_map,
trusted_cas: args.trusted_cas,
};
let app = server::make_app(ctx);
let sock = match TcpListener::bind(args.listen_address).await {
Ok(s) => s,
Err(e) => {
error!("Failed to bind server socket: {}", e);
std::process::exit(1);
}
};
if let Ok(addr) = sock.local_addr() {
info!("Starting {} on {:?}", env!("CARGO_PKG_NAME"), addr);
}
axum::serve(sock, app).await.unwrap();
}
fn default_key_dir() -> PathBuf {
"age-keys".into()
}
fn default_master_key() -> PathBuf {
"master.key".into()
}
fn default_key_map() -> PathBuf {
"key-map.yml".into()
}
fn default_trusted_cas() -> PathBuf {
"trusted-ca.keys".into()
}
fn default_listen_address() -> String {
"[::]:8087".into()
}

291
src/server.rs Normal file
View File

@ -0,0 +1,291 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use age::armor::{ArmoredWriter, Format};
use age::ssh::Recipient;
use age::x25519::Identity;
use age::{Decryptor, Encryptor};
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use secrecy::ExposeSecret;
use ssh_key::public::PublicKey;
use ssh_key::Certificate;
use ssh_key::Fingerprint;
use tracing::{debug, error, warn};
use yaml_rust::YamlLoader;
use super::Context;
/// Errors encountered while dispensing keys
#[derive(Debug, thiserror::Error)]
enum DispenseKeysError {
#[error("I/O Error: {0}")]
Io(#[from] std::io::Error),
#[error("Encryption failed: {0}")]
Encrypt(#[from] age::EncryptError),
}
impl IntoResponse for DispenseKeysError {
fn into_response(self) -> Response {
(StatusCode::BAD_REQUEST, format!("{}", self)).into_response()
}
}
/// Authentication structure
///
/// Contains the SSH certificate included in the Authorization HTTP
/// request header
struct Auth(Certificate);
impl Auth {
fn unauthorized() -> Response {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
}
#[axum::async_trait]
impl FromRequestParts<Arc<Context>> for Auth {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<Context>,
) -> Result<Self, Self::Rejection> {
let cert: Certificate = parts
.headers
.get("Authorization")
.ok_or_else(Self::unauthorized)?
.to_str()
.map_err(|e| {
warn!("Invalid Authorization header: {}", e);
Self::unauthorized()
})?
.parse()
.map_err(|e| {
warn!("Could not parse certificate: {}", e);
Self::unauthorized()
})?;
let trusted = load_trusted_cas(&state.trusted_cas);
cert.validate(&trusted).map_err(|_| {
warn!("Invalid certificate");
Self::unauthorized()
})?;
Ok(Self(cert))
}
}
/// Create the Axum application
pub(crate) fn make_app(ctx: Context) -> Router {
let ctx = Arc::new(ctx);
Router::new()
.route("/", get(|| async { "UP" }))
.route("/keys", get(dispense_keys))
.with_state(ctx)
}
/// GET /keys - Dispense keys to the user
///
/// Given a valid SSH certificate, which is signed by a trusted SSH CA,
/// any keys mapped to the principals listed in the certificate are
/// loaded, decrypted with the master key, and then encrypted with the
/// public key from the SSH certificate.
async fn dispense_keys(
State(ctx): State<Arc<Context>>,
Auth(cert): Auth,
) -> Result<Vec<u8>, DispenseKeysError> {
let master_key = load_key(&ctx.master_key, None);
let key_map = load_map(&ctx.key_map);
let mut all_keys = vec![];
for princ in cert.valid_principals() {
if let Some(keys) = key_map.get(princ) {
for key in keys {
let mut path = ctx.key_dir.clone();
path.push(key);
if let Some(k) = load_key(path, master_key.as_ref()) {
let secret = k.to_string();
all_keys.extend(secret.expose_secret().bytes());
all_keys.push(b'\n');
}
}
}
}
let pubkey = PublicKey::new(cert.public_key().clone(), "");
let recipient = pubkey.to_string().parse().unwrap();
encrypt(recipient, &all_keys)
}
/// Load trusted CA keys from a file
///
/// Each line in the file must contain a valid OpenSSH public key.
fn load_trusted_cas(path: impl AsRef<Path>) -> Vec<Fingerprint> {
let mut result = vec![];
debug!("Loading trusted CA keys from {}", path.as_ref().display());
let f = match File::open(path) {
Ok(f) => f,
Err(e) => {
error!("Failed to load trusted CA list: {}", e);
return result;
}
};
let mut reader = BufReader::new(f);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
error!("Error while reading trusted CA list: {}", e);
break;
}
}
match PublicKey::from_str(&line) {
Ok(k) => result.push(k.fingerprint(Default::default())),
Err(e) => {
error!("Failed to parse key: {}", e);
}
}
}
debug!("Loaded {} CA keys", result.len());
result
}
/// Encrypt some data to the specified recipient
fn encrypt(
recipient: Recipient,
data: &[u8],
) -> Result<Vec<u8>, DispenseKeysError> {
let enc = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
let mut buf = vec![];
let mut writer = enc.wrap_output(ArmoredWriter::wrap_output(
&mut buf,
Format::AsciiArmor,
)?)?;
writer.write_all(data)?;
writer.finish().and_then(|a| a.finish())?;
Ok(buf)
}
/// Load the key map
///
/// The key map is a YAML document containing a map of principal names
/// to a list of keys the principal is authorized to have.
fn load_map(path: impl AsRef<Path>) -> HashMap<String, Vec<String>> {
let path = path.as_ref();
let mut map = Default::default();
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
error!("Failed to load key map from {}: {}", path.display(), e);
return map;
}
};
let doc = match YamlLoader::load_from_str(&contents) {
Ok(mut d) => match d.pop() {
Some(d) => d,
None => return map,
},
Err(e) => {
error!("Could not parse YAML: {}", e);
return map;
}
};
if let Some(m) = doc.into_hash() {
for (k, v) in m {
if let Some(principal) = k.into_string() {
let mut keys = vec![];
if let Some(values) = v.into_vec() {
for key in values.into_iter() {
if let Some(k) = key.into_string() {
keys.push(k)
}
}
}
map.insert(principal, keys);
}
}
}
map
}
/// Load a (possibly encrypted) age key from a file
fn load_key(
path: impl AsRef<Path>,
key: Option<&Identity>,
) -> Option<Identity> {
let path = path.as_ref();
let mut f = match File::open(path) {
Ok(f) => f,
Err(e) => {
error!("Could not open key file {}: {}", path.display(), e);
return None;
}
};
let mut keystr = vec![];
match f.read_to_end(&mut keystr) {
Ok(s) => s,
Err(e) => {
error!("Failed to load key: {}", e);
return None;
}
};
if let Some(key) = key {
let dec = match Decryptor::new(&keystr[..]) {
Ok(d) => match d {
Decryptor::Recipients(d) => d,
_ => {
error!("Cannot decrypt passphrase-protected keys");
return None;
}
},
Err(e) => {
error!("Failed to decrypt file {}: {}", path.display(), e);
return None;
}
};
let mut buf = vec![];
let mut reader =
match dec.decrypt(std::iter::once(key as &dyn age::Identity)) {
Ok(r) => r,
Err(e) => {
error!("Failed to decrypt file {}: {}", path.display(), e);
return None;
}
};
let _ = reader.read_to_end(&mut buf);
keystr = buf;
};
let keystr = match std::str::from_utf8(&keystr) {
Ok(s) => s,
Err(e) => {
error!("Invalid key: {}", e);
return None;
}
};
for (idx, line) in keystr.lines().enumerate() {
if let Some(line) = line.split('#').next() {
let line = line.trim();
if !line.is_empty() {
match line.parse() {
Ok(k) => return Some(k),
Err(e) => {
error!(
"Could not parse {} line {}: {}",
path.display(),
idx + 1,
e
);
}
}
}
}
}
None
}