已發布的原始碼
下載原始碼 ZIP
CH-J Tools
瀏覽特定應用程式版本的目錄與檔案。
CH-J / Server Manager
1704 個檔案
0.1.2-beta
新增時間: · 建置時間:
1
//! Optional public OpenPGP distribution through the fixed CH-J Hagrid VKS endpoint.2
//! Requests contain a freshly serialized public certificate; private packets never leave Core.3
use crate::core::{4
error::{CoreError, CoreResult},5
keychain,6
vault::VaultStore,7
};8
use pgp::{9
composed::{Deserializable, SignedPublicKey, SignedSecretKey},10
types::KeyDetails,11
};12
use serde_json::{json, Value};13
use std::{io::Cursor, time::Duration};15
pub const ORIGIN: &str = "https://keys.ch-j.de";16
const MAX_BODY: usize = 1024 * 1024;17
fn error(code: &str, message: &str) -> CoreError {18
CoreError::new(code, message)19
}20
fn request_error(_: reqwest::Error) -> CoreError {21
error(22
"KEYSERVER_UNAVAILABLE",23
"Could not reach keys.ch-j.de over verified HTTPS. Check the network connection and try again.",24
)25
}26
// Resolve A and AAAA with the system's DNS servers (dual-stack since 2026-11-05, IPv6 first); using27
// explicit DNS also avoids macOS's negative getaddrinfo cache for newly added names.28
struct KeyserverResolver;29
impl reqwest::dns::Resolve for KeyserverResolver {30
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {31
let host = name.as_str().to_string();32
Box::pin(async move {33
type DnsError = Box<dyn std::error::Error + Send + Sync>;34
if host != "keys.ch-j.de" {35
return Err(36
Box::new(std::io::Error::other("Unexpected keyserver DNS name")) as DnsError,37
);38
}39
let (config, mut options) = hickory_resolver::system_conf::read_system_conf()40
.map_err(|e| Box::new(e) as DnsError)?;41
options.ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4;42
options.timeout = Duration::from_secs(4);43
options.attempts = 2;44
let resolver = hickory_resolver::TokioResolver::builder_with_config(45
config,46
hickory_resolver::name_server::TokioConnectionProvider::default(),47
)48
.with_options(options)49
.build();50
let lookup = resolver51
.lookup_ip("keys.ch-j.de.")52
.await53
.map_err(|e| Box::new(e) as DnsError)?;54
let addresses: Vec<std::net::SocketAddr> = lookup55
.iter()56
.map(|ip| std::net::SocketAddr::new(ip, 0))57
.collect();58
if addresses.is_empty() {59
return Err(60
Box::new(std::io::Error::other("Keyserver has no address")) as DnsError,61
);62
}63
Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)64
})65
}66
}67
pub(crate) fn client() -> CoreResult<reqwest::Client> {68
reqwest::Client::builder()69
.https_only(true)70
.dns_resolver(std::sync::Arc::new(KeyserverResolver))71
.use_rustls_tls()72
.min_tls_version(reqwest::tls::Version::TLS_1_2)73
.redirect(reqwest::redirect::Policy::none())74
.no_proxy()75
.connect_timeout(Duration::from_secs(10))76
.timeout(Duration::from_secs(30))77
.user_agent(concat!("CH-J-Tools/", env!("CARGO_PKG_VERSION")))78
.build()79
.map_err(request_error)80
}81
fn certificate(text: &str) -> CoreResult<SignedPublicKey> {82
if crate::core::openpgp_compat::handles(text.as_bytes())? {83
return Err(error("KEYSERVER_UNSUPPORTED_VERSION", "The keyserver supports OpenPGP V4 and V6 certificates."));84
}85
if text.len() > MAX_BODY || SignedSecretKey::from_reader_single(Cursor::new(text)).is_ok() {86
return Err(error(87
"KEYSERVER_PUBLIC_ONLY",88
"Only public OpenPGP certificates can be sent to the keyserver.",89
));90
}91
let (key, _) = SignedPublicKey::from_reader_single(Cursor::new(text)).map_err(|_| {92
error(93
"KEYSERVER_PUBLIC_ONLY",94
"Invalid public OpenPGP certificate.",95
)96
})?;97
key.verify_bindings().map_err(|_| {98
error(99
"KEYSERVER_INVALID_CERTIFICATE",100
"Invalid OpenPGP certificate bindings.",101
)102
})?;103
Ok(key)104
}105
pub fn public_payload(entry: &Value) -> CoreResult<(String, Vec<u8>)> {106
if entry["kind"] != "openpgp" {107
return Err(error(108
"KEYSERVER_OPENPGP_ONLY",109
"Hagrid distributes OpenPGP certificates; SSH identities remain in the local Keychain.",110
));111
}112
let mut key = certificate(entry["public"].as_str().unwrap_or(""))?;113
if !matches!(114
key.primary_key.version(),115
pgp::types::KeyVersion::V4 | pgp::types::KeyVersion::V6116
) {117
return Err(error(118
"KEYSERVER_UNSUPPORTED_VERSION",119
"The keyserver supports OpenPGP V4 and V6 certificates.",120
));121
}122
let fingerprint = hex::encode_upper(key.fingerprint().as_ref());123
if entry["fingerprint"] != fingerprint {124
return Err(error(125
"KEYSERVER_FINGERPRINT_MISMATCH",126
"The certificate fingerprint does not match this identity.",127
));128
}129
// New PQ certificates are fingerprint-first uploads. Self-certification is not130
// independent proof of email ownership; never disclose unverified email UIDs.131
let pq=key.primary_key.algorithm().is_pqc() || key.public_subkeys.iter().any(|s|s.key.algorithm().is_pqc());132
if pq && key.details.users.iter().any(|u|u.id.id().contains(&b'@')) {133
if key.primary_key.version()!=pgp::types::KeyVersion::V6 {134
return Err(error("KEYSERVER_UNVERIFIED_IDENTITY","This PQ V4 certificate contains an email identity. Use the keyserver's explicit identity-verification workflow; no upload was sent."));135
}136
key.details.users.retain(|u|!u.id.id().contains(&b'@'));137
}138
let text = key.to_armored_string(Default::default()).map_err(|_| {139
error(140
"KEYSERVER_INVALID_CERTIFICATE",141
"Could not export the public certificate.",142
)143
})?;144
let body = serde_json::to_vec(&json!({"keytext":text}))?;145
if body.len() > MAX_BODY {146
return Err(error(147
"KEYSERVER_TOO_LARGE",148
"The public certificate exceeds the keyserver upload limit.",149
));150
}151
Ok((fingerprint, body))152
}153
async fn body(mut response: reqwest::Response) -> CoreResult<Vec<u8>> {154
if response155
.content_length()156
.is_some_and(|size| size > MAX_BODY as u64)157
{158
return Err(error(159
"KEYSERVER_RESPONSE_INVALID",160
"Keyserver response exceeds the size limit.",161
));162
}163
let mut bytes = Vec::new();164
while let Some(chunk) = response.chunk().await.map_err(request_error)? {165
if bytes.len() + chunk.len() > MAX_BODY {166
return Err(error(167
"KEYSERVER_RESPONSE_INVALID",168
"Keyserver response exceeds the size limit.",169
));170
}171
bytes.extend_from_slice(&chunk);172
}173
Ok(bytes)174
}175
fn status(status: reqwest::StatusCode) -> CoreResult<()> {176
if status.is_success() {177
return Ok(());178
}179
Err(match status.as_u16() {180
429 => error(181
"KEYSERVER_RATE_LIMITED",182
"The keyserver rate limit was reached. Try again later.",183
),184
415 => error("KEYSERVER_UNSUPPORTED_FORMAT","The server does not support this certificate format. This is not a cryptographic verification failure."),185
413 => error(186
"KEYSERVER_TOO_LARGE",187
"The public certificate exceeds the keyserver upload limit.",188
),189
_ => error(190
"KEYSERVER_REJECTED",191
"The keyserver did not accept the request. No remote success has been recorded.",192
),193
}194
.with("status", status.as_u16()))195
}196
fn upload_ack(bytes: &[u8], fingerprint: &str) -> CoreResult<()> {197
let response: Value = serde_json::from_slice(bytes).map_err(|_| {198
error(199
"KEYSERVER_RESPONSE_INVALID",200
"Invalid keyserver acknowledgement.",201
)202
})?;203
if response["key_fpr"] != fingerprint {204
return Err(error(205
"KEYSERVER_FINGERPRINT_MISMATCH",206
"The keyserver acknowledged a different fingerprint.",207
));208
}209
Ok(())210
}211
pub(crate) async fn upload_with(212
client: &reqwest::Client,213
fingerprint: &str,214
payload: Vec<u8>,215
) -> CoreResult<()> {216
let response = client217
.post(format!("{ORIGIN}/vks/v1/upload"))218
.header(reqwest::header::CONTENT_TYPE, "application/json")219
.header(reqwest::header::ACCEPT, "application/json")220
.body(payload)221
.send()222
.await223
.map_err(request_error)?;224
status(response.status())?;225
upload_ack(&body(response).await?, fingerprint)226
}227
/// Downloads the public certificate for `fingerprint` (primary key or subkey, full hex) from keys.ch-j.de for a228
/// one-off verification. `Ok(None)` when the server does not know it. The returned certificate must contain the229
/// fingerprint, so the server cannot hand over an unrelated key.230
pub async fn fetch_certificate(fingerprint: &str) -> CoreResult<Option<String>> {231
let fingerprint: String = fingerprint.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_uppercase();232
if ![40, 64].contains(&fingerprint.len()) || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {233
return Err(error("KEYSERVER_FINGERPRINT_INVALID", "Use the full OpenPGP fingerprint (40 or 64 hexadecimal characters)."));234
}235
let response = client()?236
.get(format!("{ORIGIN}/vks/v1/by-fingerprint/{fingerprint}"))237
.header(reqwest::header::ACCEPT, "application/pgp-keys")238
.send()239
.await240
.map_err(request_error)?;241
if response.status() == reqwest::StatusCode::NOT_FOUND {242
return Ok(None);243
}244
status(response.status())?;245
let bytes = body(response).await?;246
let text = String::from_utf8(bytes).map_err(|_| error("KEYSERVER_RESPONSE_INVALID", "Invalid keyserver certificate."))?;247
let key = certificate(&text)?;248
let primary = hex::encode_upper(key.fingerprint().as_ref());249
let subkeys = key.public_subkeys.iter().map(|sub| hex::encode_upper(sub.key.fingerprint().as_ref()));250
if primary != fingerprint && !subkeys.into_iter().any(|fp| fp == fingerprint) {251
return Err(error("KEYSERVER_FINGERPRINT_MISMATCH", "The keyserver returned a different certificate."));252
}253
Ok(Some(text))254
}256
pub(crate) async fn lookup_with(client: &reqwest::Client, fingerprint: &str) -> CoreResult<Value> {257
if ![40, 64].contains(&fingerprint.len())258
|| !fingerprint259
.chars()260
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())261
{262
return Err(error(263
"KEYSERVER_FINGERPRINT_INVALID",264
"Use the full uppercase OpenPGP fingerprint.",265
));266
}267
let response = client268
.get(format!("{ORIGIN}/vks/v1/by-fingerprint/{fingerprint}"))269
.header(reqwest::header::ACCEPT, "application/pgp-keys")270
.send()271
.await272
.map_err(request_error)?;273
if response.status() == reqwest::StatusCode::NOT_FOUND {274
return Ok(json!({"found":false,"revoked":false,"subkeys":0}));275
}276
status(response.status())?;277
let bytes = body(response).await?;278
let text = std::str::from_utf8(&bytes).map_err(|_| {279
error(280
"KEYSERVER_RESPONSE_INVALID",281
"Invalid keyserver certificate.",282
)283
})?;284
let key = certificate(text)?;285
if hex::encode_upper(key.fingerprint().as_ref()) != fingerprint {286
return Err(error(287
"KEYSERVER_FINGERPRINT_MISMATCH",288
"The keyserver returned a different certificate.",289
));290
}291
let revoked = key292
.details293
.revocation_signatures294
.iter()295
.any(|sig| sig.verify_key(&key.primary_key).is_ok());296
Ok(json!({"found":true,"revoked":revoked,"subkeys":key.public_subkeys.len()}))297
}298
fn record(299
vault: &VaultStore,300
id: &str,301
generation: u64,302
fingerprint: &str,303
expected_public: &str,304
remote: &Value,305
published: bool,306
) -> CoreResult<Value> {307
vault.update_at_generation(generation, |data| {308
let entry = data309
.pointer_mut("/secrets/keychain")310
.and_then(Value::as_array_mut)311
.and_then(|items| items.iter_mut().find(|e| e["id"] == id))312
.ok_or_else(|| error("KEYCHAIN_NOT_FOUND", "Key was not found."))?;313
if entry["fingerprint"] != fingerprint || entry["public"] != expected_public {314
return Err(error(315
"KEYSERVER_FINGERPRINT_MISMATCH",316
"The identity changed while the request was running.",317
));318
}319
let now = chrono::Utc::now().to_rfc3339();320
if !entry["keyserver"].is_object() {321
entry["keyserver"] = json!({});322
}323
entry["keyserver"]["origin"] = json!(ORIGIN);324
entry["keyserver"]["checkedAt"] = json!(now);325
entry["keyserver"]["found"] = remote["found"].clone();326
entry["keyserver"]["revoked"] = remote["revoked"].clone();327
if published {328
entry["keyserver"][if remote["revoked"] == true {329
"revocationPublishedAt"330
} else {331
"publishedAt"332
}] = json!(now);333
}334
Ok(json!({"keyserver":entry["keyserver"],"identity":keychain::describe(entry)}))335
})336
}337
pub async fn publish(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {338
let entry = keychain::get_at_generation(vault, id, generation)?;339
let (fingerprint, payload) = public_payload(&entry)?;340
let http = client()?;341
vault.assert_generation(generation)?;342
upload_with(&http, &fingerprint, payload).await?;343
let remote = lookup_with(&http, &fingerprint).await?;344
if remote["found"] != true || (entry["revoked"] == true && remote["revoked"] != true) {345
return Err(error(346
"KEYSERVER_NOT_CONFIRMED",347
"The keyserver has not confirmed the certificate or its revocation. Retry publishing.",348
));349
}350
let mut result = record(351
vault,352
id,353
generation,354
&fingerprint,355
entry["public"].as_str().unwrap_or(""),356
&remote,357
true,358
)?;359
result["published"] = json!(true);360
Ok(result)361
}362
pub async fn check(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {363
let entry = keychain::get_at_generation(vault, id, generation)?;364
let (fingerprint, _) = public_payload(&entry)?;365
let remote = lookup_with(&client()?, &fingerprint).await?;366
record(367
vault,368
id,369
generation,370
&fingerprint,371
entry["public"].as_str().unwrap_or(""),372
&remote,373
false,374
)375
}377
#[cfg(test)]378
mod tests {379
use super::*;380
#[test]381
fn request_contains_only_reserialized_public_material_and_correct_identity() {382
let (_, entry) =383
crate::core::openpgp::generate(&json!({"name":"Synthetic API fixture","subkeys":[]}))384
.unwrap();385
let (fingerprint, body) = public_payload(&entry).unwrap();386
let request: Value = serde_json::from_slice(&body).unwrap();387
assert_eq!(request.as_object().unwrap().len(), 1);388
assert_eq!(fingerprint, entry["fingerprint"]);389
assert!(request["keytext"]390
.as_str()391
.unwrap()392
.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----"));393
assert!(!String::from_utf8(body).unwrap().contains("PRIVATE"));394
let mut tampered = entry.clone();395
tampered["public"] = entry["private"].clone();396
assert!(public_payload(&tampered).is_err());397
tampered = entry.clone();398
tampered["fingerprint"] = json!("0000000000000000000000000000000000000000");399
assert!(public_payload(&tampered).is_err());400
tampered["kind"] = json!("ssh");401
assert!(public_payload(&tampered).is_err());402
assert!(upload_ack(403
br#"{"key_fpr":"WRONG","token":"must-not-be-retained"}"#,404
&fingerprint405
)406
.is_err());407
assert!(upload_ack(408
serde_json::to_vec(&json!({"key_fpr":fingerprint,"token":"must-not-be-retained"}))409
.unwrap()410
.as_slice(),411
&fingerprint412
)413
.is_ok());414
}415
#[tokio::test]416
async fn remote_results_cannot_cross_a_lock_or_overwrite_a_changed_certificate() {417
let root = tempfile::tempdir().unwrap();418
let vault = VaultStore::new(root.path());419
vault.create("synthetic-password").await.unwrap();420
let (_, entry) =421
crate::core::openpgp::generate(&json!({"name":"Synthetic vault fixture","subkeys":[]}))422
.unwrap();423
let generation = vault.unlocked_generation().unwrap();424
keychain::store(&vault, entry.clone()).unwrap();425
let id = entry["id"].as_str().unwrap();426
let fp = entry["fingerprint"].as_str().unwrap();427
let public = entry["public"].as_str().unwrap();428
let remote = json!({"found":true,"revoked":false});429
record(&vault, id, generation, fp, public, &remote, true).unwrap();430
let revoked = keychain::revoke(&entry, "").unwrap();431
vault432
.update_at_generation(generation, |data| {433
data["secrets"]["keychain"][0] = revoked;434
Ok(())435
})436
.unwrap();437
assert!(record(&vault, id, generation, fp, public, &remote, true).is_err());438
vault.lock();439
vault.unlock("synthetic-password").await.unwrap();440
assert!(record(&vault, id, generation, fp, public, &remote, true).is_err());441
assert_eq!(keychain::get(&vault, id).unwrap()["revoked"], true);442
}443
#[tokio::test]444
#[ignore = "explicit live IPv6/HTTPS test against the owner's keyserver; publishes only synthetic public certificates and their revocations"]445
async fn live_hagrid_accepts_v4_v6_and_preserves_revocation() {446
let http = if let Ok(address) = std::env::var("CHJ_KEYS_TEST_IPV6") {447
let ip: std::net::Ipv6Addr = address.parse().unwrap();448
reqwest::Client::builder()449
.https_only(true)450
.redirect(reqwest::redirect::Policy::none())451
.no_proxy()452
.resolve("keys.ch-j.de", std::net::SocketAddr::new(ip.into(), 443))453
.timeout(Duration::from_secs(30))454
.build()455
.unwrap()456
} else {457
client().unwrap()458
};459
for version in [4, 6] {460
let (_,entry)=crate::core::openpgp::generate(&json!({"name":"CH-J API integration fixture","comment":uuid::Uuid::new_v4().to_string(),"version":version,"subkeys":[]})).unwrap();461
let (fp, payload) = public_payload(&entry).unwrap();462
assert_eq!(lookup_with(&http, &fp).await.unwrap()["found"], false);463
upload_with(&http, &fp, payload).await.unwrap();464
let status = lookup_with(&http, &fp).await.unwrap();465
assert_eq!(status["found"], true);466
assert_eq!(status["revoked"], false);467
let revoked = keychain::revoke(&entry, "").unwrap();468
let (_, payload) = public_payload(&revoked).unwrap();469
upload_with(&http, &fp, payload).await.unwrap();470
assert_eq!(lookup_with(&http, &fp).await.unwrap()["revoked"], true);471
let (_, old) = public_payload(&entry).unwrap();472
upload_with(&http, &fp, old).await.unwrap();473
assert_eq!(474
lookup_with(&http, &fp).await.unwrap()["revoked"],475
true,476
"Uploading an older certificate must not undo revocation"477
);478
}479
}480
}SHA-256
f9f9feca4089b727dae2984a78730af4e6c5304526a16d0474b1be9149037be2CH-J Proprietary Software License 1.14 · 原始碼依 CH-J Proprietary Software License 1.14 提供。公開原始碼不會改變授權條款,也不會授予額外的權利。
壓縮檔 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f