KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/provision_decrypt.rs
Parfii-bot cc6b8341a3 fix(kei-buddy): close 3 HIGH audit findings from session multi-critic swarm
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.
2026-05-17 13:41:18 +08:00

398 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}
}