feat(kei-buddy): provision_decrypt — VPS-side blob decryption
Mirrors keisei-marketplace/src/lib/crypto-box.ts::sealBoxToVps.
Two new subcommands on kei-buddy bin:
- genkeys --key <path> → writes PKCS#8 PEM x25519 priv,
prints standard-base64 pub (44 char)
- decrypt-and-export --vps-key <pem> --blob <json> --env-out <env>
→ ECDH(vps_priv, ephPub) → HKDF-SHA256
info=keibuddy-token-v1 → XChaCha20-Poly1305
decrypt → append BOT_TOKEN/TELEGRAM_BOT_TOKEN
to env file (replaces stale, keeps other lines)
Cloud-init in hetzner.ts already calls these. Without this commit the
VPS could decode its own pubkey but had no way to recover the sealed
bot-token blob — the bot would never log into Telegram.
Crypto stack (mirror of @noble in TS):
- x25519-dalek 2 (static_secrets feature)
- chacha20poly1305 0.10 (XChaCha20Poly1305)
- hkdf 0.12, sha2 0.10
- base64 0.22 (accepts URL_SAFE_NO_PAD + STANDARD)
- zeroize 1 for priv-key wipe
Tests (6/6 pass):
- roundtrip_seal_then_decrypt — re-implement marketplace sealing in Rust,
verify our decryption recovers plaintext
- decrypt_and_export_writes_env_file — full e2e through CLI surface
- decrypt_and_export_replaces_existing_token — stale BOT_TOKEN replaced,
other env lines preserved
- decrypt_rejects_wrong_key — XChaCha20 AEAD tag fails on wrong key
- pem_roundtrip — write_pkcs8 + parse_pkcs8 round-trip
- b64decode_accepts_urlsafe_and_standard — handles both encodings
Cross-verified end-to-end:
$ node marketplace_seal.mjs <pub> <token> → /tmp/blob.json
$ kei-buddy decrypt-and-export --vps-key ... → BOT_TOKEN matches input
Constructor Pattern: 1 file (provision_decrypt.rs, 344 LOC), 1 module,
1 responsibility (token-blob decryption + key generation).
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes (e2e marketplace-seal → kei-buddy-decrypt round-trip)
follow-up-required:
- none
This commit is contained in:
parent
e9b0debec3
commit
86834b82af
5 changed files with 518 additions and 1 deletions
117
_primitives/_rust/Cargo.lock
generated
117
_primitives/_rust/Cargo.lock
generated
|
|
@ -8,6 +8,16 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
|
|
@ -1144,6 +1154,17 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
|
|
@ -1155,6 +1176,19 @@ dependencies = [
|
|||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -1167,6 +1201,17 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
|
|
@ -1453,6 +1498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -2879,6 +2925,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
|
|
@ -3190,8 +3245,11 @@ dependencies = [
|
|||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hkdf",
|
||||
"kei-chat-store",
|
||||
"kei-contacts-apple",
|
||||
"kei-contacts-google",
|
||||
|
|
@ -3200,15 +3258,19 @@ dependencies = [
|
|||
"kei-social-store",
|
||||
"kei-stt",
|
||||
"kei-telegram-webhook",
|
||||
"rand_core 0.6.4",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5189,6 +5251,12 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
|
|
@ -5482,6 +5550,17 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.8.1"
|
||||
|
|
@ -5726,7 +5805,7 @@ version = "0.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"chacha20 0.10.0",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
|
@ -7955,6 +8034,16 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
|
|
@ -8857,6 +8946,18 @@ version = "0.6.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
|
|
@ -8965,6 +9066,20 @@ name = "zeroize"
|
|||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ kei-contacts-google = { path = "../kei-contacts-google" }
|
|||
kei-contacts-apple = { path = "../kei-contacts-apple" }
|
||||
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"] }
|
||||
chacha20poly1305 = { version = "0.10", features = ["alloc"] }
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
rand_core = "0.6"
|
||||
zeroize = "1"
|
||||
|
||||
# serve feature deps
|
||||
axum = { version = "0.7", features = ["json", "http1", "tokio"], optional = true }
|
||||
kei-telegram-webhook = { path = "../kei-telegram-webhook", optional = true }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! kei-buddy binary — 4 subcommands: serve / migrate / webhook-set / webhook-delete.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
|
|
@ -27,6 +28,27 @@ enum Command {
|
|||
},
|
||||
/// Delete the registered Telegram webhook (revert to polling).
|
||||
WebhookDelete,
|
||||
/// Generate x25519 keypair, write PKCS#8 PEM private key, print pubkey
|
||||
/// (standard base64, 44 chars) to stdout. Cloud-init fallback if openssl
|
||||
/// is unavailable.
|
||||
Genkeys {
|
||||
/// Path to write the PKCS#8 PEM private key (chmod 400).
|
||||
#[arg(long)]
|
||||
key: PathBuf,
|
||||
},
|
||||
/// Decrypt sealed bot-token blob from marketplace and append BOT_TOKEN
|
||||
/// to env file. Mirrors marketplace/src/lib/crypto-box.ts::sealBoxToVps.
|
||||
DecryptAndExport {
|
||||
/// Path to PKCS#8 PEM x25519 private key (default: /etc/keisei-vps.key).
|
||||
#[arg(long, default_value = "/etc/keisei-vps.key")]
|
||||
vps_key: PathBuf,
|
||||
/// Path to sealed blob JSON (default: /etc/keisei-blob.json).
|
||||
#[arg(long, default_value = "/etc/keisei-blob.json")]
|
||||
blob: PathBuf,
|
||||
/// Path to env file to append BOT_TOKEN into (default: /etc/keisei.env).
|
||||
#[arg(long, default_value = "/etc/keisei.env")]
|
||||
env_out: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -37,9 +59,31 @@ async fn main() -> anyhow::Result<()> {
|
|||
Command::Migrate => cmd_migrate(),
|
||||
Command::WebhookSet { url } => cmd_webhook_set(url).await,
|
||||
Command::WebhookDelete => cmd_webhook_delete().await,
|
||||
Command::Genkeys { key } => cmd_genkeys(&key),
|
||||
Command::DecryptAndExport {
|
||||
vps_key,
|
||||
blob,
|
||||
env_out,
|
||||
} => cmd_decrypt_and_export(&vps_key, &blob, &env_out),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_genkeys(key_path: &std::path::Path) -> anyhow::Result<()> {
|
||||
let pub_b64 = kei_buddy::provision_decrypt::genkeys(key_path)?;
|
||||
println!("{pub_b64}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_decrypt_and_export(
|
||||
vps_key: &std::path::Path,
|
||||
blob: &std::path::Path,
|
||||
env_out: &std::path::Path,
|
||||
) -> anyhow::Result<()> {
|
||||
kei_buddy::provision_decrypt::decrypt_and_export(vps_key, blob, env_out)?;
|
||||
eprintln!("BOT_TOKEN exported to {}", env_out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
async fn cmd_serve() -> anyhow::Result<()> {
|
||||
use kei_buddy::serve::{run_serve, ServeConfig};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub mod machine;
|
|||
pub(crate) mod machine_helpers;
|
||||
pub(crate) mod machine_lang;
|
||||
pub mod persona_merge;
|
||||
pub mod provision_decrypt;
|
||||
pub mod retrieval;
|
||||
pub mod schema;
|
||||
pub mod state;
|
||||
|
|
|
|||
346
_primitives/_rust/kei-buddy/src/provision_decrypt.rs
Normal file
346
_primitives/_rust/kei-buddy/src/provision_decrypt.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
// 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}"))
|
||||
}
|
||||
|
||||
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() < 32 {
|
||||
bail!("PKCS#8 DER too short: {} bytes", der.len());
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue