フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

年末調整保険料控除XML署名確認の顛末

みなさんこんにちは。エンジニアの佐藤です。今回は年末調整のお話です。

年末調整の時節、会社からは「保険料控除では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の場合は、JDKXML署名検証のための機能が実装されており、その使い方はOracleの以下のサイトに書かれています。

XML Digital Signature API

ただし、結構な分量があり、その内容もかなり専門的です。幸い以下の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

Githubソースコードに以下のように追記します。

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で方式は共通です。)

  1. SignedInfoの内容 から作った署名情報を、証明書の公開キーで復号したSignatureValueの署名データで検証する。
  2. Reference ID指定された要素からSignature要素を除いた部分の内容の ダイジェストを計算する。
  3. ダイジェストを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社のXMLJavaでの署名情報検証には成功しています。その時の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システム開発に携わったエンジニアの方に感謝したい気持ちになりました。

Spring Boot Web JPA は GraalVM でメモリ節約

こんにちは。エンジニアの佐藤です。今回はGraalVMのお話をさせていただきます。

きっかけは相談から

先日他プロジェクトの方から「Webサーバーのリクエストが不思議なタイムアウトをしているが、どうしてだろうか?」と相談を受けました。話を聞いていると、メモリ(主記憶)利用超過が疑わしいと思いました。メモリは古来からある問題で、瞬間的にでも超過すると、サーバーはOS(通常はLinux)によって実行停止措置となってしまいます。今回の場合、そのリミットは1GB。仮想メモリはありませんので、この制限を踏み越えればアウトです。

Spring Boot Data JPAの凄まじいメモリ使用量

インタビューによると、利用しているフレームワークはSpring Boot。長い歴史を持つJava言語のポピュラーなフレームワークです。しかし筆者はこのとき「どうしてNodeJSとかにしなかったのだろうか」と思っていましました。なぜならばこのSpring Boot、「Data JPA」を使うと、起動がものすごく重くなるからです。数年前に何とか速くならないものかと頑張ったこともあったのですが、どうしようもない降参だと思っていました。メモリ利用量も多く、ごく単純なアプリケーションでも300MB以上のメモリを消費するとされています。今回のリミットの1GB(=1000MB)と比較すると、何とも心配な消費量です。

そう言えばGraalVMがあった

そう言えば。。Java界隈での最近の話題と言えば、GraalVMです。これ自体は以前からあったのですが(2011~)、今年初夏にOracleが無償利用条件を緩和し、再び注目を集めています。Spring FrameworkでもSpring NativeとしてSpring Boot v3からサポートされています。GraalVMのホームページによると、起動時間の短縮とメモリ利用量 節約に効果があるとされています。今回のお悩みにミートしそうです。

しかし、(古い話ですが)Java言語は何といっても動的コンパイルの元祖です。四半世紀も続いたパラダイムを事前コンパイルにシフトするなど、可能なものでしょうか?そこはもちろんJavaの本家Oracleが頑張っているので大丈夫と信じたいですが、Javaの歴史は古いので、古いライブラリがサポートされていない可能性はどこまでもあります。一方、Javaと多くのアイディアを共有するMicrosoft .NETはネイティブイメージコンパイラNGenを10年以上前からサポートし、Microsoft製品のインストール経過で利用されているのを目にしていました。

この機会に、試してみることにしましょう。

ミニマムプログラムを観察する

最初に極小プログラムで試してみましょう。GraalVMサイトの以下のような内容です。なお、今回のテストはOracle Cloud A1インスタンス上のUbuntu 22.04 (arm64)で行いました。

public class HelloWorld {
  public static void main(String[] args) {
    System.out.println("Hello, World!");
  }
}

ネイティブイメージをビルドしてみると。。Javaクラスファイルに比べて非常に大きいのがわかります。(1000倍以上!)さらに、ビルドは非常に重い。4コアインスタンスでメモリを1GB近く消費して1分ほどかかりました。(たった4行のソースコードにです!)

ファイル サイズ
HelloWorld.class 427 bytes
helloworld(ネイティブ) 6781368 bytes (6MB!)

実行時のメモリ消費量はどうでしょうか?このページに書かれているtimeコマンドで最大利用メモリ(Maximum resident set size)を調べてみると、以下のようになりました。

ファイル Maximum resident set size (kbytes)
HelloWorld.class 36044 (36MB)
helloworld(ネイティブ) 1220 (1MB!)

利用メモリの劇的節約です!これは期待できそうです。

ミニマムSpring Boot Web + Data JPAで試す

次に本丸のSpring Boot Web + DBアプリケーションで試してみましょう。Spring BootのGithubから以下のサンプルを選びました。

https://github.com/spring-guides/gs-accessing-data-mysql/tree/main/complete

以下のコマンドでネイティブイメージをビルドします。

mvn -Pnative native:compile

コンパイルは多量のリソースを必要とします。4コアインスタンスで10分ほどで、利用メモリは8GBに達しました。

すぐには成功しませんでしたが、何点か調整すると、成功しました。

遭遇したエラーと対処方法は以下のようなものです。

エラー 対処方法
MySQLドライバがロードできない pom.xml依存関係にmysql-connector-jを追加。
実行時にクラス初期化エラー pom.xmlのparentをspring-boot-starter-parent v3.1.4に最新化。Reachability Metadataサポート追加

出来上がったネイティブイメージのファイルサイズは非常に大きいです。

ファイル サイズ
accessing-data-mysql-complete-0.0.1-SNAPSHOT.jar 44MB
accessing-data-mysql-complete(ネイティブ) 157MB

メモリ消費はどうでしょうか?サーバーが起動してから、最も単純なDB参照を伴うREST APIに1回レスポンスを返すまでの動作について、同様にtimeコマンドでメモリ消費量を比較してみると。。減りました!45%節約です!

ファイル Maximum resident set size (kbytes)
Jarで実行 320568 (321MB)
ネイティブで実行 175344 (175MB)

今後に期待

今回の実験はここまでですが、これは期待できると思います。ただし、今回調査していて感じたことですが、開発コードを正しく動かす道のりは簡単ではない場合がありそうです。JVMから事前コンパイルへの方式変更の実行環境へのインパクトは大きく、ソリューションの品質保証のためには非互換の開発モジュールをどうやって動かすか?などの難局を乗り切る知識がきっと必要になるでしょう。

今回も最後までお読みいただきありがとうございました。

Flect 社内における OR 技術の横展開に向けた取り組みの紹介

こんにちは.研究開発室の福井です.研究開発室においてオペレーションズ・リサーチ(OR)のビジネス活用について研究を行っております.本稿では,Flect 社内における OR の技術の横展開に向けた取り組みを紹介します.

続きを読む

ffmpeg.wasmをGitHub Pagesで動かすよ

こんにちは。研究開発室の岡田です。

Heroku 無料枠終了のお知らせの衝撃からだいぶ月日がたち、終了まで残り1か月強となりました。皆様におかれましては、有料プランへの移行準備あるいは、別サービスへの移行作業は進められておられますでしょうか。どうすべきか迷っておられる方は、識者の方々がいろいろと比較検討してくださっていますので、ご参考にされるといいと思います。(参考, 参考, 参考, 参考)

私も例にもれず、実験的なアプリはHerokuにデプロイするのが常であり、いろいろとお世話になっていました。慣れもあるかもしれませんが、Herokuの開発UXは非常に優れていると感じており、サーバ側で処理が必要となるようなアプリについては、引き続きHerokuを使ってもいいかなと考えています。一方で、サーバ側の処理がない、いわゆる静的なアプリについては、ソースコードと一緒にリポジトリで管理できるので、GitHub Pagesがお手軽でよさそうだなと考えています1

さて、私がHerokuにデプロイしていたアプリにはffmpeg.wasmを利用したものが幾つかあるのですが、実はこれをGitHub Pagesにデプロイする際には問題が発生します。ffmpeg.wasmを使用したことがある方にはおなじみの話だと思いますが、例のCOOP, COEP制限に引っかかってしまいブラウザで処理が行えなくなってしまうのです。今回は、これについて、簡単な説明と回避方法についてご紹介したいと思います。


  1. 最初からHerokuではなく、GitHub Pagesで動かしておけよ、という突込みはごもっともですが、開発環境を流用していた関係で、「まぁHerokuでいいか」ということになってました。そういう方、結構おられますよね?

続きを読む

Amazon Chime SDKでセンターフレームを作る

こんにちは。研究開発室の岡田です。

Amazon Chime SDK for Javascriptのバージョンが3.x系になって久しいですが、私がOSSで公開しているデモも漸くv3.xに対応させることができました。v2.x系からv3.x系への移行の手順は公式のドキュメントに記載されていますので、これから移行を考えておられる方はそちらを参考にされるとよいと思います。

今回はSDKの移行に伴い、デモに簡易版センターフレーム(Center Stage)を追加したので、その実装方法についてご紹介したいと思います。

つくったものはこんな感じのものです。 ezgif-3-c31fdaacf1

続きを読む

Vue.jsなどSPA実装されたエラーページをSpring Webの「404 Not Found」エラーページとして表示するには

みなさんこんにちは。エンジニアの佐藤です。今回はSpring MVC(Webサーバー機能)のエラー処理に関するお話です。

先日筆者は「Spring Framworkの『404 Not Found』エラーページをVue.JSで表示したい」という相談を受けました。

Vue.JSは主にSPA(Single Page Application)を実現するためのフレームワークであり、今回のプロジェクトでもその目的で使われていました。SPAでは、WebページのユーザーインターフェイスはすべてJavascriptアーカイブで実装され、WebサーバーはSPAモジュールから発信されるAPIリクエストを処理する実装になります。今回もそのように実装されており、APIサーバーはSpring MVCで開発されており、SPAのJavascriptアーカイブもここに静的リソースとして配置されていました。

SPA実装では通常、HTMLページのリクエストは最初のJavascriptアーカイブを配置する目的でしか使われません。しかし例外があり、それはディープリンクです。

このプロジェクトの場合、メールに記されたリンクを利用者が踏むというシナリオがあり、その場合はWebサーバーがそのHTTPリクエストを受けます。APIしか実装せず、初回リクエストしか準備のないWebサーバーに、このディープリンクを処理させることができるのでしょうか?それは可能で、以下のような実装になります。

  1. ディープリンクに対応するハンドラを記述し、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  2. ブラウザはJavascriptアーカイブ配置ページを読み取り、SPAを開始する。
  3. 開始したSPAは、ロケーション(アドレスバー文字列に対応)から自分がディープリンクを開いていることを認識し、対応するルーティング・表示処理を行う。

1の部分の実装例は、例えば以下のようなものです。

@Controller
@RequestMapping("/")
public class DeepLinkController {

    @GetMapping("a_deep_link")
    public ResponseEntity<InputStreamResource> a_deep_link() {
        return returnResource("static/index.html", "text/html");
    } // end of verification

    private ResponseEntity<InputStreamResource> returnResource(String resourcePath, String contentType) {
        InputStreamResource isr = new InputStreamResource(getClass().getClassLoader().getResourceAsStream(resourcePath));
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", contentType);
        return new ResponseEntity<>(isr, headers, HttpStatus.OK);
    } // end of returnIndex
}

しかし、今回相談されたのは、こういうディープリンクが、「サポート外」のパスだった場合です。否定、invertなのです。バリエーションは無限です。一体どうやれば実装できるのでしょうか?

結論を先に

筆者が試行錯誤の末にたどり着いた実装方法は、以下のようなものです。

  • server.error.whitelabel.enabled=false
  • server.error.path=/error2
  • /error2のリクエストハンドラを作成し、/errorへのリダイレクトを返す処理を書く。
  • /errorのリクエストハンドラで、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  • Vue.JSのルーター(ブラウザのロケーション文字列に応じて異なるページ描画を設定する機能)で、/errorを設定する。

実装方法の解説

  • server.error.whitelabel.enabled=false

Spring MVCには「ホワイトラベルエラーページ」というものが用意されており、既定では、HTTPリクエスト処理で例外が発生した場合は、その例外の内容を表示する開発時用のページが表示されます。今回はエラーページはSPAを実装するVue.JSで作ることになっているので、この機能は無効にします。

  • server.error.path=/error2

ここが、今回の実装の核心部分です。Spring MVCでは、HTTPリクエストハンドルの過程で発生したエラーを、このリクエストパスに対応するコントローラに通知する仕様になっているようです。ようなのです、と書いたのは、筆者は結局Spring MVCのどこでこの処理が書かれているのか、見つけることができなかったからです。ここではそういうものと考えたいと思います。

  • /error2のリクエストハンドラを作成し、/errorへのリダイレクトを返す処理を書く。

/error2のコントローラには、/errorへのリダイレクト処理を書きます。以下のような感じです。

@RequestMapping("error2")
public ResponseEntity<String> error(HttpServletRequest req, HttpServletResponse res) {
    final int status = res.getStatus();
    final String contextPath = req.getContextPath();
    headers.add("Location", StringUtils.join("/error?code=", status));
    return new ResponseEntity<>(headers, HttpStatus.FOUND);
}

/error2というのはもちろん、例えばの話で、その後のリダイレクト処理で利用する/errorと重ならなければ何でも構いません。Spring MVCのエラー処理の仕掛けを利用しつつ、リダイレクトによりディープリンク処理に持ち込むところがポイントです。

ここまで来たら、あとは冒頭に紹介したディープリンクと同じです。

  • /errorのリクエストハンドラで、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  • Vue.JSのルーター(ブラウザのロケーション文字列に応じて異なるページ描画を設定する機能)で、/errorを設定する。

失敗した作戦と理由

(以下は失敗談や筆者の所感なので、読み飛ばしていただいても構いません。)

このブログを書こうと思ったのは、表題のような実装について解説したブログを、どこにも発見できなかったからです。この実装にたどり着くまでには様々な試行錯誤がありました。以下は失敗談ですが、何かの参考になるかもしれないと思い、共有させていただきます。

フィルタ

Spring Securityでは、ログイン処理にフィルタを設定することができます。

Spring Security Architecture https://spring.io/guides/topicals/spring-security-architecture/#web-security

最初はこの機能を使い、ワイルドカード指定したURLでリダイレクトを設定すればいいだろうと思っていたのですが、挫折しました。

ワイルドカードだけでは、サポートするURI以外を全部という指定はできないことに気がついたからです。。

コントロールアドバイス(例外ハンドラ)

Spring MVCコントローラの種類によらず、リクエストハンドル処理で起こった特定の例外を集中的にハンドルする仕掛けがあります。この仕掛けでは@ControllerAdviceと@ExceptionHandlerアノテーションを設定し、例外ハンドラを設定します。

Global Exception Handling https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc#global-exception-handling

「該当URLが無かったときも、これで集中的に拾えるのでは?」と思いましたが、そうは行きませんでした。

まず、spring.mvc.throw-exception-if-no-handler-foundをtrueに変更しなければなりません。

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties.web.spring.mvc.throw-exception-if-no-handler-found

それはかまわないのですが、このフラグをtrueに設定すると、NoHandlerFoundExceptionに様々な例外が通知され、「サポートサれていないURIのすべて」とは異なることがわかりました。

また、静的リソースハンドラのソースコードを見てみると、例外を投げずに直接 404 NotFoundをHttpServletResponseに書き込んでいる実装もあり、サポートサれていないURIの中には例外スローを経由しない場合もあることに気付かされました。

インターセプタ

Spring MVCにはインターセプタと呼ばれる、フィルタと似た仕掛けがあります。

1.11.5. Interceptors https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-config-interceptors

様々なリクエストハンドルステージで仕掛けられ、postHandleやafterCompletionなどもありますので、いろいろ処理して最終的に404 NotFoundとなった処理結果について、リダイレクトに変更すればよいのではないか?というアイディアもありました。

しかし、postHandleやafterCompletionでは、リクエスト処理をここで完結させるという機能がありません。ハンドルするのは良いのですが、既に処理フローの決まっているレスポンスを変更して、その後の処理がおかしくなるのは困ると思いました。

多機能だが難解なSpring Framework

Spring Frameworkを使ったWebサーバーの実装は、定番の一つとして普及しているように思えます。アノテーションを付けたクラスを配置してSpringApplicationを開始すると、驚くほど高機能なアプリケーションを少ない記述量で実現できます。6年前に初めて手がけてから今回まで何度か使っていますが、うまい仕掛けだと思います。

しかし一方で数年来改善されていない問題もあると思います。文献の不足です。

不正確かもしれませんが、ReactやFlaskは、結構Google検索だけで何とかなってしまうことが多いのですが、Spring Frameworkはそうは行きません。今回もそうでしたが、結局ライブラリの裏側、ソースコードの読解まで踏み込む必要が生じることが多いのです。Spring Framworkには長い歴史があり、過去何度か大幅な改定をしているため、古い時代の巷情報が検索されてくると紛らわしいという問題もあります。

ソースコードを含めたドキュメントの探し方をノウハウとして持っていないとなかなか使いこなせないフレームワークだと思います。

最後までお読みいただきありがとうございました。

Remote Audio forwarding with PulseAudio with complete setup

This is Shuochen from R&D research division from Flect. In this blog I would like to demonstrate how to setup PulseAudio so you can forward audio from local PC to the remote instance.

Introduction

What is audio forwarding? Why do we need it?

First of all, I would like to explain what is audio forwarding. Audio forwarding is the act of redirecting the sound stream from one device to another device​. The way to achieve audio forwarding depends on the Operation System (OS), in this blog we focus on Linux based systems and Mac OS because PulseAudio is designed for the Linux system.

Now to answer the question on why do we need audio forwarding. Imagine you live in a two floor house and you have two laptops. One laptop is upstairs and one laptop is downstairs. Suppose you want both laptops to play the same music. How would you achieve that? This is done by audio forwarding, we are redirecting the audio output from one laptop to another laptop so both laptop can play the same music. The same idea can be extended to a remote device anywhere in the world that is connected to the internet.

Now suppose we have a remote cloud instance. The remote instance could be a Google instance, an AWS instance or an Oracle instance. However, they share the common characteristics of not having a sound card or microphone. Even if they do have a sound card, you cannot hear them due to physical distance is too far away! If you are developing an audio application, how can you hear what is playing on the remote instance? Again we can achieve this with audio forwarding. We stream the audio output from the remote instance to the local computer so we can hear it. It is also possible to do the reverse: we can stream our microphone input from the local computer to the remote instance.

There are multiple softwares which allow audio forwarding, with PulseAudio being one of the possible choice. The reason that I chose PulseAudio is that it is installed by default on Linux computers.

Types of audio forwarding

Before moving to explain on how to achieve audio forwarding, it is necessary to understand that there are 2 types of audio forwarding. Audio forwarding within the same network and audio forwarding for different networks.

Audio forwarding within the same network is the first example which we share the audio between laptops in the same house. The devices could be further away (for example same building), but the idea is the same. They are connected to the same network. When in the same network, the device can access to each other directly using the private IP address. Therefore, audio forwarding within the same network is easy. There are multiple ways to achieve audio forwarding: direction connection, tunneling, mDNS services and RTP protocol. In this blog I will focus on setting up audio forwarding in different networks.

Usually, it is common for devices to be in different networks. It is common for our devices at home to join a router and then connect to the internet. This means the devices are typically behind NAT (Network address translation)​. In other words, it is not directly accessible from the internet. This is desired for security reasons but it creates a problem for audio forwarding. If it is not directly accessible then we cannot forward any sound stream.

Therefore the first step for audio forwarding is to establish a connection between the remote instance and the local PC. There are 2 ways this can be achieved and I will explain each of the method in the next section.

Setting up connection

Port forwarding

The first method, which is straightforward in the concept but more complicated in the setup is to use port forwarding. You may already have done port forwarding before for certain games (for example Minecraft).

It is not a good idea to allow any traffic to access your local computer without any restrictions. What we will do instead, is to allow the internet to access the local computer only on one port. Because PulseAudio uses the port 4713 by default, this will be the port that will be port forwarded. Basically this means, any request for the port 4713 to your public ip address will be forwarded to your local computer.

Unfortunately, the steps of port forwarding depends on the router so there is no universal guide. The website https://portforward.com/, has a good comprehensive summary for how to do port forwarding.

After you have completed the port forwarding, please do check for the firewall and allow for access to the port 4713. The firewall setting includes both the firewall list of the router and the firewall options of the OS.

To test for connection, type telnet ip_address 4713 in the remote cloud instance.

SSH

SSH is the secure alternative for establish connection between the remote server and the client. In particular we will use SSH -R option to establish a reverse tunnel from the remote server back to the client. Normal SSH establishes the connection from the client to the server. However, we also require the reverse process for PulseAudio to work. That is, establish the connection from the server to the client. This is not difficult because usually remote instance requires SSH to connect to it anyway. We are just including additional parameters to also create a reverse tunnel.

In order to set up a reverse tunnel, run the command ssh –R port1:127.0.0.1:4713 username@ip_address when connecting from the client to the server. Port1 can be any port number of your choice, as long it is not used. 127.0.0.1 means local host, because we are forwarding the request from the localhost of the server back to the client. Finally, 4713 is the default port number used by PulseAudio. Unless you have a good reason to change it, use this port number.

Again to test the connection, type telnet ip_address 4713 in the remote cloud instance.

Installing required modules​

Installing required modules should be rather simple. The required modules are SSH and PulseAudio itself. Both SSH and PulseAudio should be installed by default. If for some reason PulseAudio is not installed on the distribution, install it with sudo apt install pulseaudio

For Mac OS, install it with brew install pulseaudio

Setting up the server

Just to be clear, when I use the term server here it means the PulseAudio server, not the SSH server which you connect to. PulseAudio server needs to be the device with the actual sound device on it, otherwise we will not hear any sound output.

Edit the configuration file

After installing PulseAudio, we need to edit the configuration file. We edit the configuration file with sudo nano ~/.config/pulse/default.pa. In this file (default.pa), enable the line load-module module-native-protocol-tcp auth-anonymous=1​. In other words, delete the * sign in front for the line to take effect. auth-anonymous=1​ means we do not restrict on who can connect to the server. You can set the IP of the client to only allow the remote instance to connect it.

Restart the service

After changing the configuration, it is necessary to restart the PulseAudio service. For Mac OS, type brew services restart pulseaudio in the terminal.

For Linux distrubutions, first type pulseaudio -k, then type pulseauido --start

Setting up the client

Now we need to repeat the same steps to set up the PulseAudio client. In this case, PulseAudio client is the the device on the remote client, which will connect to the PulseAudio server. It is possible for multiple clients to connect to the same server, should it be required.

Edit the configuration file

Again, we need to edit the configuration file with sudo nano ~/.config/pulse/default.pa. In this file (default.pa), enable the line load-module module-native-protocol-tcp​. In other words, delete the * sign in front for the line to take effect. auth-anonymous=1​ is no longer required because the client side does not perform authentication.

In addition, 2 additional modules should be enabled. Module-tunnel-source and module-tunnel-sink. Module-tunnel-sink-new provides audio forwarding for the sink, which is the speaker output from the remote cloud instance (client), back to the server (local computer). Module-tunnel-source-new provides the audio forwarding of the microphone input from the server to the client.

To enable module-tunnel-sink-new module, enable the line load-module module-tunnel-sink-new server=127.0.0.1:6666 sink_name=Sound​. This is assuming you have provided a reverse tunnel in SSH with the port number 6666. In the case of port forwarding, the server argument would your global IP of your local computer and the port number should be 4713.

For the load-module module-tunnel-source-new module, enable load-module module-tunnel-source-new server=127.0.0.1:6666 source_name=Mic.

Both the argument sink_name and source_name are optional. Restart the service as before.

Test the connection

That is all the steps required to set up the PulseAudio. Now any audio you play on the remote client will be forwarded back to the local computer and the any microphone input from the local computer will be forwarded to the remote client. This is very convenient for developing audio applications on the remote client.

Optional adjustment

If the PulseAudio server has multiple devices. It is possible to specify which device to use for audio forwarding. We can select the device by its index (the information is shown by pacmd list-sources or pacmd list-sinks) in this way:​

pacmd set-default-sink 1, pacmd set-default-source 1

Restart the PulseAudio on both the server and client to take effect​.

Reference links

Complete guide on port forwarding
https://portforward.com/
PulseAudio wiki
https://wiki.archlinux.org/title/PulseAudio#Networked_audio
PulseAudio official page
https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/Modules/#module-cli-protocol-unixtcp