use std::{future::Future, io};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{Client, RequestError, RequestProgress, protocol::Method};
pub struct SshAuthenticationRequest<'a> {
pub invocation_id: &'a str,
pub invocation_token: &'a [u8; 32],
pub secret: &'a str,
pub algorithm: SshSignatureAlgorithm,
pub message: &'a [u8],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SshSignatureAlgorithm {
Ed25519,
RsaSha256,
RsaSha512,
}
impl SshSignatureAlgorithm {
pub fn as_str(self) -> &'static str {
match self {
Self::Ed25519 => "ssh-ed25519",
Self::RsaSha256 => "rsa-sha2-256",
Self::RsaSha512 => "rsa-sha2-512",
}
}
}
impl Client {
pub async fn request_ssh_authentication<P>(
&self,
request: SshAuthenticationRequest<'_>,
cancellation: impl Future<Output = ()>,
mut progress: P,
) -> Result<Vec<u8>, RequestError>
where
P: FnMut(RequestProgress),
{
progress(RequestProgress::Preparing);
request
.invocation_id
.parse::<Ulid>()
.map_err(RequestError::other)?;
if request.secret.is_empty() {
return Err(RequestError::other("SSH secret name is empty"));
}
let request_id = Ulid::generate();
let payload = SshAuthenticationRequestPayload {
method: Method::SshAuthenticate,
invocation_id: request.invocation_id,
invocation_token: BASE64_STANDARD.encode(request.invocation_token),
secret: request.secret,
message: BASE64_STANDARD.encode(request.message),
};
self.approval_exchange(
request_id,
&payload,
cancellation,
progress,
|response: ApprovedSignature| {
let signature = response.signature.ok_or_else(|| {
io::Error::other("approved response doesn't contain an SSH signature")
})?;
match BASE64_STANDARD.decode(signature) {
Ok(signature) if valid_signature(&signature, request.algorithm) => {
Ok(signature)
}
_ => Err(io::Error::other(
"approved response doesn't contain a valid SSH signature",
)),
}
},
)
.await
}
}
fn valid_signature(signature: &[u8], expected: SshSignatureAlgorithm) -> bool {
let Some((algorithm, remainder)) = take_string(signature) else {
return false;
};
let Some((value, remainder)) = take_string(remainder) else {
return false;
};
remainder.is_empty()
&& !value.is_empty()
&& algorithm == expected.as_str().as_bytes()
&& (expected != SshSignatureAlgorithm::Ed25519 || value.len() == 64)
}
fn take_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize;
let value = input.get(4..4_usize.checked_add(length)?)?;
Some((value, &input[4 + length..]))
}
#[derive(Serialize)]
struct SshAuthenticationRequestPayload<'a> {
method: Method,
invocation_id: &'a str,
invocation_token: String,
secret: &'a str,
message: String,
}
#[derive(Deserialize)]
struct ApprovedSignature {
signature: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_the_requested_signature_algorithm() {
let signature = signature("ssh-ed25519", &[7; 64]);
assert!(valid_signature(&signature, SshSignatureAlgorithm::Ed25519));
assert!(!valid_signature(
&signature,
SshSignatureAlgorithm::RsaSha512
));
}
#[test]
fn rejects_malformed_signatures() {
assert!(!valid_signature(&[], SshSignatureAlgorithm::Ed25519));
assert!(!valid_signature(
&signature("ssh-ed25519", &[7; 63]),
SshSignatureAlgorithm::Ed25519
));
let mut trailing = signature("ssh-ed25519", &[7; 64]);
trailing.push(0);
assert!(!valid_signature(&trailing, SshSignatureAlgorithm::Ed25519));
}
fn signature(algorithm: &str, value: &[u8]) -> Vec<u8> {
let mut signature = Vec::new();
put_string(&mut signature, algorithm.as_bytes());
put_string(&mut signature, value);
signature
}
fn put_string(output: &mut Vec<u8>, value: &[u8]) {
output.extend_from_slice(&(value.len() as u32).to_be_bytes());
output.extend_from_slice(value);
}
}