🤐

[Ruby] rubyzipでパスワード付きZIPファイル(良くないけど)を作成する

2022/08/29に公開

rubyzip gemを使って、パスワード付きZIPファイルを作成するモジュールを実装するサンプルです。

まずはパスワード付きZIPファイルの使用中止を検討しよう

いきなりですが、そもそもパスワード付きZIPファイルの使用は、以下のような理由から推奨されていません。

  • Windowsの標準機能が対応しているため広く使われる「ZipCrypto」という暗号方式は、強度が弱い

  • セキュリティ対策製品が、ファイルの中身をウイルスチェックできない

  • Emotetなどの攻撃で、ウイルスを送りつける手段としても使われる[1]

また、企業間でよく使われているパスワード付きZIPファイルを利用したメールの添付ファイル送信方法、いわゆるPPAP[2]にも問題があります。

  • 宛先間違い、通信傍受に無力

  • 圧縮・解凍が手間

  • ...

要は、セキュリティを目的にしているのに、セキュリティ的意義が薄い(=防御できていない)、あるいは一部の脆弱性を強めてしまう方法である、ということです。

是非、「パスワード付きZIP 非推奨」「パスワード付きZIP 廃止」などでググったり、参考情報のところのリンク先を確認したりしてみてください。

本記事は、上記の事情を承知した上で、それでもやむを得ない理由で歯ぎしりしながらパスワード付きZIPを生成しなければならなくなった方向けです。

GitHub上のサンプル

GitHubに、サンプルを公開しています。

デプロイ方法やサンプル動作確認プログラム、APIドキュメントも用意しています。
本記事中の簡略版ではなくマジメバージョンを実際に動かして確認してみたい方は、リンク先を確認してみてください。

バージョン

以下で動作確認しています。

  • Ruby 3.1.2

  • rubyzip 2.3

    • 記事執筆時点(2022年8月29日)でRubyGems.orgにある最新バージョン[3]

    • 現在開発が進められているrubyzip 3.0では、一部APIの仕様が変わっている[4]ので、注意

      • 本記事で使っているZip::OutputStream.write_bufferメソッド[5]も変更を受けているので、以下まとめの部分に影響する。具体的には、encという引数が名前付き引数になって、より便利になっている

まとめ

  • 通常のZIPファイルを作る場合はZip::File.openメソッド[6]があるが、パスワード付きZIPの作成には対応していない

  • パスワード付きZIPファイルを作成するには、Zip::OutputStream.write_bufferメソッド[5:1]という少し低いレベルのAPIを使う

    1. Zip::OutputStream.write_bufferメソッドで、パスワード付きZIPファイルデータのStringIO[7]オブジェクトを得る

      • 1番目の引数にStringIOインスタンス、2番目の位置引数にパスワード文字列から作ったZip::TraditionalEncrypter[8]インスタンスを渡す
    2. StringIO#string[9]で、パスワード付きZIPファイルデータのStringを得る

    3. 2のStringをファイルに書き込んで、ZIPファイルができあがる

サンプル実装

パスワード付きZIPファイルをrubyzip gemで作成する場合の事情は、前項(まとめ)のとおりです。

今回は、ZipGeneratorというモジュールに、以下のようなクラスメソッドを実装します:

  • ZipGenerator.get_zip_buffer(archived_filepaths, password: nil)

    「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを持ったStringIO[7:1]を返す、低レベルなメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのStringIOを出力できる。主に、他の、より高レベルなメソッドが使うことを想定。

  • ZipGenerator.zip_archive(archived_filepaths, zip_path, password: nil)

    「ZIPファイルに含めたいファイル」のパスのArrayを渡して、指定したパスにZIPファイルを出力するためのメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルを出力できる。

  • ZipGenerator.get_zip_tempfile(archived_filepaths, password: nil)

    「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを格納したTempfile[10]オブジェクトを返すメソッド。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのTempfileを出力できる。

本記事では、メソッドの細かいエラー実装などの部分は省いて、「実現したい機能を自分で実装する場合に参考にできる」程度のコードのみ残して説明しています。

一応本記事のコードでも(下準備込みで)動きますが、マジメなコードはGitHubリポジトリを参照してください

下準備と実行方法

下準備(あまり本質的でないので畳みます)

以下のGemfileを書いて、

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem "rubyzip", "~> 2.3"

bundle installします。

$ ls
Gemfile

$ bundle install
# 出力は省略

以降のコードは、lib/zip_generator.rbファイルに書いていくものとします。

$ mkdir lib
$ touch lib/zip_generator.rb

$ tree -F
.
├── Gemfile
├── Gemfile.lock
└── lib/
    └── zip_generator.rb # ここにコードを書く

1 directory, 3 files

実行の際は、lib/zip_generator.rbrequire_relativeしたコードでメソッドを呼ぶプログラムを書いて、そのプログラムについて

$ bundle exec ruby <プログラム>.rb

します。

ZipGenerator.get_zip_buffer

「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを持ったStringIO[7:2]を返す、低レベルなメソッドです。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのStringIOを出力できます。

これ単体を使うというよりは、他の、より高レベルなメソッドが使うことを想定したメソッドです。

lib/zip_generator.rb
require "zip"

module ZipGenerator
  FileEntry = Struct.new(:entry_name, :filepath, keyword_init: true)

  def self.get_zip_buffer(archived_filepaths, password: nil)
    # ファイルパスのうち、ファイル名(basename)部分をZIPファイル中のエントリー、
    # つまり、ZIPファイルを解凍した際のファイル名にする。(という実装)
    file_entries = archived_filepaths
                     .map { |path| FileEntry.new(entry_name: File.basename(path), filepath: path) }

    enc = password ? Zip::TraditionalEncrypter.new(password) : nil

    Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output|
      file_entries.each do |file_entry|
        output.put_next_entry(file_entry.entry_name)

        file_data = File.read(file_entry.filepath)
        output.write(file_data)
      end
    end #=> メソッドの戻り値は、ZIPファイルのデータを持ったStringIOオブジェクト
  end
end

ここでのポイントは、次のとおりです:

  • Zip::OutputStream.write_bufferメソッド[5:2]を使う

    • 第1位置引数で渡したStringIO[7:3]オブジェクトにZIPファイルのデータを書き込む

    • その書き込んだStringIOオブジェクトが戻り値になる

      • (したがって、このメソッドを戻り値にしているZipGenerator.get_zip_bufferの戻り値もStringIOオブジェクト)
    • 第2位置引数にZip::TraditionalEncrypter[8:1]インスタンスを渡すと、パスワード付きZIPファイルのStringIOオブジェクトが得られる

    Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output|
      # ...
    end
    
  • Zip::OutputStream.write_bufferのブロック中では各「ZIPファイルに含めようとしているファイル」について、

    • Zip::OutputStream#put_next_entryメソッド[11]で、ZIPエントリー名...つまりはZIPファイルを解凍した際のファイル名を登録し、

    • Zip::OutputStream#writeメソッド[12]で、実際のZIPデータ(String)を書き込む

    Zip::OutputStream.write_buffer(StringIO.new(""), enc) do |output|
      file_entries.each do |file_entry|
        output.put_next_entry(file_entry.entry_name)
    
        file_data = File.read(file_entry.filepath)
        output.write(file_data)
      end
    end
    
  • パスワードの元となるZip::TraditionalEncrypterインスタンスは、Zip::TraditionalEncrypter.new(<パスワードにしたいString>)[13]で得られる

    enc = password ? Zip::TraditionalEncrypter.new(password) : nil
    

ZipGenerator.zip_archive

本命のメソッドです。

「ZIPファイルに含めたいファイル」のパスのArrayを渡して、指定したパスにZIPファイルを出力させます。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルを出力させられます。

lib/zip_generator.rb
require "zip"
require "pathname"

module ZipGenerator
  def self.zip_archive(archived_filepaths, zip_path, password: nil)
    buffer = get_zip_buffer(archived_filepaths, password: password)

    File.open(zip_path, "wb") { |f| f.write(buffer.string) }
  end
end
  • StringIO#string[9:1]で、パスワード付きZIPファイルデータのStringを得る

  • それをファイルに書き込む

ZipGenerator.get_zip_tempfile

「ZIPファイルに含めたいファイル」のパスのArrayを渡すと、ZIPファイルのデータを格納したTempfile[10:1]オブジェクトを返すメソッドです。パスワードを名前付き引数で指定することで、パスワード付きZIPファイルのTempfileを出力できます。

主に、以下のような用途を想定しています:

  • ZIPファイルを動的に作成して、Webページ上のその場でダウンロードさせたり、メールの添付ファイルにしたりする(そしてその後はZIPファイルが必要なくなる)ような場面

  • ZIPファイルのStringIOを生成してから使うまでに少し時間がかかったり、大量にZIPファイルを作成したりなど、データをメモリ上に持っておきたくない場面

lib/zip_generator.rb
require "zip"
require "tempfile"

module ZipGenerator
  def self.get_zip_tempfile(archived_filepaths, password: nil)
    buffer = get_zip_buffer(archived_filepaths, password: password)

    Tempfile.open do |fp|
      fp.write(buffer.string)
      fp
    end #=> メソッドの戻り値は、Tempfileオブジェクト
  end
end

ここでのポイントは、以下のとおりです:

  • メソッドの戻り値がTempfileオブジェクトになるように、Tempfile.openメソッドのブロックの中でfp.writeTempfile#write)ではなく、fp(Tempfileオブジェクト)を最後にしている

    Tempfile.open do |fp|
      fp.write(buffer.string)
      fp # <= ブロックの戻り値
    end
    
  • (Tempfileの使い方の注意)Tempfileを使う際は、Tempfile#closeなどに気をつける[10:2]

参考情報

パスワード付きZIPファイル・PPAP

rubyzip

脚注
  1. 「Emotet(エモテット)」と呼ばれるウイルスへの感染を狙うメールについて(IPA) ↩︎

  2. PPAP (セキュリティ)(Wikipedia) ↩︎

  3. rubyzip(RubyGems.org) ↩︎

  4. Updating to version 3.0 < Important notes < README.md(rubyzip GitHub) ↩︎

  5. Zip::OutputStream.write_buffer(rubyzip 2.3 rubydoc.info) ↩︎ ↩︎ ↩︎

  6. Zip::File.open(rubyzip 2.3 rubydoc.info) ↩︎

  7. StringIO(Rubyリファレンスマニュアル) ↩︎ ↩︎ ↩︎ ↩︎

  8. Zip::TraditionalEncrypter(rubyzip 2.3 rubydoc.info) ↩︎ ↩︎

  9. StringIO#string(Rubyリファレンスマニュアル) ↩︎ ↩︎

  10. Tempfile(Rubyリファレンスマニュアル) ↩︎ ↩︎ ↩︎

  11. Zip::OutputStream#put_next_entry(rubyzip 2.3 rubydoc.info) ↩︎

  12. Zip::IOExtras::AbstractOutputStream#wite(rubyzip 2.3 rubydoc.info) ↩︎

  13. Zip::TraditionalEncryption#initialize(rubyzip 2.3 rubydoc.info) ↩︎

Discussion