ちょっと前に heika の v0.1.0 をリリースした。やったね🎉
heika は、Symbol のノード運営のための、鍵と証明書の Rust のツールキットである。ノードの証明書の発行や更新のほかに、ノードに TLS でつないで、ノードが出している証明書を確かめたり、近くて速いノードを探したりできる。Linux、macOS、Windows のバイナリを Release に置いている。
……はい、というわけで本題。heika を作る中で rustls でノードにつなごうとしたら、証明書が X.509 の v1 だというだけで弾かれた。この記事は、それをどう通したかの話である。
Symbol のノードの TLS
Symbol のノード(catapult)どうしは、次の設定の相互 TLS でつながる。
- TLS 1.3 だけ
- 鍵交換は X25519 だけ
- 証明書の鍵と署名は Ed25519
- 証明書は、ノードと CA の2枚のチェーン。CA は自己署名で、ノードの証明書は CA の鍵で署名されている
ノードの身元はアカウントの公開鍵で決まる。CA の鍵がメインアカウントの鍵で、ホスト名は関係ない。なので、ブラウザーのような「信頼できるルート CA から、名前の合う証明書がたどれるか」という確かめ方はしない。
そして、この証明書が拡張のない v1 のこともある。catapult は v1 でも受け付けるので、v1 の証明書のままのノードもある。heika も、v3(shoestring と同じ拡張)のほかに v1 の証明書を発行できるようにしている。
rustls が v1 を弾く場所
rustls は証明書を webpki(rustls-webpki)に読ませる。webpki は、証明書の version が v3 でなければ読まない。
// mozilla::pkix supports v1, v2, v3, and v4, including both the implicit
// (correct) and explicit (incorrect) encoding of v1. We allow only v3.
fn version3(input: &mut untrusted::Reader<'_>) -> Result<(), Error> {
v1 の証明書を渡すと、UnsupportedCertVersion で失敗する。そもそも Symbol のチェーンは WebPKI の確かめ方に合わないので、ServerCertVerifier は自分で書くことになる。ここまでは予想どおりだった。
ハマったのは、webpki が証明書を読む場所がもう1つあることだ。TLS 1.3 のハンドシェイクでは、相手が CertificateVerify で署名を送ってくる。この署名を確かめる rustls の verify_tls13_signature も、中で証明書を webpki に読ませている。
pub fn verify_tls13_signature(
msg: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
supported_schemes: &WebPkiSupportedAlgorithms,
) -> Result<HandshakeSignatureValid, Error> {
// 略
let cert = webpki::EndEntityCert::try_from(cert).map_err(pki_error)?;
verify_server_cert だけを自分のものにして、verify_tls13_signature は rustls のものに任せると、そこで v1 が弾かれる。
通し方
verify_server_cert と verify_tls13_signature の両方を、webpki を通らないように書く。
証明書は、v1 も読める x509-cert で読む。署名の確かめには、rustls が用意している verify_tls13_signature_with_raw_key を使う。これは証明書ではなく SubjectPublicKeyInfo だけを受け取る関数で、RFC 7250 の Raw Public Key のためのものだ。証明書から SubjectPublicKeyInfo を取り出して渡せば、webpki に証明書を読ませずに済む。
use std::sync::Arc;
use der::{Decode, Encode};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{CryptoProvider, verify_tls13_signature_with_raw_key};
use rustls::pki_types::{CertificateDer, ServerName, SubjectPublicKeyInfoDer, UnixTime};
use rustls::{CertificateError, DigitallySignedStruct, Error, SignatureScheme};
#[derive(Debug)]
struct Verifier {
provider: Arc<CryptoProvider>,
}
impl ServerCertVerifier for Verifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, Error> {
// ノード、CA の2枚を x509-cert で読み、
// CA が自己署名で、ノードが CA の鍵で署名されていて、どちらも有効期間内かを確かめる
todo!()
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
Err(Error::General("TLS 1.2 is not supported".into()))
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
let spki = spki(cert)?;
verify_tls13_signature_with_raw_key(
message,
&SubjectPublicKeyInfoDer::from(&spki[..]),
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![SignatureScheme::ED25519]
}
}
/// 証明書の SubjectPublicKeyInfo の DER。x509-cert は v1 も読める
fn spki(cert: &CertificateDer<'_>) -> Result<Vec<u8>, Error> {
let cert = x509_cert::Certificate::from_der(cert)
.map_err(|_| Error::from(CertificateError::BadEncoding))?;
cert.tbs_certificate()
.subject_public_key_info()
.to_der()
.map_err(|_| CertificateError::BadEncoding.into())
}
設定は、TLS 1.3 と X25519 だけに絞ってから、この Verifier を渡す。
let mut provider = rustls::crypto::ring::default_provider();
// catapult は鍵交換に X25519 しか使わない
provider.kx_groups = vec![rustls::crypto::ring::kx_group::X25519];
let provider = Arc::new(provider);
let config = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(&[&rustls::version::TLS13])?
.dangerous()
.with_custom_certificate_verifier(Arc::new(Verifier { provider }))
.with_client_cert_resolver(resolver);
dangerous() という名前のとおり、ここから先は自分で確かめた分しか確かめられない。heika では、catapult がつなぐときと同じ条件を確かめている。
- 2枚のチェーンであること
- CA が自己署名であること
- ノードの証明書の issuer が CA の subject と同じで、CA の鍵で署名されていること
- どちらの証明書も有効期間内であること
ホスト名は確かめない。catapult も見ないので、SNI には適当な名前を入れている。
こちらが v1 の証明書で名乗るとき
相互 TLS なので、こちらも証明書で名乗る。こちらの証明書は rustls が読まずにそのまま送るので、v1 でも何もしなくてよい。CertifiedKey に DER と秘密鍵を入れて、with_client_cert_resolver に渡すだけである。
テストのために rustls でサーバーを立てるなら、ClientCertVerifier の側にも同じことが要る。verify_tls13_signature を、同じように verify_tls13_signature_with_raw_key で書く。
まとめ
- webpki は v3 の証明書しか読まない
- rustls で v1 を通すには、
ServerCertVerifierのverify_server_certとverify_tls13_signatureの両方を、webpki を通らないように書く - 署名の確かめは、証明書から取り出した SubjectPublicKeyInfo を
verify_tls13_signature_with_raw_keyに渡せばよい - こちらが名乗る証明書は、v1 でもそのまま使える
heika: https://github.com/HinaBlogChannel/heika
heika cert show --connect <ホスト>:<ポート> --client-cert node.full.crt.pem < node.key.pem
で、ノードが出している証明書と、こちらの証明書が受け付けられたかを確かめられる。
リリースしたその日に Twitter のアカウントが凍結されて、heika のことをつぶやけなくなった。ぴえん。なので、ここに書いている。Twitter は凍結されても、Claude Code の契約だけは凍結しないでほしい。