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.
This commit is contained in:
Parfii-bot 2026-05-17 13:41:18 +08:00
parent 9c6df65ae2
commit c844524f68
2 changed files with 55 additions and 3 deletions

View file

@ -41,7 +41,7 @@ chrono = { workspace = true }
# provision-crypto: x25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305 # provision-crypto: x25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305
# Mirrors marketplace/src/lib/crypto-box.ts so VPS can decrypt the # Mirrors marketplace/src/lib/crypto-box.ts so VPS can decrypt the
# bot-token blob emitted by the browser. # 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"] } chacha20poly1305 = { version = "0.10", features = ["alloc"] }
hkdf = "0.12" hkdf = "0.12"
sha2 = "0.10" sha2 = "0.10"

View file

@ -59,6 +59,17 @@ fn b64decode(s: &str) -> Result<Vec<u8>> {
.map_err(|e| anyhow!("base64 decode: {e}")) .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]> { fn parse_x25519_pkcs8_pem(pem: &str) -> Result<[u8; 32]> {
let dash_prefix = "-".repeat(5); let dash_prefix = "-".repeat(5);
let body: String = pem let body: String = pem
@ -69,8 +80,18 @@ fn parse_x25519_pkcs8_pem(pem: &str) -> Result<[u8; 32]> {
let der = STANDARD let der = STANDARD
.decode(body.trim()) .decode(body.trim())
.context("PEM body is not valid base64")?; .context("PEM body is not valid base64")?;
if der.len() < 32 { if der.len() != X25519_PKCS8_DER_LEN {
bail!("PKCS#8 DER too short: {} bytes", 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]; let mut out = [0u8; 32];
out.copy_from_slice(&der[der.len() - 32..]); out.copy_from_slice(&der[der.len() - 32..]);
@ -329,6 +350,37 @@ mod tests {
assert_eq!(b64decode(urlsafe).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 { fn tempdir_unique() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0); static COUNTER: AtomicU64 = AtomicU64::new(0);