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:
parent
9c6df65ae2
commit
c844524f68
2 changed files with 55 additions and 3 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue