pray-cli 1.14.0

Package manager for the language placed before inference — Prayfile CLI (binary: pray)
use crate::auth_session_store::{persist_session, SessionFile};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
use pray_core::auth::{
    AuthPasskeyChallengeRequest, AuthPasskeyChallengeResponse, AuthPasskeyLoginRequest,
    AuthPasskeyLoginResponse, AuthSshKeyChallengeRequest, AuthSshKeyChallengeResponse,
    AuthSshKeyLoginRequest, AuthSshKeyLoginResponse,
};
use pray_core::{PrayError, PrayResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::Path;

pub use crate::auth_session_store::{current_signer, current_signer_fingerprint};

pub fn login_with_passkey(
    server_url: &str,
    credential_id: &str,
    private_key_path: &Path,
    session_root: &Path,
) -> PrayResult<SessionFile> {
    let challenge: AuthPasskeyChallengeResponse = post_json(
        &format!(
            "{}/v1/auth/passkeys/challenge",
            trim_trailing_slash(server_url)
        ),
        &AuthPasskeyChallengeRequest {
            credential_id: credential_id.to_string(),
        },
    )?;
    let private_key_bytes = fs::read(private_key_path)?;
    let seed: [u8; 32] = private_key_bytes.as_slice().try_into().map_err(|_| {
        PrayError::Unsupported("passkey private key must be 32 raw bytes".to_string())
    })?;
    let signing_key = SigningKey::from_bytes(&seed);
    let signature = STANDARD.encode(signing_key.sign(challenge.challenge.as_bytes()).to_bytes());
    let response: AuthPasskeyLoginResponse = post_json(
        &format!("{}/v1/auth/passkeys/login", trim_trailing_slash(server_url)),
        &AuthPasskeyLoginRequest {
            credential_id: credential_id.to_string(),
            challenge_id: challenge.challenge_id,
            signature,
        },
    )?;
    persist_session(
        session_root,
        SessionFile {
            server_url: server_url.to_string(),
            email: response.email,
            token: response.token,
            kind: "passkey".to_string(),
            signer_fingerprint: None,
        },
    )
}

pub fn login_with_ssh_agent(
    server_url: &str,
    public_key_path: &Path,
    session_root: &Path,
) -> PrayResult<SessionFile> {
    let public_key = fs::read_to_string(public_key_path)?;
    let challenge: AuthSshKeyChallengeResponse = post_json(
        &format!(
            "{}/v1/auth/ssh-keys/challenge",
            trim_trailing_slash(server_url)
        ),
        &AuthSshKeyChallengeRequest {
            public_key: public_key.trim().to_string(),
        },
    )?;
    let signature = ssh_agent_sign(public_key.trim(), challenge.challenge.as_bytes())?;
    let response: AuthSshKeyLoginResponse = post_json(
        &format!("{}/v1/auth/ssh-keys/login", trim_trailing_slash(server_url)),
        &AuthSshKeyLoginRequest {
            public_key: public_key.trim().to_string(),
            challenge_id: challenge.challenge_id,
            signature,
        },
    )?;
    persist_session(
        session_root,
        SessionFile {
            server_url: server_url.to_string(),
            email: response.email,
            token: response.token,
            kind: "ssh_key".to_string(),
            signer_fingerprint: Some(challenge.fingerprint),
        },
    )
}

fn post_json<Request, Response>(url: &str, body: &Request) -> PrayResult<Response>
where
    Request: Serialize,
    Response: for<'de> Deserialize<'de>,
{
    let (host_port, path) = split_http_url(url)?;
    let mut stream = TcpStream::connect((&host_port.0[..], host_port.1))
        .map_err(|error| PrayError::Network(error.to_string()))?;
    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30)));
    let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(30)));
    let body_text =
        serde_json::to_string(body).map_err(|error| PrayError::Manifest(error.to_string()))?;
    write!(
        stream,
        "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
        path,
        host_port.0,
        host_port.1,
        body_text.len(),
        body_text
    )
    .map_err(|error| PrayError::Network(error.to_string()))?;
    let mut response = String::new();
    stream
        .read_to_string(&mut response)
        .map_err(|error| PrayError::Network(error.to_string()))?;
    let (status, response_body) = split_http_response(&response)?;
    if !(200..300).contains(&status) {
        return Err(PrayError::Network(format!(
            "request to {url} failed with status {status}: {response_body}"
        )));
    }
    serde_json::from_str(response_body).map_err(|error| PrayError::Parse {
        kind: "auth response",
        message: error.to_string(),
    })
}

fn split_http_url(url: &str) -> PrayResult<((String, u16), String)> {
    let url = url
        .strip_prefix("http://")
        .ok_or_else(|| PrayError::Unsupported("only http:// URLs are supported".to_string()))?;
    let (host_port, path) = url.split_once('/').unwrap_or((url, ""));
    let (host, port) = host_port
        .split_once(':')
        .ok_or_else(|| PrayError::Unsupported("URL must include a port".to_string()))?;
    let port = port
        .parse::<u16>()
        .map_err(|error| PrayError::Unsupported(error.to_string()))?;
    Ok(((host.to_string(), port), format!("/{}", path)))
}

fn split_http_response(response: &str) -> PrayResult<(u16, &str)> {
    let (header, body) = response
        .split_once("\r\n\r\n")
        .ok_or_else(|| PrayError::Resolution("invalid HTTP response".to_string()))?;
    let status = header
        .lines()
        .next()
        .and_then(|line| line.split_whitespace().nth(1))
        .and_then(|status| status.parse::<u16>().ok())
        .ok_or_else(|| PrayError::Resolution("missing HTTP status".to_string()))?;
    Ok((status, body))
}

fn trim_trailing_slash(value: &str) -> String {
    value.trim_end_matches('/').to_string()
}

fn ssh_agent_sign(public_key: &str, message: &[u8]) -> PrayResult<String> {
    let agent_socket = std::env::var("SSH_AUTH_SOCK")
        .map_err(|_| PrayError::Unsupported("SSH_AUTH_SOCK is not set".to_string()))?;
    let mut stream = std::os::unix::net::UnixStream::connect(agent_socket)?;
    let (_, raw_key_bytes) = parse_ssh_ed25519_public_key(public_key)?;
    let mut public_key_blob = Vec::new();
    write_ssh_string(&mut public_key_blob, b"ssh-ed25519");
    write_ssh_string(&mut public_key_blob, &raw_key_bytes);
    let mut payload = Vec::new();
    write_ssh_string(&mut payload, &public_key_blob);
    write_ssh_string(&mut payload, message);
    write_u32(&mut payload, 0)?;
    write_ssh_message(&mut stream, 13, &payload)?;
    let (message_type, response) = read_ssh_message(&mut stream)?;
    if message_type != 14 {
        return Err(PrayError::Resolution(format!(
            "ssh agent returned unexpected message type: {message_type}"
        )));
    }
    let signature_blob = read_ssh_string_bytes(&response)?;
    parse_ssh_signature_blob(signature_blob)
}

fn parse_ssh_ed25519_public_key(public_key: &str) -> PrayResult<(String, [u8; 32])> {
    let mut fields = public_key.split_whitespace();
    let algorithm = fields.next().ok_or_else(|| {
        PrayError::Unsupported("public key must include an algorithm".to_string())
    })?;
    if algorithm != "ssh-ed25519" {
        return Err(PrayError::Unsupported(format!(
            "unsupported public key algorithm: {algorithm}"
        )));
    }
    let key_value = fields
        .next()
        .ok_or_else(|| PrayError::Unsupported("public key must include key bytes".to_string()))?;
    let blob = STANDARD
        .decode(key_value.as_bytes())
        .map_err(|error| PrayError::Parse {
            kind: "public key",
            message: error.to_string(),
        })?;
    let mut cursor = blob.as_slice();
    let blob_algorithm = read_ssh_string(&mut cursor)?;
    if blob_algorithm != b"ssh-ed25519" {
        return Err(PrayError::Parse {
            kind: "public key",
            message: "ed25519 public key blob must start with ssh-ed25519".to_string(),
        });
    }
    let key_bytes = read_ssh_string(&mut cursor)?;
    let key_bytes: [u8; 32] = key_bytes
        .as_slice()
        .try_into()
        .map_err(|_| PrayError::Parse {
            kind: "public key",
            message: "ed25519 public key must be 32 bytes".to_string(),
        })?;
    Ok((format!("ssh-ed25519 {key_value}"), key_bytes))
}

fn parse_ssh_signature_blob(signature_blob: Vec<u8>) -> PrayResult<String> {
    let mut cursor = &signature_blob[..];
    let algorithm = read_ssh_string(&mut cursor)?;
    if algorithm != b"ssh-ed25519" {
        return Err(PrayError::Unsupported(format!(
            "unsupported ssh signature algorithm: {}",
            String::from_utf8_lossy(&algorithm)
        )));
    }
    let signature = read_ssh_string(&mut cursor)?;
    Ok(STANDARD.encode(signature))
}

fn write_ssh_message(
    stream: &mut std::os::unix::net::UnixStream,
    message_type: u8,
    payload: &[u8],
) -> PrayResult<()> {
    let mut buffer = Vec::new();
    buffer.push(message_type);
    buffer.extend_from_slice(payload);
    write_u32(stream, buffer.len() as u32)?;
    stream.write_all(&buffer)?;
    Ok(())
}

fn read_ssh_message(stream: &mut std::os::unix::net::UnixStream) -> PrayResult<(u8, Vec<u8>)> {
    let length = read_u32(stream)? as usize;
    let mut buffer = vec![0u8; length];
    stream.read_exact(&mut buffer)?;
    let message_type = buffer
        .first()
        .copied()
        .ok_or_else(|| PrayError::Resolution("empty ssh agent response".to_string()))?;
    Ok((message_type, buffer[1..].to_vec()))
}

fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
    buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
    buffer.extend_from_slice(bytes);
}

fn read_ssh_string(cursor: &mut &[u8]) -> PrayResult<Vec<u8>> {
    let length = read_u32_from_slice(cursor)? as usize;
    if cursor.len() < length {
        return Err(PrayError::Resolution(
            "truncated ssh agent response".to_string(),
        ));
    }
    let (value, rest) = cursor.split_at(length);
    *cursor = rest;
    Ok(value.to_vec())
}

fn read_ssh_string_bytes(buffer: &[u8]) -> PrayResult<Vec<u8>> {
    let mut cursor = buffer;
    read_ssh_string(&mut cursor)
}

fn write_u32<T: Write>(writer: &mut T, value: u32) -> PrayResult<()> {
    writer.write_all(&value.to_be_bytes())?;
    Ok(())
}

fn read_u32<T: Read>(reader: &mut T) -> PrayResult<u32> {
    let mut buffer = [0u8; 4];
    reader.read_exact(&mut buffer)?;
    Ok(u32::from_be_bytes(buffer))
}

fn read_u32_from_slice(cursor: &mut &[u8]) -> PrayResult<u32> {
    if cursor.len() < 4 {
        return Err(PrayError::Resolution("truncated ssh field".to_string()));
    }
    let (length_bytes, rest) = cursor.split_at(4);
    *cursor = rest;
    Ok(u32::from_be_bytes(
        length_bytes.try_into().expect("length bytes"),
    ))
}