From c844524f68296b393059d5eb70b1ef86b187b0a3 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Sun, 17 May 2026 13:41:18 +0800 Subject: [PATCH] fix(kei-buddy): close 3 HIGH audit findings from session multi-critic swarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- _primitives/_rust/kei-buddy/Cargo.toml | 2 +- .../_rust/kei-buddy/src/provision_decrypt.rs | 56 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/_primitives/_rust/kei-buddy/Cargo.toml b/_primitives/_rust/kei-buddy/Cargo.toml index f205a54..9c3857a 100644 --- a/_primitives/_rust/kei-buddy/Cargo.toml +++ b/_primitives/_rust/kei-buddy/Cargo.toml @@ -41,7 +41,7 @@ chrono = { workspace = true } # provision-crypto: x25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305 # Mirrors marketplace/src/lib/crypto-box.ts so VPS can decrypt the # bot-token blob emitted by the browser. -x25519-dalek = { version = "2", features = ["static_secrets"] } +x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] } chacha20poly1305 = { version = "0.10", features = ["alloc"] } hkdf = "0.12" sha2 = "0.10" diff --git a/_primitives/_rust/kei-buddy/src/provision_decrypt.rs b/_primitives/_rust/kei-buddy/src/provision_decrypt.rs index aea1822..f19f9b0 100644 --- a/_primitives/_rust/kei-buddy/src/provision_decrypt.rs +++ b/_primitives/_rust/kei-buddy/src/provision_decrypt.rs @@ -59,6 +59,17 @@ fn b64decode(s: &str) -> Result> { .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 @@ -69,8 +80,18 @@ fn parse_x25519_pkcs8_pem(pem: &str) -> Result<[u8; 32]> { let der = STANDARD .decode(body.trim()) .context("PEM body is not valid base64")?; - if der.len() < 32 { - bail!("PKCS#8 DER too short: {} bytes", der.len()); + 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..]); @@ -329,6 +350,37 @@ mod tests { 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);