use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
use ssh_key::{private::KeypairData, Algorithm, PrivateKey};
use crate::error::AuthError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApiKeyStartLoginRequest {
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChallengeResponse {
pub challenge: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApiKeyVerifyLoginRequest {
pub api_key: String,
pub service: String,
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct Session {
pub session_key: String,
pub expires_in: i64,
}
impl Session {
pub fn basic_auth_header(&self) -> String {
let raw = format!("{0}:{0}", self.session_key);
format!("Basic {}", B64.encode(raw.as_bytes()))
}
}
pub fn sign_challenge(private_key: &SigningKey, challenge: &str) -> Result<String, AuthError> {
let signature = private_key.sign(challenge.as_bytes());
Ok(B64.encode(signature.to_bytes()))
}
pub fn parse_private_key_openssh(text: &str) -> Result<SigningKey, AuthError> {
let pk = PrivateKey::from_openssh(text)
.map_err(|e| AuthError::InvalidKey(format!("invalid OpenSSH private key: {e}")))?;
if pk.is_encrypted() {
return Err(AuthError::EncryptedKey);
}
if pk.algorithm() != Algorithm::Ed25519 {
return Err(AuthError::WrongAlgorithm {
got: pk.algorithm().as_str().to_owned(),
expected: "ed25519",
});
}
match pk.key_data() {
KeypairData::Ed25519(kp) => Ok(SigningKey::from_bytes(kp.private.as_ref())),
_ => Err(AuthError::KeyDataMismatch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::Verifier;
use pretty_assertions::assert_eq;
fn fixed_test_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
#[test]
fn sign_challenge_is_deterministic_for_fixed_key() {
let key = fixed_test_key();
let challenge = "the-challenge-string";
let s1 = sign_challenge(&key, challenge).unwrap();
let s2 = sign_challenge(&key, challenge).unwrap();
assert_eq!(s1, s2, "Ed25519 must be deterministic");
let s3 = sign_challenge(&key, "other").unwrap();
assert_ne!(s1, s3);
let raw = B64.decode(&s1).unwrap();
assert_eq!(raw.len(), 64);
}
#[test]
fn sign_then_verify_with_public_key_succeeds() {
let key = fixed_test_key();
let public = key.verifying_key();
let challenge = b"abc123";
let b64 = sign_challenge(&key, std::str::from_utf8(challenge).unwrap()).unwrap();
let raw = B64.decode(&b64).unwrap();
let sig_bytes: [u8; 64] = raw.try_into().unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
public
.verify(challenge, &signature)
.expect("signature must verify under the matching public key");
}
fn fixed_test_key_openssh() -> String {
use ssh_key::private::Ed25519Keypair;
use ssh_key::LineEnding;
let kp = Ed25519Keypair::from_seed(&[7u8; 32]);
let pk = ssh_key::PrivateKey::from(kp);
pk.to_openssh(LineEnding::LF).unwrap().to_string()
}
#[test]
fn parse_private_key_openssh_round_trips_seed() {
let pem = fixed_test_key_openssh();
let parsed = parse_private_key_openssh(&pem).unwrap();
assert_eq!(parsed.to_bytes(), [7u8; 32]);
let public = parsed.verifying_key();
let sig_b64 = sign_challenge(&parsed, "ping").unwrap();
let raw = B64.decode(&sig_b64).unwrap();
let sig_bytes: [u8; 64] = raw.try_into().unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
public.verify(b"ping", &signature).expect("verifies");
}
#[test]
fn parse_private_key_openssh_rejects_garbage() {
let r = parse_private_key_openssh("not a key");
assert!(matches!(r, Err(AuthError::InvalidKey(_))));
}
#[test]
fn parse_private_key_openssh_rejects_rsa_pem() {
let pem = "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n";
let r = parse_private_key_openssh(pem);
assert!(matches!(r, Err(AuthError::InvalidKey(_))));
}
#[test]
fn session_basic_auth_header_format() {
let s = Session {
session_key: "abc".to_owned(),
expires_in: 60,
};
assert_eq!(s.basic_auth_header(), "Basic YWJjOmFiYw==");
}
#[test]
fn signs_official_example_challenge() {
let key = fixed_test_key();
let challenge = "f0dcd2fa-92b1-4151-93af-61697eae217a";
let b64 = sign_challenge(&key, challenge).unwrap();
let raw = B64.decode(&b64).unwrap();
let sig_bytes: [u8; 64] = raw.try_into().unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
key.verifying_key()
.verify(challenge.as_bytes(), &signature)
.expect("signature must verify under matching public key");
}
#[test]
fn challenge_response_round_trip() {
let raw = r#"{"challenge":"abc"}"#;
let parsed: ChallengeResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.challenge, "abc");
assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
}
#[test]
fn api_key_verify_login_request_round_trip() {
let raw = r#"{"api_key":"AK","service":"NEXTAPI","signature":"c2ln"}"#;
let parsed: ApiKeyVerifyLoginRequest = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.api_key, "AK");
assert_eq!(parsed.service, "NEXTAPI");
assert_eq!(parsed.signature, "c2ln");
assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
}
}