JavaScript
Node.js
gmail
googleapi
メール

NodeでGmailの下書きにパスワードをかけた添付ファイルを突っ込む

  • ペイン
    • 社内ルール上、添付ファイルは「必ずパスワードをかけ」「パスワードは別のメールで」1送付することとなっている。
    • しかし、パスワードをかけ忘れるケースが稀にあり、またかけたとしても辞書攻撃で簡単に破れそうなものがほとんどである。(日付4桁とか……)
  • → 選択したファイルに自動でランダムなパスワードをかけてGmailの下書きに突っ込めばいいんじゃね?

ということで、Node.jsでアップロードされたファイルをパス付きZIPに固めてGmailの下書きに入れるコードを書いてみました。

Expressに乗せたのはただの趣味です。

ソースはこちら。
https://github.com/kuinaein/passzip-express/compare/cff700b...3a01739

パスワード生成

ランダムで16文字のパスワードを生成します。
これは12バイト分の乱数を生成してBase64エンコードかけるだけです。半角英数62文字に自前で変換するのも面倒なので。

const crypto = require('crypto');

// 12バイト(=96ビット)のデータをbase64エンコードすると16文字になる
// 6ビットのデータをbase64エンコードすると1文字(8ビット)になるので
function generatePassword () {
  return new Promise((resolve, reject) => {
    try {
      resolve(Buffer.from(crypto.randomBytes(12)).toString('base64'));
    } catch(err) {
      reject(err);
    }
  })
}

一応暗号論的疑似乱数を使っていますが、どうせスーパーハカーさん対策にはならないのでメルセンヌ・ツイスタ等でも良い気がします。ただし線形合同法、テメーは駄目だ!

フロントエンドとか別の言語の場合は下記あたり参照。
各言語での、本当に安全な乱数の作り方

なお、random-number-csprngは出力に規則性が見られたので使うのをやめました。原因はこのあたりか?
calculateParameters seems to be broken on node · Issue #4 · joepie91/node-random-number-csprng

→2018-07-07追記:random-number-csprng出力の規則性については、自分のパラメータ指定ミスである可能性が高いです。要確認

ZIPにパスワードをかける

Node.jsの場合はminizip-asm.jsが唯一の選択肢のようです。使い方自体は簡単。
ただし、Linuxではunzipコマンドで展開できないので7-Zipが必要になることに注意。(なので、ピュアJavascriptにこだわらなければ他の手を使ったほうがよさそう)

また、Windows7以前のWindowsではZIP内のファイル名をシフトJIS(精確にはCP932)にしないと文字化けするのでその変換もしておきます。

const iconv = require('iconv-lite');
const Minizip = require('minizip-asm.js');

// ...

const mz = new Minizip();
for (const f of req.files) {
  mz.append(iconv.encode(f.originalname, 'Windows932'),
    f.buffer, { password })
}

// ...

Buffer.from(mz.zip().toString('base64')),

添付ファイルとしてGmailの下書きフォルダに入れる

鬼門。GMail APIは引数として RFC822に沿ったナマのメールデータ を要求するのでそれを自力で組み立てる必要があります。
かつ、 URLセーフな Base64にエンコードしてやらないと弾かれます。(自分はここでハマりました)

その他の主な注意点は下記の通り。

  • 件名及びファイル名に日本語を含む場合は、「=?UTF-8?B?Base64エンコードしたファイル名?=」にする。
  • 添付ファイル付きのメールは「Content-Type: multipart/mixed; boundary="任意の境界文字列"」として送る。
    • 各パートごとに「--境界文字列」で区切り、ヘッダ行を入れる。
    • 添付ファイルは「Content-Type: application/zip; name="ファイル名"」とする。
    • 添付ファイルは URLアンセーフな Base64にエンコードしないと壊れる。注意。
    • メール末尾は「--境界文字列--」で締める。(ケツの--に注意)
const URLSafeBase64 = require('urlsafe-base64');

const { google } = require('googleapis');

function b64 (s) {
  const b = s instanceof Buffer ? s : Buffer.from(s)
  return URLSafeBase64.encode(b)
}

  // ...

  const gmail = google.gmail({version: 'v1', auth})

  // ...

    let buf = Buffer.from(`Subject: =?UTF-8?B?${b64(req.body.subject)}?=
  Content-Type: multipart/mixed; boundary="${boundary}"

  --${boundary}
  Conent-Type: text/plain; charset="UTF-8";
  Content-Transfer-Encoding: base64

  パスワードは別メールでお送りいたします。
  --${boundary}
  Content-Type: application/zip; name="=?UTF-8?B?${b64(attachmentName)}?="
  Content-Transfer-Encoding: base64\n\n`);
    buf = Buffer.concat([
      buf,
      Buffer.from(mz.zip().toString('base64')),
      Buffer.from(`--${boundary}--\n`)
    ]);

    b64ed = b64(buf);
    b64ed2 = b64(`Subject: =?UTF-8?B?${b64('【PW】' + req.body.subject)}?=

  別メールにて送信した添付ファイルのパスワードは
  「${password}」
  になります。
  `);

    // ...

  }).then(r => {
    // ...
    return gmail.users.drafts.create({
      userId: 'me',
      resource: { message: { raw: b64ed } }
    });
  }).then(r => {
    // ...
    return gmail.users.drafts.create({
      userId: 'me',
      resource: { message: { raw: b64ed2 } }
    });

  1. ちなみに、パスワードを別メールで送る理由は、本体を送る際に宛先を間違えたときの保険という意味合いが大きいです。スーパーハカーさん対策ではありません……。ちまたで言われている通りメールボックス盗聴できるなら無意味だし、添付ファイルなんていくらでもブルートフォースかけられるし。