Initial commit
commit
0355e24e23
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!src
|
||||
!Cargo.*
|
|
@ -0,0 +1,5 @@
|
|||
/age-keys
|
||||
/target
|
||||
key-map.yml
|
||||
master.key
|
||||
trusted-ca.keys
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
|
@ -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", "--"]
|
|
@ -0,0 +1 @@
|
|||
max_width = 79
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue