Compare commits

..

14 Commits

Author SHA1 Message Date
70de99fd25 flake.lock: Update
Flake lock file updates:

• Updated input 'naersk':
    'github:nmattia/naersk/6944160c19cb591eb85bbf9b2f2768a935623ed3' (2022-09-03)
  → 'github:nmattia/naersk/abca1fb7a6cfdd355231fc220c3d0302dbb4369a' (2023-07-05)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/5dc7114b7b256d217fe7752f1614be2514e61bb8' (2022-11-25)
  → 'github:NixOS/nixpkgs/3c7487575d9445185249a159046cc02ff364bff8' (2023-07-06)
• Updated input 'utils':
    'github:numtide/flake-utils/5aed5285a952e0b949eb3ba02c12fa4fcfef535f' (2022-11-02)
  → 'github:numtide/flake-utils/dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7' (2023-06-25)
• Added input 'utils/systems':
    'github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e' (2023-04-09)
2023-07-09 19:23:09 +02:00
e7c3a9f116 rename to sshcd 2023-07-09 19:12:27 +02:00
f47c57c1c0 wip: JWTAuthenticated
TODO: move into api/

fix: test

chore: move JWTAuthenticated into extract

chore: fmt
2023-03-12 16:25:17 +01:00
dffbcceeba Merge branch 'split_components' of git.shimun.net:shimun/ssh-cert-dist into split_components 2023-03-10 10:50:11 +01:00
2688c81aed added: more error variants 2023-03-10 10:45:07 +01:00
e696663aec added: cert/SHA256:... 2023-03-10 10:38:31 +01:00
ba77091de7 added: update ssh-key 2023-03-10 10:16:46 +01:00
4ff3cbe9d9 added: include serial 2023-02-22 16:01:10 +01:00
bccaa6935f added: ensure vailidy range stays same 2023-02-22 15:52:42 +01:00
c299a4e132 added: shell-escape 2023-02-22 15:29:56 +01:00
50ba6c9934 fix: trailing spaces 2023-02-22 15:23:38 +01:00
f069dae3ee fix: before <=> after 2023-02-22 15:18:33 +01:00
17bb56dd5f added: update ssh-key 2023-02-22 15:12:24 +01:00
e3b920fcd5 added: renew command 2023-02-22 14:56:01 +01:00
13 changed files with 855 additions and 632 deletions

1179
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ authors = ["shimun <shimun@shimun.net>"]
edition = "2021"
[[bin]]
name = "ssh-cert-dist"
name = "sshcd"
path = "src/main.rs"
[dependencies]
@@ -17,7 +17,7 @@ clap = { version = "4.0.29", features = ["env", "derive"] }
rand = "0.8.5"
reqwest = { version = "0.11.13" }
serde = { version = "1.0.148", features = ["derive"] }
ssh-key = { version = "0.5.1", features = ["ed25519", "p256", "p384", "rsa", "signature"] }
ssh-key = { version = "0.6.0-pre.0", features = ["ed25519", "p256", "p384", "rsa", "serde"] }
thiserror = "1.0.37"
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }

View File

@@ -116,11 +116,13 @@ async fn fetch(
args: ClientArgs { api, interactive },
}: FetchArgs,
) -> anyhow::Result<()> {
let certs = read_dir(&cert_dir).await?;
let certs = read_certs_dir(&cert_dir).await?;
// let publics_keys = read_pubkey_dir(&cert_dir).await?;
let client = reqwest::Client::new();
let threshold_exp = min_delta.and_then(|min_delta| {
SystemTime::now().checked_add(Duration::from_secs(60 * 60 * 24 * min_delta as u64))
});
// let standalone_certs = publics_keys.into_iter().map(|(name, key)| )
let updates = certs
.into_iter()
.filter(|cert| {

View File

@@ -11,8 +11,9 @@ anyhow = "1.0.66"
async-trait = "0.1.59"
axum = { version = "0.6.1" }
axum-extra = { version = "0.4.1", features = ["typed-routing"] }
hex = { version = "0.4.3", features = ["serde"] }
serde = { version = "1.0.148", features = ["derive"] }
ssh-key = { version = "0.5.1", features = ["ed25519", "p256", "p384", "rsa", "signature"] }
ssh-key = { version = "0.6.0-pre.0", features = ["ed25519", "p256", "p384", "rsa"] }
thiserror = "1.0.37"
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }

View File

@@ -24,11 +24,11 @@ pub async fn read_certs(
if !ca_dir.exists() {
return Ok(Vec::new());
}
read_dir(&ca_dir).await
read_certs_dir(&ca_dir).await
}
#[instrument]
pub async fn read_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<Certificate>> {
pub async fn read_certs_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<Certificate>> {
let mut dir = fs::read_dir(path.as_ref())
.await
.with_context(|| format!("read certs dir '{:?}'", path.as_ref()))?;
@@ -55,6 +55,26 @@ pub async fn read_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<Cert
Ok(certs)
}
pub async fn read_pubkey_dir(path: impl AsRef<Path> + Debug) -> anyhow::Result<Vec<PublicKey>> {
let mut dir = fs::read_dir(path.as_ref())
.await
.with_context(|| format!("read certs dir '{:?}'", path.as_ref()))?;
let mut pubs = Vec::new();
while let Some(entry) = dir.next_entry().await? {
//TODO: investigate why path().ends_with doesn't work
let file_name = entry.file_name().into_string().unwrap();
if !file_name.ends_with(".pub") || file_name.ends_with("-cert.pub") {
trace!("skipped {:?} due to missing '.pub' extension", entry.path());
continue;
}
let cert = load_public_key(entry.path()).await?;
if let Some(cert) = cert {
pubs.push(cert);
}
}
Ok(pubs)
}
fn parse_utf8(bytes: Vec<u8>) -> anyhow::Result<String> {
String::from_utf8(bytes).context("invalid utf-8")
}
@@ -122,3 +142,15 @@ pub async fn load_cert(file: impl AsRef<Path> + Debug) -> anyhow::Result<Option<
|| format!("parse {:?} as openssh certificate", &file),
)?))
}
pub async fn load_public_key(file: impl AsRef<Path> + Debug) -> anyhow::Result<Option<PublicKey>> {
let contents = match fs::read(&file).await {
Ok(contents) => contents,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("read {:?}", &file)),
};
let string_repr = parse_utf8(contents)?;
Ok(Some(PublicKey::from_openssh(&string_repr).with_context(
|| format!("parse {:?} as openssh public key", &file),
)?))
}

View File

@@ -1,24 +1,37 @@
use axum_extra::routing::TypedPath;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use ssh_key::Fingerprint;
#[derive(TypedPath, Deserialize)]
#[typed_path("/certs")]
pub struct CertList;
#[derive(TypedPath, Deserialize)]
#[typed_path("/certs/:identifier")]
#[typed_path("/cert/:identifier")]
pub struct GetCert {
pub identifier: String,
}
#[derive(TypedPath, Deserialize)]
#[typed_path("/certs/:identifier/info")]
#[typed_path("/certs/:pubkey_hash")]
pub struct GetCertsPubkey {
pub pubkey_hash: Fingerprint,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct CertIds {
pub ids: Vec<String>,
}
#[derive(TypedPath, Deserialize)]
#[typed_path("/cert/:identifier/info")]
pub struct GetCertInfo {
pub identifier: String,
}
#[derive(TypedPath, Deserialize)]
#[typed_path("/certs/:identifier")]
#[typed_path("/cert/:identifier")]
pub struct PostCertInfo {
pub identifier: String,
}

38
flake.lock generated
View File

@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1662220400,
"narHash": "sha256-9o2OGQqu4xyLZP9K6kNe1pTHnyPz0Wr3raGYnr9AIgY=",
"lastModified": 1688534083,
"narHash": "sha256-/bI5vsioXscQTsx+Hk9X5HfweeNZz/6kVKsbdqfwW7g=",
"owner": "nmattia",
"repo": "naersk",
"rev": "6944160c19cb591eb85bbf9b2f2768a935623ed3",
"rev": "abca1fb7a6cfdd355231fc220c3d0302dbb4369a",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1669411043,
"narHash": "sha256-LfPd3+EY+jaIHTRIEOUtHXuanxm59YKgUacmSzaqMLc=",
"lastModified": 1688679045,
"narHash": "sha256-t3xGEfYIwhaLTPU8FLtN/pLPytNeDwbLI6a7XFFBlGo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5dc7114b7b256d217fe7752f1614be2514e61bb8",
"rev": "3c7487575d9445185249a159046cc02ff364bff8",
"type": "github"
},
"original": {
@@ -41,13 +41,31 @@
"utils": "utils"
}
},
"utils": {
"systems": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1687709756,
"narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7",
"type": "github"
},
"original": {

View File

@@ -43,9 +43,11 @@
# `nix run`
apps."${pname}-server" = utils.lib.mkApp {
drv = packages."${pname}-server";
exePath = "/bin/sshcd-server";
};
apps."${pname}-client" = utils.lib.mkApp {
drv = packages."${pname}-client";
exePath = "/bin/sshcd";
};
# `nix run .#streamDockerImage | docker load`
@@ -91,7 +93,15 @@
rustc --version
printf "\nbuild inputs: ${pkgs.lib.concatStringsSep ", " (map (bi: bi.name) (buildInputs ++ nativeBuildInputs))}"
function server() {
cargo watch -x "run --bin ssh-cert-dist-server --all-features -- ''${@}"
if [ ! -e "certs/ca.pub" ]; then
mkdir -p certs keys
ssh-keygen -t ed25519 -f certs/ca -q -N ""
ssh-keygen -t ed25519 -f keys/host -q -N ""
ssh-keygen -t ed25519 -f keys/client -q -N ""
ssh-keygen -s certs/ca -V +1000d -h -I host -n localhost,127.0.0.1 -h keys/host.pub
ssh-keygen -s certs/ca -V +1000d -I client -n "client,client@localhost" keys/client.pub -O force-command="echo Hello World"
fi
cargo watch -x "run --bin sshcd-server --all-features -- ''${@}"
}
'';
};

View File

@@ -16,10 +16,10 @@ in
runtimeInputs = [ cfg.package ];
text = ''
${optionalString options.fetch ''
ssh-cert-dist fetch --cert-dir '${path}' --api-endpoint '${cfg.endpoint}'
sshcd fetch --cert-dir '${path}' --api-endpoint '${cfg.endpoint}'
''}
${optionalString options.upload ''
ssh-cert-dist upload --api-endpoint '${cfg.endpoint}' ${path}/*
sshcd upload --api-endpoint '${cfg.endpoint}' ${path}/*
''}
'';
});

View File

@@ -57,7 +57,7 @@ in
chown ${cfg.user}:${cfg.group} ${cfg.dataDir}
''}";
User = cfg.user;
ExecStart = "${cfg.package}/bin/ssh-cert-dist-server";
ExecStart = "${cfg.package}/bin/sshcd-server";
};
};
};

View File

@@ -13,6 +13,9 @@ authorized =[ "dep:jwt-compact" ]
index = []
info = [ "axum/json", "ssh-key/serde" ]
[[bin]]
name = "sshcd-server"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.66"
@@ -24,14 +27,15 @@ clap = { version = "4.0.29", features = ["env", "derive"] }
jwt-compact = { version = "0.6.0", features = ["serde_cbor", "std", "clock"], optional = true }
rand = "0.8.5"
serde = { version = "1.0.148", features = ["derive"] }
ssh-key = { version = "0.5.1", features = ["ed25519", "p256", "p384", "rsa", "signature"] }
ssh-key = { version = "0.6.0-pre.0", features = ["ed25519", "p256", "p384", "rsa"] }
thiserror = "1.0.37"
tokio = { version = "1.22.0", features = ["io-std", "test-util", "tracing", "macros", "fs"] }
tower = { version = "0.4.13", features = ["util"] }
tower-http = { version = "0.3.4", features = ["map-request-body", "trace"] }
tower = { version = "0.4.13" }
tower-http = { version = "0.3.4", features = ["map-request-body", "trace", "util"] }
tracing = { version = "0.1.37", features = ["release_max_level_debug"] }
tracing-subscriber = "0.3.16"
ssh-cert-dist-common = { path = "../common" }
shell-escape = "0.1.5"
[dev-dependencies]
tempfile = "3.3.0"

View File

@@ -1,21 +1,28 @@
mod extract;
use std::collections::HashMap;
use std::fmt::Debug;
use std::net::SocketAddr;
use std::path::{self, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Context;
use axum::body;
use axum::extract::rejection::QueryRejection;
use axum::extract::{Query, State};
use chrono::Duration;
use shell_escape::escape;
use ssh_cert_dist_common::*;
use axum::{http::StatusCode, response::IntoResponse, Json, Router};
use axum_extra::routing::RouterExt;
use clap::{Args, Parser};
use jwt_compact::alg::{Hs256, Hs256Key};
use jwt_compact::{AlgorithmExt, Token, UntrustedToken};
use jwt_compact::{AlgorithmExt};
use rand::{thread_rng, Rng};
use serde::{Deserialize, Serialize};
use ssh_key::{Certificate, Fingerprint, PublicKey};
@@ -24,7 +31,7 @@ use tower::ServiceBuilder;
use tower_http::{trace::TraceLayer, ServiceBuilderExt};
use tracing::{debug, info, trace};
use self::extract::{CertificateBody, SignatureBody};
use self::extract::{CertificateBody, SignatureBody, JWTAuthenticated, JWTString};
#[derive(Parser)]
pub struct ApiArgs {
@@ -72,7 +79,7 @@ impl Default for ApiArgs {
}
#[derive(Debug, Clone)]
struct ApiState {
pub struct ApiState {
certs: Arc<Mutex<HashMap<String, Certificate>>>,
cert_dir: PathBuf,
ca: PublicKey,
@@ -173,12 +180,23 @@ pub enum ApiError {
AuthenticationRequired(String),
#[error("invalid ssh signature")]
InvalidSignature,
#[error("malformed ssh signature: {0}")]
ParseSignature(anyhow::Error),
#[error("malformed ssh certificate: {0}")]
ParseCertificate(anyhow::Error),
#[error("{0}")]
JWTParse(#[from] jwt_compact::ParseError),
#[error("{0}")]
JWTVerify(#[from] jwt_compact::ValidationError),
#[error("{0}")]
Query(#[from] QueryRejection),
}
type ApiResult<T> = Result<T, ApiError>;
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
trace!({ error = ?self }, "returned error for request");
(
match self {
Self::CertificateNotFound => StatusCode::NOT_FOUND,
@@ -214,7 +232,7 @@ async fn list_certs(
))
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "aud", rename = "get")]
struct AuthClaims {
identifier: String,
@@ -291,13 +309,40 @@ struct CertInfo {
impl From<&Certificate> for CertInfo {
fn from(cert: &Certificate) -> Self {
let validity = cert.valid_after_time().duration_since(cert.valid_before_time()).unwrap();
let validity_days = validity.as_secs() / ((60*60) * 24);
let validity = cert
.valid_before_time()
.duration_since(cert.valid_after_time())
.unwrap_or(Duration::zero().to_std().unwrap());
let expiry = cert.valid_before_time().checked_add(validity).unwrap();
let expiry_date = expiry.duration_since(UNIX_EPOCH).unwrap();
let host_key = if cert.cert_type().is_host() {
" -h"
} else { "" };
let opts = cert.critical_options().iter().map(|(opt, val)| if val.is_empty() { opt.clone() } else { format!("{opt}={val}") }).map(|arg| format!("-O {arg}")).join(" ");
let renew_command = format!("ssh-keygen -s ./ca_key {host_key} -I {} -n {} -V {validity_days}d {opts}", cert.key_id(), cert.valid_principals().join(","));
} else {
""
};
let opts = cert
.critical_options()
.iter()
.map(|(opt, val)| {
if val.is_empty() {
opt.clone()
} else {
format!("{opt}={val}")
}
})
.map(|arg| format!("-O {}", escape(arg.into())))
.collect::<Vec<_>>()
.join(" ");
let opts = opts.trim();
let renew_command = format!(
"ssh-keygen -s ./ca_key {host_key} -I {} -n {} -z {} -V {:#x}:{:#x} {opts} {}.pub",
escape(cert.key_id().into()),
escape(cert.valid_principals().join(",").into()),
cert.serial() + 1,
cert.valid_after(),
expiry_date.as_secs(),
escape(cert.key_id().into())
);
CertInfo {
principals: cert.valid_principals().to_vec(),
ca: cert.signature_key().clone().into(),
@@ -306,7 +351,7 @@ impl From<&Certificate> for CertInfo {
identity_hash: cert.public_key().fingerprint(ssh_key::HashAlg::Sha256),
key_id: cert.key_id().to_string(),
expiry: cert.valid_before_time(),
renew_command
renew_command,
}
}
}
@@ -336,22 +381,26 @@ struct PostCertsQuery {
challenge: String,
}
impl From<Query<PostCertsQuery>> for JWTString {
fn from(Query(PostCertsQuery { challenge }): Query<PostCertsQuery>) -> Self {
Self::from(challenge)
}
}
/// POST with signed challenge
async fn post_certs_identifier(
PostCertInfo { identifier }: PostCertInfo,
State(ApiState { certs, jwt_key, .. }): State<ApiState>,
State(ApiState { certs, .. }): State<ApiState>,
JWTAuthenticated {
data: auth_claims, ..
}: JWTAuthenticated<AuthClaims, Query<PostCertsQuery>>,
Query(PostCertsQuery { challenge }): Query<PostCertsQuery>,
SignatureBody(sig): SignatureBody,
) -> ApiResult<String> {
let certs = certs.lock().await;
let cert = certs.get(&identifier).ok_or(ApiError::InvalidSignature)?;
let token: Token<AuthClaims> = Hs256
.validate_integrity(
&UntrustedToken::new(&challenge).context("jwt parse")?,
&jwt_key,
)
.map_err(|_| ApiError::InvalidSignature)?;
if token.claims().custom.identifier != identifier {
if auth_claims.identifier != identifier {
return Err(ApiError::InvalidSignature);
}
let pubkey: PublicKey = cert.public_key().clone().into();
@@ -509,7 +558,7 @@ mod tests {
)
};
let res = put_cert_update(PutCert, State(state.clone()), CertificateBody(cert_first)).await;
assert!(res.is_ok());
assert!(dbg!(res).is_ok());
let res = put_cert_update(PutCert, State(state.clone()), CertificateBody(cert_newer)).await;
assert!(res.is_ok());
let res = put_cert_update(
@@ -582,6 +631,9 @@ mod tests {
identifier: "test_cert".into(),
},
State(state.clone()),
JWTAuthenticated::new(AuthClaims {
identifier: "test_cert".into(),
}),
Query(PostCertsQuery { challenge }),
SignatureBody(sig),
)

View File

@@ -1,6 +1,16 @@
use super::ApiError;
use std::fmt::Debug;
use std::marker::PhantomData;
use super::{ApiError, ApiState};
use anyhow::Context;
use axum::{async_trait, body::BoxBody, extract::FromRequest, http::Request};
use axum::{
async_trait,
body::BoxBody,
extract::{FromRequest, FromRequestParts},
http::Request,
};
use jwt_compact::{alg::Hs256, AlgorithmExt, Token, UntrustedToken};
use serde::{de::DeserializeOwned, Serialize};
use ssh_key::{Certificate, SshSig};
use tracing::trace;
@@ -21,7 +31,8 @@ where
.context("failed to extract body")?;
let cert = Certificate::from_openssh(&body)
.with_context(|| format!("failed to parse '{}'", body))?;
.with_context(|| format!("failed to parse '{}'", body))
.map_err(ApiError::ParseCertificate)?;
trace!(%body, "extracted certificate");
Ok(Self(cert))
}
@@ -42,8 +53,71 @@ where
.await
.context("failed to extract body")?;
let sig = SshSig::from_pem(&body).with_context(|| format!("failed to parse '{}'", body))?;
let sig = SshSig::from_pem(&body)
.with_context(|| format!("failed to parse '{}'", body))
.map_err(ApiError::ParseSignature)?;
trace!(%body, "extracted signature");
Ok(Self(sig))
}
}
pub struct JWTString(String);
impl From<String> for JWTString {
fn from(s: String) -> Self {
Self(s)
}
}
// TODO: be generic over ApiState -> AsRef<Target=Hs256>, AsRef<Target=A> where A: AlgorithmExt
#[derive(Debug)]
pub struct JWTAuthenticated<
T: Serialize + DeserializeOwned + Clone + Debug,
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
> where
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
{
pub data: T,
_marker: PhantomData<Q>,
}
impl<
T: Serialize + DeserializeOwned + Clone + Debug,
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
> JWTAuthenticated<T, Q>
where
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
{
pub fn new(data: T) -> Self {
Self {
data,
_marker: Default::default(),
}
}
}
#[async_trait]
impl<
T: Serialize + DeserializeOwned + Clone + Debug,
Q: FromRequestParts<ApiState> + Debug + Into<JWTString>,
> FromRequestParts<ApiState> for JWTAuthenticated<T, Q>
where
ApiError: From<<Q as FromRequestParts<ApiState>>::Rejection>,
{
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &ApiState,
) -> Result<Self, Self::Rejection> {
let JWTString(token) = Q::from_request_parts(parts, state).await?.into();
let token = UntrustedToken::new(&token).map_err(ApiError::JWTParse)?;
let verified: Token<T> = Hs256
.validate_integrity(&token, &state.jwt_key)
.map_err(ApiError::JWTVerify)?;
Ok(Self {
data: verified.claims().custom.clone(),
_marker: Default::default(),
})
}
}