Lambdaからクリアテキスト署名メールを送ろうとして2ヶ月かかった話


SMIMEでメールを暗号化して送信していましたが、SMIMEに対応していないメーラーだと本文が読めないので クリアテキスト署名 という形式で送ることになりました。
やり方が全然わからずプレッシャーに押しつぶされそうになりながらもなんとか解決できた経緯を書いていきます。

環境

  • Lambda(Node.js 12.x)
  • SES

クリアテキスト署名とは

PKI関連技術に関するコンテンツ

SMIMEでメールを暗号化して送信すると、SMIMEに対応していないメーラーだとメールの本文が読めません。
メール本文と署名データを別にして、メール本文をplaintextで送る手法です。

node-forgeで対応しようとしたが断念

node-forge

PKCS#7 という形式で署名データを生成するらしいが、クリアテキスト署名の生成方法がわからず。

PKCS #7 分離署名

3.4.3 multipart/signed フォーマットを使った署名

さらに調べていくと、PKCS #7 分離署名という言葉を見つける。

detached mode

node-forgeのReadmeを読んでいると、detached modeという記載を見つける。
おそらくこれが分離署名にあたるのではないかと仮定し、実装してみる。

// PKCS#7 Sign in detached mode.
// Includes the signature and certificate without the signed data.
p7.sign({detached: true});

node-forgeのIssuesに、detached modeについて言及しているものがあった

PKCS#7 detached is not that much detached #607

内容を読んでみると、detached modeに不具合があるもよう。
Closedになってるのでもう直っているのかなと思い、該当のソースを確認してみると…

// TODO: optimize away duplication

いや直ってないんかいwww
node-forgeは諦めよう。

openssl_pkcs7_sign() というPHPの関数を見つける

$message = realpath('message.txt');
$sign = realpath('sign.txt');
$cert = 'file://' .realpath('./cert.txt');
$key = 'file://' .realpath('./key.txt');
$headers = [
    'signing-time' => (new DateTime())->format('o-m-d H:i:s'),
];

$certfile = file_get_contents($cert);
$pkeyfile = file_get_contents($key);

openssl_pkcs7_sign($message, $sign, $certfile, array($pkeyfile, ''), $headers, PKCS7_TEXT | PKCS7_DETACHED);

出力される「sign.txt」の中身をそのままRawMessageとして、 ses.sendRawEmail()を送信したところうまくいった。
この関数はおそらく裏でopensslコマンドを実行しているだけだと思われる。
Lambdaでopensslを使えば解決するのでは。

Lambdaでopensslコマンドを使用する

AWS LambdaでOpenSSLを使う方法

参考サイトのままだとうまくいかないので補足します。

opensslに実行権限を付与

opensslに実行権限を付与してからzipを生成すること。

zipの作り方に注意

opensslをディレクトリに入れてzipを作ると階層が1つ深くなってしまうので、以下の方法でzipを生成すること

zip -r openssl.zip openssl

最終的にこんなコードになりました。

const execSync = require('child_process').execSync

const toAddresses = '送信先メールアドレス'
const cert = 'PEM形式の証明書'
const privateKey = 'PEM形式の秘密鍵'
let mailBody = 'メール本文'

const certPath = '/tmp/cert.txt'
fs.writeFileSync(certPath, cert)

const privateKeyPath = '/tmp/privateKey.txt'
fs.writeFileSync(privateKeyPath, privateKey)

mailBody = 'Content-Type: text/plain; charset=ISO-2022-JP\r\n\r\n' + mailBody 

const mailBodyPath = '/tmp/mailBody.txt'
fs.writeFileSync(mailBodyPath, mailBody )

// opensslコマンドで署名データ生成
const opensslCommand = `/opt/openssl smime -pk7out -sign -in ${mailBodyPath} -signer ${certPath} -inkey ${privateKeyPath}  -md SHA256 -from "${sender}" -to "${toAddresses}" -subject "${subject}"`
const rawMessage = execSync(opensslCommand).toString()

const eParams = {
  Destinations: [toAddresses],
  RawMessage: {
    Data: Buffer.from(rawMessage),
  },
  Source: sender,
}

await ses.sendRawEmail(eParams).promise()

おそらく-pk7out が分離署名にあたるのではないかと思われます。
証明書などのデータはファイルから読み込む方法しかないようなので、無理やりですが、/tmp/ディレクトリに保存して読み込んでいます。