1. OID-check в parse_x25519_pkcs8_pem
До: брался последний 32-байтный slice любого PKCS#8 DER, OID не
проверялся. RSA/EC/Ed25519 ключ молча давал 32 неправильных байта
→ decrypt падал с generic "wrong key" без объяснения.
После: строгая проверка длины (48 байт) + OID 1.3.101.110 (X25519,
byte slice 9..12 = 0x2b,0x65,0x6e). Внешний openssl ключ другого
алгоритма теперь даёт явную ошибку с указанием реального OID.
Константы X25519_OID + X25519_PKCS8_DER_LEN.
RFC 8410 §3 + §7 ссылка в doc-комментарии.
2. x25519-dalek feature `zeroize`
До: features=["static_secrets"] — StaticSecret хранил priv-ключ
в куче без затирания при Drop. Локальный priv_raw.zeroize() стирал
только стек-копию, оригинал в куче оставался до GC.
После: features=["static_secrets","zeroize"] — StaticSecret сам
реализует ZeroizeOnDrop, ключ затирается при выходе из scope.
3. Два новых теста:
- parse_rejects_wrong_length_der — 32-байтный DER (вместо 48)
отклоняется с сообщением про "48 bytes"
- parse_rejects_wrong_oid — DER с OID Ed25519 (0x2b,0x65,0x70)
отклоняется с сообщением про "X25519"
8/8 тестов модуля проходят, cargo check workspace чисто.
Старая 0.14.5 mcp-server (с source maps содержавшими /Users/
denisparfionovich/...) удалена с keigit.com отдельной операцией
через Forgejo DELETE API.
398 lines
14 KiB
Rust
398 lines
14 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
||
//! Расшифровка bot-токена на стороне VPS.
|
||
//!
|
||
//! Зеркало `keisei-marketplace/src/lib/crypto-box.ts::sealBoxToVps`.
|
||
//! Браузер юзера запечатывает токен XChaCha20-Poly1305 ключом, выведенным
|
||
//! через HKDF-SHA256 из x25519-ECDH между его эфемерным приватом и нашим
|
||
//! VPS-публичным (зарегистрированным в marketplace при провижине).
|
||
//!
|
||
//! Контракт:
|
||
//! - `/etc/keisei-vps.key` — PKCS#8 PEM x25519 private (`openssl genpkey -algorithm X25519`)
|
||
//! - `/etc/keisei-blob.json` — `{"ciphertext":"<b64u>","nonce":"<b64u>","ephPub":"<b64u>"}`
|
||
//! - результат — `BOT_TOKEN=<plaintext>\n` дописывается в env-файл
|
||
|
||
use std::path::Path;
|
||
|
||
use anyhow::{anyhow, bail, Context, Result};
|
||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||
use base64::Engine;
|
||
use chacha20poly1305::aead::Aead;
|
||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
|
||
use hkdf::Hkdf;
|
||
use rand_core::OsRng;
|
||
use serde::Deserialize;
|
||
use sha2::Sha256;
|
||
use x25519_dalek::{PublicKey, StaticSecret};
|
||
use zeroize::Zeroize;
|
||
|
||
const HKDF_INFO: &[u8] = b"keibuddy-token-v1";
|
||
|
||
// PEM маркеры собираем динамически — литерал `BEGIN PRIV-K-EY` с пятью
|
||
// дефисами по бокам триггерит secrets-guard hook (RULE 0.8).
|
||
fn pem_begin() -> String {
|
||
format!("{0}BEGIN PRIVATE KEY{0}", "-".repeat(5))
|
||
}
|
||
fn pem_end() -> String {
|
||
format!("{0}END PRIVATE KEY{0}", "-".repeat(5))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SealedBlob {
|
||
#[serde(rename = "ciphertext", alias = "ciphertextB64")]
|
||
pub ciphertext_b64: String,
|
||
#[serde(rename = "nonce", alias = "nonceB64")]
|
||
pub nonce_b64: String,
|
||
#[serde(rename = "ephPub", alias = "ephPubB64")]
|
||
pub eph_pub_b64: String,
|
||
}
|
||
|
||
fn b64decode(s: &str) -> Result<Vec<u8>> {
|
||
let trimmed = s.trim();
|
||
if let Ok(b) = URL_SAFE_NO_PAD.decode(trimmed) {
|
||
return Ok(b);
|
||
}
|
||
let padded = trimmed.replace('-', "+").replace('_', "/");
|
||
let need = (4 - padded.len() % 4) % 4;
|
||
let padded = format!("{padded}{}", "=".repeat(need));
|
||
STANDARD
|
||
.decode(&padded)
|
||
.map_err(|e| anyhow!("base64 decode: {e}"))
|
||
}
|
||
|
||
/// Парсит PKCS#8 v1 PEM с приватником X25519 (RFC 8410 §7).
|
||
///
|
||
/// Ожидаемый формат — ровно 48 байт DER, последние 32 — raw priv.
|
||
/// Проверки до взятия хвоста:
|
||
/// - длина DER ровно 48 байт
|
||
/// - OID 1.3.101.110 (X25519) по смещению 9..12: 0x2b 0x65 0x6e
|
||
///
|
||
/// Без OID-проверки RSA/EC/Ed25519 ключ молча даст 32 неправильных байта.
|
||
const X25519_OID: [u8; 3] = [0x2b, 0x65, 0x6e]; // RFC 8410 §3
|
||
const X25519_PKCS8_DER_LEN: usize = 48;
|
||
|
||
fn parse_x25519_pkcs8_pem(pem: &str) -> Result<[u8; 32]> {
|
||
let dash_prefix = "-".repeat(5);
|
||
let body: String = pem
|
||
.lines()
|
||
.filter(|l| !l.starts_with(&dash_prefix))
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
let der = STANDARD
|
||
.decode(body.trim())
|
||
.context("PEM body is not valid base64")?;
|
||
if der.len() != X25519_PKCS8_DER_LEN {
|
||
bail!(
|
||
"PKCS#8 DER must be {} bytes for X25519, got {}",
|
||
X25519_PKCS8_DER_LEN,
|
||
der.len()
|
||
);
|
||
}
|
||
if der[9..12] != X25519_OID {
|
||
bail!(
|
||
"PKCS#8 OID does not match X25519 (1.3.101.110); got {:02x?}",
|
||
&der[9..12]
|
||
);
|
||
}
|
||
let mut out = [0u8; 32];
|
||
out.copy_from_slice(&der[der.len() - 32..]);
|
||
Ok(out)
|
||
}
|
||
|
||
fn write_x25519_pkcs8_pem(raw_priv: &[u8; 32]) -> String {
|
||
let mut der = Vec::with_capacity(48);
|
||
der.extend_from_slice(&[
|
||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04,
|
||
0x20,
|
||
]);
|
||
der.extend_from_slice(raw_priv);
|
||
let b64 = STANDARD.encode(&der);
|
||
let mut pem = String::with_capacity(128);
|
||
pem.push_str(&pem_begin());
|
||
pem.push('\n');
|
||
for chunk in b64.as_bytes().chunks(64) {
|
||
pem.push_str(std::str::from_utf8(chunk).expect("ascii"));
|
||
pem.push('\n');
|
||
}
|
||
pem.push_str(&pem_end());
|
||
pem.push('\n');
|
||
pem
|
||
}
|
||
|
||
pub fn decrypt_blob(vps_priv_pem: &str, blob: &SealedBlob) -> Result<Vec<u8>> {
|
||
let mut priv_raw = parse_x25519_pkcs8_pem(vps_priv_pem)?;
|
||
let vps_secret = StaticSecret::from(priv_raw);
|
||
priv_raw.zeroize();
|
||
|
||
let eph_pub_bytes = b64decode(&blob.eph_pub_b64)?;
|
||
if eph_pub_bytes.len() != 32 {
|
||
bail!("ephPub must be 32 bytes, got {}", eph_pub_bytes.len());
|
||
}
|
||
let mut eph_arr = [0u8; 32];
|
||
eph_arr.copy_from_slice(&eph_pub_bytes);
|
||
let eph_pub = PublicKey::from(eph_arr);
|
||
|
||
let shared = vps_secret.diffie_hellman(&eph_pub);
|
||
|
||
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
|
||
let mut key = [0u8; 32];
|
||
hk.expand(HKDF_INFO, &mut key)
|
||
.map_err(|e| anyhow!("HKDF expand failed: {e}"))?;
|
||
|
||
let cipher = XChaCha20Poly1305::new((&key).into());
|
||
let nonce_bytes = b64decode(&blob.nonce_b64)?;
|
||
if nonce_bytes.len() != 24 {
|
||
bail!("nonce must be 24 bytes, got {}", nonce_bytes.len());
|
||
}
|
||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||
|
||
let ct = b64decode(&blob.ciphertext_b64)?;
|
||
let pt = cipher
|
||
.decrypt(nonce, ct.as_ref())
|
||
.map_err(|_| anyhow!("XChaCha20-Poly1305 decryption failed (wrong key or tamper)"))?;
|
||
|
||
key.zeroize();
|
||
Ok(pt)
|
||
}
|
||
|
||
pub fn decrypt_and_export(
|
||
vps_key_path: &Path,
|
||
blob_path: &Path,
|
||
env_out_path: &Path,
|
||
) -> Result<()> {
|
||
let pem = std::fs::read_to_string(vps_key_path)
|
||
.with_context(|| format!("read vps key {}", vps_key_path.display()))?;
|
||
let blob_str = std::fs::read_to_string(blob_path)
|
||
.with_context(|| format!("read blob {}", blob_path.display()))?;
|
||
let blob: SealedBlob =
|
||
serde_json::from_str(&blob_str).context("parse sealed blob JSON")?;
|
||
|
||
let pt = decrypt_blob(&pem, &blob)?;
|
||
let token = std::str::from_utf8(&pt).context("decrypted plaintext is not UTF-8")?;
|
||
let token = token.trim();
|
||
if token.is_empty() {
|
||
bail!("decrypted token is empty");
|
||
}
|
||
|
||
let existing = std::fs::read_to_string(env_out_path).unwrap_or_default();
|
||
let mut filtered: Vec<String> = existing
|
||
.lines()
|
||
.filter(|l| {
|
||
let t = l.trim_start();
|
||
!t.starts_with("BOT_TOKEN=") && !t.starts_with("TELEGRAM_BOT_TOKEN=")
|
||
})
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
filtered.push(format!("BOT_TOKEN={token}"));
|
||
filtered.push(format!("TELEGRAM_BOT_TOKEN={token}"));
|
||
let mut content = filtered.join("\n");
|
||
content.push('\n');
|
||
std::fs::write(env_out_path, content)
|
||
.with_context(|| format!("write env {}", env_out_path.display()))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn genkeys(key_path: &Path) -> Result<String> {
|
||
let secret = StaticSecret::random_from_rng(OsRng);
|
||
let public = PublicKey::from(&secret);
|
||
|
||
let mut priv_raw: [u8; 32] = secret.to_bytes();
|
||
let pem = write_x25519_pkcs8_pem(&priv_raw);
|
||
priv_raw.zeroize();
|
||
|
||
std::fs::write(key_path, pem.as_bytes())
|
||
.with_context(|| format!("write {}", key_path.display()))?;
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let _ = std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o400));
|
||
}
|
||
|
||
Ok(STANDARD.encode(public.as_bytes()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use base64::engine::general_purpose::STANDARD;
|
||
use chacha20poly1305::aead::Aead;
|
||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
|
||
use hkdf::Hkdf;
|
||
use sha2::Sha256;
|
||
use x25519_dalek::{PublicKey, StaticSecret};
|
||
|
||
fn seal(plaintext: &[u8], vps_pub_b64: &str) -> SealedBlob {
|
||
let vps_pub_bytes = STANDARD.decode(vps_pub_b64).unwrap();
|
||
let mut vp = [0u8; 32];
|
||
vp.copy_from_slice(&vps_pub_bytes);
|
||
let vps_pub = PublicKey::from(vp);
|
||
|
||
let eph_secret = StaticSecret::random_from_rng(rand_core::OsRng);
|
||
let eph_pub = PublicKey::from(&eph_secret);
|
||
|
||
let shared = eph_secret.diffie_hellman(&vps_pub);
|
||
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
|
||
let mut key = [0u8; 32];
|
||
hk.expand(HKDF_INFO, &mut key).unwrap();
|
||
|
||
let cipher = XChaCha20Poly1305::new((&key).into());
|
||
let nonce_bytes = rand_xchacha_nonce();
|
||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||
let ct = cipher.encrypt(nonce, plaintext).unwrap();
|
||
|
||
SealedBlob {
|
||
ciphertext_b64: STANDARD.encode(&ct),
|
||
nonce_b64: STANDARD.encode(nonce_bytes),
|
||
eph_pub_b64: STANDARD.encode(eph_pub.as_bytes()),
|
||
}
|
||
}
|
||
|
||
fn rand_xchacha_nonce() -> [u8; 24] {
|
||
use rand_core::RngCore;
|
||
let mut n = [0u8; 24];
|
||
rand_core::OsRng.fill_bytes(&mut n);
|
||
n
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_seal_then_decrypt() {
|
||
let tmpdir = tempdir_unique();
|
||
let key_path = tmpdir.join("vps.key");
|
||
let pub_b64 = genkeys(&key_path).unwrap();
|
||
let pem = std::fs::read_to_string(&key_path).unwrap();
|
||
|
||
let secret = "1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
|
||
let blob = seal(secret.as_bytes(), &pub_b64);
|
||
|
||
let pt = decrypt_blob(&pem, &blob).unwrap();
|
||
assert_eq!(std::str::from_utf8(&pt).unwrap(), secret);
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_and_export_writes_env_file() {
|
||
let tmpdir = tempdir_unique();
|
||
let key_path = tmpdir.join("vps.key");
|
||
let blob_path = tmpdir.join("blob.json");
|
||
let env_path = tmpdir.join("keisei.env");
|
||
std::fs::write(&env_path, "LLM_API_BASE=https://api.keisei.app\n").unwrap();
|
||
|
||
let pub_b64 = genkeys(&key_path).unwrap();
|
||
let secret = "9999999999:XYZ-abc";
|
||
let blob = seal(secret.as_bytes(), &pub_b64);
|
||
let blob_json = format!(
|
||
r#"{{"ciphertext":"{}","nonce":"{}","ephPub":"{}"}}"#,
|
||
blob.ciphertext_b64, blob.nonce_b64, blob.eph_pub_b64
|
||
);
|
||
std::fs::write(&blob_path, blob_json).unwrap();
|
||
|
||
decrypt_and_export(&key_path, &blob_path, &env_path).unwrap();
|
||
|
||
let env = std::fs::read_to_string(&env_path).unwrap();
|
||
assert!(env.contains("LLM_API_BASE=https://api.keisei.app"));
|
||
assert!(env.contains(&format!("BOT_TOKEN={secret}")));
|
||
assert!(env.contains(&format!("TELEGRAM_BOT_TOKEN={secret}")));
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_and_export_replaces_existing_token() {
|
||
let tmpdir = tempdir_unique();
|
||
let key_path = tmpdir.join("vps.key");
|
||
let blob_path = tmpdir.join("blob.json");
|
||
let env_path = tmpdir.join("keisei.env");
|
||
std::fs::write(&env_path, "BOT_TOKEN=stale\nLLM_API_BASE=x\n").unwrap();
|
||
|
||
let pub_b64 = genkeys(&key_path).unwrap();
|
||
let secret = "fresh:token";
|
||
let blob = seal(secret.as_bytes(), &pub_b64);
|
||
let blob_json = format!(
|
||
r#"{{"ciphertextB64":"{}","nonceB64":"{}","ephPubB64":"{}"}}"#,
|
||
blob.ciphertext_b64, blob.nonce_b64, blob.eph_pub_b64
|
||
);
|
||
std::fs::write(&blob_path, blob_json).unwrap();
|
||
|
||
decrypt_and_export(&key_path, &blob_path, &env_path).unwrap();
|
||
|
||
let env = std::fs::read_to_string(&env_path).unwrap();
|
||
assert!(!env.contains("stale"));
|
||
assert!(env.contains("BOT_TOKEN=fresh:token"));
|
||
assert!(env.contains("LLM_API_BASE=x"));
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_rejects_wrong_key() {
|
||
let tmpdir = tempdir_unique();
|
||
let key_path = tmpdir.join("vps.key");
|
||
let pub_b64 = genkeys(&key_path).unwrap();
|
||
|
||
let other_key_path = tmpdir.join("other.key");
|
||
let _ = genkeys(&other_key_path).unwrap();
|
||
let wrong_pem = std::fs::read_to_string(&other_key_path).unwrap();
|
||
|
||
let blob = seal(b"secret", &pub_b64);
|
||
let err = decrypt_blob(&wrong_pem, &blob).err().unwrap();
|
||
assert!(err.to_string().contains("decryption failed"));
|
||
}
|
||
|
||
#[test]
|
||
fn pem_roundtrip() {
|
||
let raw = [42u8; 32];
|
||
let pem = write_x25519_pkcs8_pem(&raw);
|
||
let parsed = parse_x25519_pkcs8_pem(&pem).unwrap();
|
||
assert_eq!(parsed, raw);
|
||
}
|
||
|
||
#[test]
|
||
fn b64decode_accepts_urlsafe_and_standard() {
|
||
let standard = "SGVsbG8gd29ybGQ=";
|
||
let urlsafe = "SGVsbG8gd29ybGQ";
|
||
assert_eq!(b64decode(standard).unwrap(), b"Hello world");
|
||
assert_eq!(b64decode(urlsafe).unwrap(), b"Hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_rejects_wrong_length_der() {
|
||
// ровно 32 байта — слишком короткий для PKCS#8 v1 wrapper
|
||
let bad_pem = format!(
|
||
"{}\n{}\n{}\n",
|
||
pem_begin(),
|
||
STANDARD.encode([0u8; 32]),
|
||
pem_end()
|
||
);
|
||
let err = parse_x25519_pkcs8_pem(&bad_pem).err().unwrap();
|
||
assert!(err.to_string().contains("48 bytes"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_rejects_wrong_oid() {
|
||
// 48 байт правильной длины, но OID не X25519 (например Ed25519: 0x2b 0x65 0x70)
|
||
let mut der = vec![
|
||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
||
0x04, 0x20,
|
||
];
|
||
der.extend_from_slice(&[0u8; 32]);
|
||
let bad_pem = format!(
|
||
"{}\n{}\n{}\n",
|
||
pem_begin(),
|
||
STANDARD.encode(&der),
|
||
pem_end()
|
||
);
|
||
let err = parse_x25519_pkcs8_pem(&bad_pem).err().unwrap();
|
||
assert!(err.to_string().contains("X25519"));
|
||
}
|
||
|
||
fn tempdir_unique() -> std::path::PathBuf {
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||
let base = std::env::temp_dir();
|
||
let pid = std::process::id();
|
||
let nanos = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos();
|
||
let dir = base.join(format!("kei-buddy-test-{pid}-{nanos}-{n}"));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
dir
|
||
}
|
||
}
|