みなさんこんにちは。エンジニアの佐藤です。今回は年末調整のお話です。
年末調整の時節、会社からは「保険料控除ではXMLを奨励」というアナウンスがありました。「エックスエムエル!」という言葉と共に新鮮な驚きが頭の中に広がりました。ええっ、XMLだって?あいつらまだ生きていたのか!そう、コンピュータ同士の相互通信データフォーマットとして、XMLは時代遅れなのです。現在の主役はとっくにJSON。何を今更XML。
しかし、筆者はXMLは嫌いではありませんし、それなりに知識もあります。(昔話に興味のある方は、末尾の「昔話」をご覧ください。) 保険業社が発行するXMLがどんなものか、大きな興味をもってファイルを開いてみました。
保険料控除のXMLはどういう構造になっているのか
ざっくり言うと、以下のような構造になっています。
<文書> <保険情報詳細/> <署名情報/> </文書>
その仕様は「源泉徴収票等オンライン化に関する仕様書」として国税庁が配布しています。これ自体は特に難解なわけではなく、保険業社や支払い金額など、年末調整の保険料控除の書類に記入していた項目が書かれているだけです。
難しいのは(そしておもしろいのは)、署名情報の部分です。概要は以下のようになっています。
<dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" Id="..."> <dsig:SignedInfo> <dsig:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> <dsig:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/> <dsig:Reference URI="#(保険情報詳細データへのポインタ)"> <dsig:Transforms> <dsig:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/> <dsig:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> </dsig:Transforms> <dsig:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/> <dsig:DigestValue>(保険情報詳細データのダイジェスト)</dsig:DigestValue> </dsig:Reference> </dsig:SignedInfo> <dsig:SignatureValue>(署名データ)</dsig:SignatureValue> <dsig:KeyInfo> <dsig:X509Data> <dsig:X509Certificate>(署名者の証明書)</dsig:X509Certificate> </dsig:X509Data> </dsig:KeyInfo> </dsig:Signature>
内容はこうです。まず保険詳細情報の部分がXML文書としてバイナリデータになり、ダイジェストが計算されます。そしてこのダイジェストが署名者の秘密キーで署名され、署名データとなります。この署名データを署名者の証明書に含まれる公開キーで検証することで、証明書の発行者(=署名者)が署名した内容に改ざんがないことが確認できるのです。
証明書はbase64形式ですが、復号すると署名者は「Registrar of Tokyo Legal Affairs Bureau, Japanese Government」となっていました。この署名者として署名できるのは、署名者に対応する秘密キーを手にした者だけに、厳密に制限されます。 つまりこれは、東京法務局が署名した公文書で、法律上の扱いはともかく、その性質は住民票や車検証といった身近な公文書と完全に同じです。(たったこれだけのテキスト断片にそんな価値があるなんて、なんだかワクワクしてきませんか。)
この保険業社発行のデータは、もちろんその有効性を確認してから発行されていると思います。疑うわけではありません。しかし、エンジニアとしてその真正性を確認したいと思った筆者は、署名の検証を手元でやってみようと思い立ちました。
ただ、これが結構、険しい道だったのです。結論はこうです。署名検証は可能で、署名は正しかったです。しかしこれを確認する手順は、ライブラリで一発では決してなかったのです。Java言語とPython言語で、2つの保険料控除XMLの署名を確認しましたが、確認のためにはそれなりの知識とプログラム調整を要しました。 ひょっとしたら同じ問題に遭遇している方がいらっしゃるかもしれないと思い、ここにその顛末を紹介させていただきます。
Java言語の場合
Javaの場合は、JDKにXML署名検証のための機能が実装されており、その使い方はOracleの以下のサイトに書かれています。
ただし、結構な分量があり、その内容もかなり専門的です。幸い以下のGithub公開レポジトリに、この文書で解説されているサンプルコードが取りまとめられており、筆者もこれを動かしてみることから始めました。
https://github.com/jknecht/xml-signature-validation
しかし、、そのまま動かすと以下の例外になってしまいます。
javax.xml.crypto.MarshalException: It is forbidden to use algorithm http://www.w3.org/2000/09/xmldsig#rsa-sha1 when secure validation is enabled
そういえば署名情報を見た時から、悪い予感がしていたのです。SHA1アルゴリズムは、強行突破する計算量が現在のコンピュータの速度と比較して不十分と業界で認識され、非奨励となっていたのです。しかし今回はダイジェストがSHA1で作られているため、このアルゴリズムを動かさざるを得ません。
その手順は巷に幅広く解説されていますが、以下のような手順です。
まず既定のjava.securityファイルを回収します。筆者の場合は以下のディレクトリにありました。
/usr/lib/jvm/java-17-amazon-corretto/conf/security/java.security
次に、この内容を以下のように変更して、SHA1の禁止措置を解除します。
# 以下が禁止措置を解除した元々含まれていた項目 # disallowAlg http://www.w3.org/2000/09/xmldsig#sha1,\ # disallowAlg http://www.w3.org/2000/09/xmldsig#rsa-sha1,\ jdk.xml.dsig.secureValidationPolicy=\ disallowAlg http://www.w3.org/TR/1999/REC-xslt-19991116,\ disallowAlg http://www.w3.org/2001/04/xmldsig-more#rsa-md5,\ disallowAlg http://www.w3.org/2001/04/xmldsig-more#hmac-md5,\ disallowAlg http://www.w3.org/2001/04/xmldsig-more#md5,\ disallowAlg http://www.w3.org/2000/09/xmldsig#dsa-sha1,\ disallowAlg http://www.w3.org/2007/05/xmldsig-more#sha1-rsa-MGF1,\ disallowAlg http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha1,\ maxTransforms 5,\ maxReferences 30,\ disallowReferenceUriSchemes file http https,\ minKeySize RSA 1024,\ minKeySize DSA 1024,\ minKeySize EC 224,\ noDuplicateIds,\ noRetrievalMethodLoops
これをプログラム開始時に以下のコマンドライン引数で指定すれば、SHA1が使えるようになります。
-Djava.security.properties=${workspaceFolder}/java.security
さあこれで動くようになったでしょうか?残念ながらそうではありません。続いて以下の例外となりました。
Caused by: com.sun.org.apache.xml.internal.security.utils.resolver.ResourceResolverException: Cannot resolve element with ID ...
もういきなりわかりません。IDって。。??デバッガでJDKのコードフローを辿っていくと、URIリファレンスの解決に失敗した、と言うふうに読めます。そこで元のXMLを眺めていると、該当しそうな場所が見つかりました。冒頭に紹介した署名情報の以下の部分です。
<dsig:Reference URI="#(保険情報詳細データへのポインタ)">
このURIをIDとして設定しているのは、ルート要素の以下の部分です。
<TEG810 xmlns="http://xml.e-tax.nta.go.jp/XSD/kyotsu" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu" VR="1.0" id="TEG810...">
ははぁ、これは保険情報のXMLノードクエリ失敗だなと思って調べてみると、XML DocumentにはID解決する属性を指定する必要があるという巷情報に行きつきました。
https://stackoverflow.com/questions/3423430/java-xml-dom-how-are-id-attributes-special
Document doc = dbf.newDocumentBuilder().parse( new FileInputStream(fileName)); Element x = doc.getDocumentElement(); // 追加部分 doc.getDocumentElement().setIdAttributeNS( null, "id", true);
すると、、、以下のように表示され、署名検証が成功しました!
Signature passed core validation
試しに保険詳細情報(金額など)や署名の内容を1文字変更して検証してみましたが、正しく検証エラーとなります。A社とB社両方のXMLで署名を確認できました。
Python言語の場合
Pythonの場合は。。と思って巷ライブラリを確認した筆者は、大きなショックを受けました。ほとんど以下の一択なのです。
https://xml-security.github.io/signxml/
しかも、サンプルコードがありません。公式ドキュメントに書かれているサンプルはわずかにこれだけです。
https://xml-security.github.io/signxml/#verifying-saml-assertions
XMLVerifier.verifyの説明を読み込むなどして、以下のコードを書き起こしました。(最終コードは本項目の末尾をご覧ください。)
from lxml import etree from signxml.verifier import XMLVerifier, SignatureConfiguration from signxml.algorithms import DigestAlgorithm, SignatureMethod xmlData = None with open("a.xml", "rb") as fh: xmlData = fh.read() x = etree.XML(xmlData, None) el = x.find(".//ns:X509Certificate", namespaces={"ns": "http://www.w3.org/2000/09/xmldsig#"}) cert = el.text verifier = XMLVerifier() assertion_data = verifier.verify( xmlData, x509_cert=cert ).signed_xml s = etree.tostring(assertion_data) print(s)
証明書データをあえて事前に回収しているのは、verifyメソッドの説明に以下のように書かれているからです。
If left set to None, requires that the signature supply a valid X.509 certificate chain that validates against the known certificate authorities.
東京法務局が今回の証明書に対応するルート証明書を発行しているのか、本稿執筆時点では確認できませんでしたので、システムCAにそれを収録する代わりにCAチェックを回避する方法を選びました。
実行すると、Javaの時にも遭遇したあのエラーです。
signxml.exceptions.InvalidInput: Signature method RSA_SHA1 forbidden by configuration
以下のようにして有効化します。
verifier.verify( xmlData, x509_cert=cert, expect_config=SignatureConfiguration( signature_methods=frozenset({ SignatureMethod.RSA_SHA1 }), digest_algorithms=frozenset({ DigestAlgorithm.SHA1 }) ) )
ここまで修正して、B社のXMLは検証成功しました!例によって内容を変更して確認しましたが、正しく検証できているようです。
しかし、、A社のXMLでは以下の例外になっていまいます。
signxml.exceptions.InvalidSignature: Signature verification failed: bad signature
こんなことって。。 (あるんですよね、こういうことが。これが言わば、ソフト開発技術者業務の難所だと筆者は思います。特に今回のように暗号アルゴリズムを使う場合は、数多ある設定が完全に想定通りでないと、このような冷たいエラーから抜け出すことができないのです。)
しかし、B社のXMLでは署名検証成功しているので、この差分を詰めていけば、絶対に正解に辿り着くはずです。まずXMLの中身の確認から入りました。署名情報部分に以下のような違いがありました。
A社(失敗)
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#" ...>
B社(成功)
<dsig:Signature xmlns:dsig="http://www.w3.org/2000/09/xmldsig#" ...>
XMLに詳しい方なら分かると思いますが、この2つのXMLは、この部分だけを見れば、同値です。要素の名前が違うじゃないかと思われるかもしれませんが、B社XMLの「dsig」の部分は名前空間のプレフィクスであり、本当の名前空間識別子は「xmlns:dsig」属性で指定されている「http://www.w3.org/2000/09/xmldsig#」の方なのです。
念のためA社XMLの名前空間をB社形式に合わせてみましたが、エラーは解消しませんでした。
頭を総動員して考えられる可能性を探っていきます。
そもそもXMLの署名検証とは、以下のような過程で行われるものです。(他の方式もありますが、今回の場合です。また、今回はA社XMLとB社XMLで方式は共通です。)
- SignedInfoの内容 から作った署名情報を、証明書の公開キーで復号したSignatureValueの署名データで検証する。
- Reference ID指定された要素からSignature要素を除いた部分の内容の ダイジェストを計算する。
- ダイジェストをDigestValueと比較する。
SignedInfoの中にはダイジェストが含まれていますので、こうすることでXML全体の内容保証ができるのです。問題は、SignatureInfoや、Reference ID指定された要素はXMLテキストであり、先ほど紹介したように、その内容に表現の多様性があることです。一方でデジタル署名は内容が完全に一致するバイナリデータに対する署名ですので、XMLテキストはそのままでは署名データとしては使えないのです。そこでcanonicalizeというプロセスが登場します。CanonicalizationMethod要素を見ると、その手順の識別子が定義されています。
しかし、canonicalize手順の識別子はA社とB社で全く同じです。何が問題なのでしょうか。
次に筆者が思いついたのは、署名検証データとして持ち込まれるSignatureInfoの 完全な文字列情報の 確認です。これはPythonのデバッグ情報を出力させることで行えます。
出力した文字情報は、以下のようになっていました。(括弧内は固有情報なのでマスクしています。)
<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI="#(ID情報)"><Transforms xmlns=""><Transform xmlns="" Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform><Transform xmlns="" Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></Transform></Transforms><DigestMethod xmlns="" Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue xmlns="">(保険情報詳細データのダイジェスト)</DigestValue></Reference></SignedInfo>
A社のXMLはJavaでの署名情報検証には成功しています。その時のSignedInfo文字列はどうなっていたのでしょうか。
その出力手順は複雑で、まず以下のようなlogging.propertiesファイルを用意します。
handlers= java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = FINER java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter org.jcp.xml.dsig.internal.level = FINER com.sun.org.apache.xml.internal.security.level = FINER
次にこれをjava起動パラメターで指定します。
-Djava.util.logging.config.file=${workspaceFolder}/logging.properties
出力されたのは、以下のような文字列でした。
<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#" xmlns:gen="http://xml.e-tax.nta.go.jp/XSD/general" xmlns:kyo="http://xml.e-tax.nta.go.jp/XSD/kyotsu"><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod><Reference URI="#(ID情報)"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></Transform></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod><DigestValue>(保険情報詳細データのダイジェスト)</DigestValue></Reference></SignedInfo>
Pythonの方にだけ、「xmlns=""」という、空のXML名前空間指定が付いています。これは、ライブラリの処理としては誤りで、バグと考えても良いかもしれません。
幸い、この空のXML名前空間指定を文字列編集で除去するオプションがライブラリにありました。これはどこにも解説されていない内容で、筆者がライブラリのソースコードを読んで掘り出したものです。
verifier = XMLVerifier() verifier.excise_empty_xmlns_declarations = True
最終的なPythonプログラムは以下のようになりました。
from lxml import etree from base64 import b64decode from signxml.verifier import XMLVerifier, SignatureConfiguration from signxml.algorithms import DigestAlgorithm, SignatureMethod import logging logging.basicConfig(encoding='utf-8', level=logging.DEBUG) xmlData = None with open("a.xml", "rb") as fh: xmlData = fh.read() x = etree.XML(xmlData, None) el = x.find(".//ns:X509Certificate", namespaces={"ns": "http://www.w3.org/2000/09/xmldsig#"}) cert = el.text verifier = XMLVerifier() verifier.excise_empty_xmlns_declarations = True assertion_data = verifier.verify( xmlData, x509_cert=cert, expect_config=SignatureConfiguration( signature_methods=frozenset({ SignatureMethod.RSA_SHA1 }), digest_algorithms=frozenset({ DigestAlgorithm.SHA1 }) ) ).signed_xml s = etree.tostring(assertion_data) print(s)
このプログラムで検証すると、A社のXMLも検証OKとなり、内容改ざんの検出も期待通りでした!
振り返りと、昔話と、雑談
様々な問題がありましたが、なんとか目的を達成しました。最後までお読みいただきありがとうございます。
いろいろ調べながら感じたのは、XML署名が業界的にマイナー技術であり、とにかく文献が少なくて古いことです。時代の流れは着実にJSON/JWTであり、XMLは旧技術なのです。しかしXMLで決まっていることは仕方がないし、一度決まった業務仕様は長い間生きながらえます。知識の力で何とかつなぐのがエンジニアの仕事です。
今から20年ほど前、XMLが大きな注目を集めたことがありました。Microsoftが.NET Framework 1.0を発表し、その目玉機能としてアピールしたからです。商売の話はさておき、筆者も、あのとき、紙書類の階層構造と、バイナリメッセージフォーマットの中間にある、コンピュータでクエリできる構造化された共通テキストフォーマットであるXMLに大きな可能性を感じていました。
そんなXMLはどうして主流になれなかったのか?筆者の知る限り、現在XMLが使われているのは、Mavenなどの設定ファイルと、SOAPプロトコルと(Salesforce SOAP APIは現役です!)、SAML査証だけです。XSLなどのスキーマ定義やXSLTなどのHTMLへのデータ変換に至っては、ほとんど見かけなくなりました。その理由はおそらく、仕様を膨らませ過ぎたことにあるのではないかと筆者は思っています。そのため難解になり敷居が上がり、開発工数と戦うエンジニアの方々からは遠ざかってしまったのです。XMLが果たすはずだった役割は、今ではほぼJSONが代わりに果たしているように筆者には思えます。
このような経緯はあれど、ようやく当時の技術が実用化されて手元にあるのだと思うと、年末調整保険料控除XMLシステム開発に携わったエンジニアの方に感謝したい気持ちになりました。