Linux デスクトップにおけるクリップボードの性質

f:id:masm11:20210319205809p:plain

こんにちは、masm11 です。

今回はかなり毛色を変えて、デスクトップのある身近な機能について プログラム的な話をしようと思います。

その機能とは、クリップボードです。

セレクションとは

Linux デスクトップにおいて、「クリップボード」と、それと似たものがあるのは、 割と有名なことだと思っています。

以下の 3つがあって、まとめて「セレクション」と呼びます。

  • PRIMARY
  • SECONDARY
  • CLIPBOARD

ユーザの観点から使い方の違いを説明します。

CLIPBOARD はそのままクリップボードとして使われます。 ctrl+c でコピーして ctrl+v でペーストするやつです。

一方 PRIMARY は、範囲をマウスで選択すると、それだけでその範囲がコピーされます。 そして3ボタンマウスの中ボタンでペーストすることができます。

では SECONDARY とは何でしょうか? これは実は特に用途が決まっていません。

プログラム的には

セレクションは3つあり、ユーザから見た違いを説明しました。 では、プログラム的に違いはあるのでしょうか。

実はプログラム的には 3つは全く同じように使えます。

  1. コピー時に、アプリAが「PRIMARY を所有します」と宣言する。
  2. 他のアプリBが PRIMARY を使ってペーストしたい時は、B がデスクトップ経由で A を特定する
  3. B が A に接続する
  4. A と B との間でデータ型をネゴる
  5. A から B へデータを転送する

こんな感じです。PRIMARY でなく CLIPBOARD を使いたい時は CLIPBOARD に置き換えるだけです。

ただし、PRIMARY と CLIPBOARD とではユーザの使い方が違いますので、 そこは「所有します」と宣言するタイミングを変える必要はあります。

インターネット上の記事によっては「PRIMARY と CLIPBOARD はプログラム的な扱いも異なる」と 書いてあることがありますが、3つとも使い方は同じで、ユーザへの見せ方が違うだけです。

データ転送方法

Linux デスクトップのセレクションが、macOS や Windows のクリップボードと内部的に大きく 異なるのは、データの転送方法だと思います。

先程説明したとおり、アプリ同士が直接接続を張って、直接やりとりしています。

デスクトップは何をやっているかと言うと、セレクションを誰が所有しているかを管理しているだけです。 「PRIMARY を所有します」と宣言すると、PRIMARY を所有するアプリの情報を更新し、 別のアプリから「PRIMARY は誰が所有してるか?」と尋ねられたら、どのアプリが所有しているかを 返答します。

デスクトップはデータそのものは持ちません。 CLIPBOARD という名称ではありますが、クリップボードデータは持っていないのです。

他のデスクトップとの違い

ここで疑問に思われる方もいらっしゃるでしょう。

コピー操作をした後、そのアプリを終了したら、ペーストできるのか?

Linux デスクトップではペーストできません。 接続先アプリが存在しないし、 そもそもアプリ終了時点でそのセレクションを誰も持っていない状態になるからです。 ただし、クリップボードマネージャを使っている場合を除きます。

Linux デスクトップを使ってる方は試してみてください。

ここが Linux デスクトップのクリップボードが macOS や Windows のクリップボードと ユーザ視点で大きく異なる点だと思います。

まとめ

Linux デスクトップにおけるセレクション3つについて、 ユーザ視点での違い、プログラム的な違いを説明し、 さらに他のデスクトップのクリップボードとの大きな違いを説明しました。

本当は最後の段落が書きたかっただけです。

弊社には業務で Linux デスクトップを使っている人がいますが、 きっとそのクリップボードがそういうものだとは気づいていないと思います。

Linux デスクトップをこよなく愛するそこのあなた! 業務で Linux デスクトップを 使える日を夢見ながら一緒にインゲージで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ローカルの AWS のアクセスキーを自動的にローテーションする

f:id:kizashi1122:20210315143238p:plain

id:kizashi1122 です。

最初に発行したAWS のアクセスキーをそのままずっと使ってませんか?

docs.aws.amazon.com

そもそもできるならばキーは発行すべきではありませんが、発行することが必要なシチュエーションもありますよね。 上記記事にも、

  • アクセスキーを定期的に更新します

と、あるようにキーは漏洩したときのリスクを減らすためにも更新したほうがいいです。

f:id:kizashi1122:20210315134357p:plain:w200

こうならないようにしたいですね。

これ、実はアクセスキーを自動的にローテションする仕組みがあるんです。

手順

手順は以下の通りです。 .aws/credentials がすでにある前提です。

  • 当該 IAM ユーザに自身のキーを作成・削除する権限を与える
  • aws-rotate-iam-keys をインストールする
  • 依存コマンドである aws コマンドをインストールする
  • 依存コマンドである jq コマンドをインストールする

1つずつ見ていきましょう。

IAM ユーザの権限追加

以下のポリシーが追加されていればいいです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:ListAccessKeys",
                "iam:CreateAccessKey",
                "iam:DeleteAccessKey"
            ],
            "Resource": [
                "arn:aws:iam::*:user/${aws:username}"
            ]
        }
    ]
}

aws-rotate-iam-keys インストール

 $ git clone https://github.com/rhyeal/aws-rotate-iam-keys.git
 $ sudo cp aws-rotate-iam-keys/src/bin/aws-rotate-iam-keys /usr/local/bin/
 $ rm -rf aws-rotate-iam-keys

こんな感じで PATH の通ったところにおいてもらえたらいいです。

aws コマンドインストール

 $  curl "https://s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"
 $  unzip awscli-bundle.zip
 $  sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws

jq コマンドインストール

$ sudo yum install jq

yum でインストールできます(環境によっては他の方法でインストールしてください)

以上!

実行

超簡単です。

$ aws-rotate-iam-keys

だけ。プロファイルを切ってる場合は、

$ aws-rotate-iam-keys -p other_profile

と指定することができます。

あとはこれを crontab に入れるなり、.bashrc に追加するなりすれば自動化できます。

f:id:kizashi1122:20210315141736p:plain:w200

緑が気持ちいいです! インゲージの開発メンバーは皆この仕組みを導入しています。

実は・・・

今回の話は、2020年2月の JAWSUG で ABEJA の村主さんが発表されたこちらのスライドに書いている内容そのまんまです。

speakerdeck.com

他にもお役立ち情報が沢山載っているので必見です。

インゲージではエンジニアを募集しています! 興味ある方は こちら まで!

ActiveJob で queue を動的に指定する3つの方法

f:id:shutooike:20210311110057p:plain おはようございます。 @shutooike です!

業務上であるジョブのキューを条件によって動的に変えたい場面に遭遇し、ActiveJobのソースを読んだのでメモを残しておきます。

前提条件

Rails version: 5.2

queue_adapter: Sidekiq

対象のジョブクラスはこんな感じ

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
  def perform(args)
    do_something
  end
end

方法1

ActiveJob::Core.setActiveJob::Enqueuing#enqueuequeue を渡す

ジョブの呼び出し元で制御するという思いつく限り一番簡単な方法です。

実装

# set を使うパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.set(queue: 'another_queue').perform_later(args)
else
  SampleJob.perform_later(args)
end

# enqueue に渡すパターン
if ConditionModel.exists?(id: condition.id)
  SampleJob.new(args).enqueue(queue: 'another_queue')
else
  SampleJob.perform_later(args)
end

内部をチラッと覗くと

module ActiveJob
  module Core
...
    module ClassMethods
...
      def set(options = {})
        ConfiguredJob.new(self, options)
      end
    end
...

ActiveJob::Core.set 内で ActiveJob::ConfiguredJob というインスタンスが作られます。

module ActiveJob
  class ConfiguredJob #:nodoc:
    def initialize(job_class, options = {})
      @options = options
      @job_class = job_class
    end
...
    def perform_later(*args)
      @job_class.new(*args).enqueue @options
    end
  end
end

ActiveJob::ConfiguredJob#perform_later はジョブのインスタンスを作って ActiveJob::Enqueuing#enqueue を呼ぶので、enqueue に渡すパターンと行き着く先は同じです。

#enqueue の実装は以下のようになっており、 options['queue'] があれば ジョブインスタンスの queue_name を上書きします。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

今回の変更対象のジョブはいろんなところで呼ばれていて、呼び出し元を変更するコストが高かったのでこの方法は 不採用

方法2

before_enqueue#queue_name を動的に変更する

ActiveJob は他の Rails のライブラリと同様コールバックを持っています。

今回はエンキュー前に呼ばれる必要があるので、before_enqueue コールバックを使います。

実装

class SampleJob < ApplicationJob
  queue_as 'default'
  
  rescue_from(StandardError) do
    retry_job(wait: 5.minutes)
  end
  
+  before_enqueue do |job|
+    if ConditionModel.exists?(id: job.arguments[0]) 
+      job.queue_name = 'another_queue'
+    end
+  end

...

これだと変更箇所が一箇所で済むのでいい感じです。

ただ、今回の変更対象のジョブは上の実装のようにジョブの引数を使ってDBにアクセスし queue を変更するか確認する必要がありました。

もしジョブ実行中にDBアクセスに失敗してエラーになった場合、retry_job 時にも before_enqueue が呼ばれてDBアクセスでエラーになり、ジョブのエンキューされないまま失敗してしまう可能性があると指摘をいただきました。なので方法2も 不採用

ただ、方法1を使うのはエンジニアとして負けな気がしたので頑張って探します。

方法3

ActiveJob::Core.queue_as にブロックを渡す

先に実装を示します。

実装

class SampleJob < ApplicationJob
-  queue_as 'default'

+  queue_as do
+    if ConditionModel.exists?(id: self.arguments[0])
+      'another_queue'
+    else
+      'default'
+    end
+  end

...

方法2の before_enqueue とほぼ同じ処理を queue_as のブロックに渡しただけです。

これでうまくいく理由をAJのソースコードと共に順を追って説明していきます。

まず、queue_as の定義を見ます。

module ActiveJob
  module QueueName
    extend ActiveSupport::Concern

    # Includes the ability to override the default queue name and prefix.
    module ClassMethods
...

      def queue_as(part_name = nil, &block)
        if block_given?
          self.queue_name = block
        else
          self.queue_name = queue_name_from_part(part_name)
        end
      end

...

    included do
      class_attribute :queue_name, instance_accessor: false, default: default_queue_name
      class_attribute :queue_name_delimiter, instance_accessor: false, default: "_"
    end

...

ブロックが渡されたらブロックをそのままクラス属性の queue_name に入れ、 それ以外は引数を queue_name_from_part(プレフィックスとかをつける処理)に通してクラス属性の queue_name に入れていますね。

このクラス属性の queue_name はどこで使われるかというと、

module ActiveJob
  module Core
...

    def initialize(*arguments)
      @arguments  = arguments
      @job_id     = SecureRandom.uuid
      @queue_name = self.class.queue_name
      @priority   = self.class.priority
      @executions = 0
    end
...

ジョブのコンストラクタ内で @queue_name に代入されています。

次にジョブが Sidekiq にエンキューされるまでの流れを追います。 まず方法1でも見た ActiveJob::Enqueuing#enqueue をもう一度確認します。

module ActiveJob
  module Enqueuing
...    
    def enqueue(options = {})
      self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
      self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
      self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
      self.priority     = options[:priority].to_i if options[:priority]
      run_callbacks :enqueue do
        if scheduled_at
          self.class.queue_adapter.enqueue_at self, scheduled_at
        else
          self.class.queue_adapter.enqueue self
        end
      end
      self
    end

最後に queue_adapterenqueue メソッドが呼ばれています。

弊社はアダプターに Sidekiq を使っているので SidekiqAdapter#enqueue が呼ばれます。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

SidekiqAdapter#enqueue はジョブインスタンスを引数に受け、Sidekiq 側に投げる際、 "queue"job.queue_name を指定していることが確認できます。

この job.queue_nameActiveJob::QueueName に定義されています。

module ActiveJob
  module QueueName
...

    # Returns the name of the queue the job will be run on.
    def queue_name
      if @queue_name.is_a?(Proc)
        @queue_name = self.class.queue_name_from_part(instance_exec(&@queue_name))
      end
      @queue_name
    end
  end
end

#queue_name はインスタンス変数 @queue_name がブロックなら、instance_exec*1 を使いジョブインスタンスのコンテキストでブロックを実行した結果を @queue_name に代入したあと返し、ブロック以外なら、@queue_name をそのまま返しています。

まとめると、queue_as にブロックを渡した場合、最終的に Sidekiq に "queue" として渡す値は、初回はブロックの実行結果を、2回目以降は初回の実行結果が返すこともわかりました。これにより retry_job 時(2回目以降)にはDBアクセスしないことがわかったので、ジョブのエンキューされないまま失敗してしまう心配もなく動的に queue を変更できるようになりました。ということで今回は方法3を 採用 🎉

補足

なぜリトライ時(2回目以降)も初回のブロック実行結果が引き継がれるの?

ActiveJob から Sidekiq::Client に投げる際、"args"job.serialize を渡しています。

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end
...

ジョブインスタンスの #serializeActiveJob::Core に定義があって

module ActiveJob
  module Core
...

    # Returns a hash with the job data that can safely be passed to the
    # queueing adapter.
    def serialize
      {
        "job_class"  => self.class.name,
        "job_id"     => job_id,
        "provider_job_id" => provider_job_id,
        "queue_name" => queue_name,
        "priority"   => priority,
        "arguments"  => serialize_arguments_if_needed(arguments),
        "executions" => executions,
        "locale"     => I18n.locale.to_s
      }
    end
...

上記のハッシュを返します。このハッシュに queue_name もありますね。

serialize があれば deserialize もあるはずです。deserialize だけを探してもいいですが、せっかくなので Sidekiq から ActiveJob のジョブが動く流れを追います。(Sidekiq のコードは追いません)

module ActiveJob
  module QueueAdapters
...
    class SidekiqAdapter
      def enqueue(job) #:nodoc:
        # Sidekiq::Client does not support symbols as keys
        job.provider_job_id = Sidekiq::Client.push \
          "class"   => JobWrapper,
          "wrapped" => job.class.to_s,
          "queue"   => job.queue_name,
          "args"    => [ job.serialize ]
      end   
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

Sidekiq の worker は Redis からジョブ情報を取得し、 "class"に指定したクラスのインスタンスの perform"args" の一つ目の要素を引数に実行します。

ここで "class" として渡されている JobWrapperperform の処理は

module ActiveJob
  module QueueAdapters
    class SidekiqAdapter
...
      class JobWrapper #:nodoc:
        include Sidekiq::Worker

        def perform(job_data)
          Base.execute job_data.merge("provider_job_id" => jid)
        end
      end
...

ActiveJob::Base.execute を 呼び出しています。

module ActiveJob
  module Execution
...
    module ClassMethods
...
      def execute(job_data) #:nodoc:
        ActiveJob::Callbacks.run_callbacks(:execute) do
          job = deserialize(job_data)
          job.perform_now
        end
      end
    end
...

お、deserialize が出てきました。クラスメソッド の deserialize を探します。

module ActiveJob
  module Core
...
    module ClassMethods
      # Creates a new job instance from a hash created with +serialize+
      def deserialize(job_data)
        job = job_data["job_class"].constantize.new
        job.deserialize(job_data)
        job
      end

serialize で作ったハッシュをもとに新しいジョブのインスタンス作成すると言ってますね。 job_data というハッシュの "job_class" からジョブインスタンスを作成して、インスタンスメソッドの deserialize を呼び出しています。 インスタンスメソッドの deserialize を探します。

module ActiveJob
  module Core
...

    def deserialize(job_data)
      self.job_id               = job_data["job_id"]
      self.provider_job_id      = job_data["provider_job_id"]
      self.queue_name           = job_data["queue_name"]
      self.priority             = job_data["priority"]
      self.serialized_arguments = job_data["arguments"]
      self.executions           = job_data["executions"]
      self.locale               = job_data["locale"] || I18n.locale.to_s
    end
...

やっと辿り着きました。ジョブインスタンスの queue_namejob_data["queue_name"] が代入されています。 job_data["queue_name"] の値はブロックの実行結果が入っているのでリトライ時(2回目以降)に初回のブロック実行結果が引き継がれます。

おわりに

ActiveJob はめちゃくちゃ読みやすかったです!

【宣伝】 弊社では必要とあらばライブラリのソースコードをバリバリ読むぜ!というロックなエンジニアを募集しております!ご興味あればぜひ下記リンクをご覧ください! ingage.co.jp

ではまた!

*1:instance_exec についてはこちらの記事がわかりやすかったです。https://secret-garden.hatenablog.com/entry/2015/10/18/000000

MessageEncryptor の互換性を調べてみた

f:id:masm11:20210307174016p:plain

こんにちは、masm11 です。

Rails に MessageEncryptor というクラスがあります。 何かを暗号化/復号する際に便利ですね。

例えば、

KEY = 'somekeysomekeysomekeysomekeysomekeysomekeysomekey'
enc = ActiveSupport::MessageEncryptor.new(KEY, cipher: 'aes-256-cbc')

としておけば、

data = enc.encrypt_and_sign(obj)
obj = enc.decrypt_and_verify(data)

で暗号化と復号ができます。

ところが、Ruby 2.4 で仕様が変わってしまったんです。 KEY がちょうど 32文字でないといけなくなりました。

ちょっと試したところでは、元のコードだと

key must be 32 bytes

というエラーが出ますし、

KEY = 'somekeysomekeysomekeysomekeysome'

と 32文字で切り捨てたキーを指定すると、

ActiveSupport::MessageVerifier::InvalidSignature

というエラーが出て復号できません。

さぁ困ったどうしよう、ということで調べた結果です。

結論は一番最後 (まとめ) にあります。

サンプルを作る

まずは Ruby 2.3.3 + Rails 5.2.4.4 でサンプルを作ります。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')

長い文字列がキーです。

enc.encrypt_and_sign('short string')
=> "MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83"

short string という文字列を暗号化してみました。 その結果、長い文字列が得られています。これが暗号化文字列です。

OpenSSL を直接使って復号してみる

OpenSSL は 1.1.1i を使用しました。

上記の通り、サンプルは -- の左と右に分けられます。 このうち左側を取り出し、base64 デコードします。

$ echo MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ== | base64 -d
0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA=--qWtbnFglWUfMEL1KvT52jQ==

また base64 っぽい文字列が出てきました。

-- の左側を base64 デコードします。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | od -tx1c
0000000  d1  e6  4e  09  25  d8  d3  2e  c1  5d  4c  88  05  42  47  b9
        321 346   N  \t   % 330 323   . 301   ]   L 210 005   B   G 271
0000020  91  8b  85  39  69  e5  36  d8  16  1c  89  b2  4b  20  84  90
        221 213 205   9   i 345   6 330 026 034 211 262   K     204 220
0000040

バイナリなので od に食わせています。 これが暗号化されたデータそのものです。

一方、-- の右側を base64 デコードします。

$ echo qWtbnFglWUfMEL1KvT52jQ== | base64 -d | od -tx1c                    
0000000  a9  6b  5b  9c  58  25  59  47  cc  10  bd  4a  bd  3e  76  8d
        251   k   [ 234   X   %   Y   G 314 020 275   J 275   >   v 215
0000020

こちらもバイナリなので od に食わせています。 これは iv (initialization vector) です。 暗号化の計算に使う初期値です。 擬似乱数のシードみたいなものと考えると良いでしょう。

また、キーの16進表記が必要なので、キーも od に食わせます。

$ echo -n 0123456789abcdefghijklmnopqrstuvwxyz | od -tx1c
0000000  30  31  32  33  34  35  36  37  38  39  61  62  63  64  65  66
          0   1   2   3   4   5   6   7   8   9   a   b   c   d   e   f
0000020  67  68  69  6a  6b  6c  6d  6e  6f  70  71  72  73  74  75  76
          g   h   i   j   k   l   m   n   o   p   q   r   s   t   u   v
0000040  77  78  79  7a
          w   x   y   z
0000044

ここまで準備ができたら、いよいよ openssl コマンドで復号します。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | openssl enc -d -K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 -iv a96b5b9c58255947cc10bd4abd3e768d -aes-256-cbc | od -tx1c
0000000  04  08  49  22  11  73  68  6f  72  74  20  73  74  72  69  6e
        004  \b   I   " 021   s   h   o   r   t       s   t   r   i   n
0000020  67  06  3a  06  45  54
          g 006   : 006   E   T
0000026

echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d の部分は先程見ました。 -- の左側を base64 デコードした、暗号化されたデータそのものです。これを openssl の 標準入力に与えています。

openssl enc -d は復号指示です。

-K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 はキーの16進表記です。 先程 od でキーの16進表記を調べたので、それを指定します。ただし、キーの32文字までです。

-iv a96b5b9c58255947cc10bd4abd3e768d は iv の16進表記です。 こちらも先程 od で16進表記にしていますので、それを指定します。

-aes-256-cbc はアルゴリズムですね。

その結果を od に食わせています。よく見ると、それらしい文字が見えますね。復号できていそうです。

では、このバイナリから Ruby のオブジェクトを復元してみます。

使用した Ruby は 2.7.2 です。

Marshal.load("\x04\x08\x49\x22\x11\x73\x68\x6f\x72\x74\x20\x73\x74\x72\x69\x6e\x67\x06\x3a\x06\x45\x54")
=> "short string"

復元できました!

署名を検証する

最初に得たサンプル暗号化文字列のうち、-- より右側は署名です。 これを検証してみます。

OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, '0123456789abcdefghijklmnopqrstuvwxyz', 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==')
=> "8ae97f3fc117a62513fb8ca242f86d893f601c83"

OpenSSL::HMAC.hexdigest を呼び出しています。

  • SHA1 は固定です。
  • '0123456789abcdefghijklmnopqrstuvwxyz' はキーです。ここではキー全体を指定しています。
  • 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==' は暗号化文字列の -- より左です。

このように計算することで、"8ae97f3fc117a62513fb8ca242f86d893f601c83" が得られました。 これはサンプルの暗号化文字列の -- より右側に一致します。

署名が検証できました。

MessageEncryptor の構造

結局、MessageEncryptor はどのような処理をしているのでしょうか? まとめてみました。

  1. 暗号化
    1. シリアライズする (A)
    2. iv を決める (B)
    3. B とキー32文字を使って A を暗号化する (C)
    4. B, C をそれぞれ base64 エンコードする (D, E)
    5. E と D を -- を挟んでつなげる (F)
  2. 署名
    • キー全体を使って F の HMAC を計算する (G)
  3. F と G を -- を挟んでつなげる

上の復号手順は、これを逆にたどったものです。

MessageEncryptor の互換性

ここまで MessageEncryptor のコードを読みながら調べたのですが、 結局、キー32文字とキー全体の両方が必要そうです。 ここに復号できない原因があるわけです。

と、ここまで来て、ふと気づきました。

MessageEncryptor のコンストラクタは以下のようになっています。

def initialize(secret, *signature_key_or_options)
  options = signature_key_or_options.extract_options!
  sign_secret = signature_key_or_options.first
  @secret = secret
  @sign_secret = sign_secret          # <--------------- !!!
  @cipher = options[:cipher] || self.class.default_cipher
  @digest = options[:digest] || "SHA1" unless aead_mode?
  @verifier = resolve_verifier
  @serializer = options[:serializer] || Marshal
end

なんと、署名用のキーが指定できるではないですか。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuv', '0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')
enc.decrypt_and_verify('MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83')
=> "short string"

復号できました!

まとめ

Rails がそう簡単に互換性を捨てるはずがありませんね。解ってしまえば簡単なことでした。

enc = ActiveSupport::MessageEncryptor.new(KEY[0..31], KEY, cipher: 'aes-256-cbc')

こうですね。

インゲージではエンジニアを募集しています。 こんなことに熱中できる方、うちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!

MITM プロキシを作る

f:id:masm11:20210221003205p:plain

こんにちは、masm11 です。

MITM (man-in-the-middle) プロキシをご存知でしょうか? ブラウザとサーバの間に入って HTTPS の仲介をするのは普通の プロキシと同じですが、通信内容を覗くことができます。 もちろん自由に覗けてしまっては HTTPS の意味がありませんので、 特別な証明書をブラウザにインストールする必要はあります。

今回はそんな MITM プロキシを Ruby で作ってみたいと思います。

先に成果物

というか、既に作って、以下に置いてあります。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb

今回はこれがどのように動いているのか、説明してみたいと思います。

CA 証明書の作成

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L15-L46

このメソッドは Ruby の OpenSSL を使って、CA のオレオレ証明書と 秘密鍵を生成しています。

  # CA の情報を設定
  name = OpenSSL::X509::Name.new
  name.add_entry 'C',  'JP'
  name.add_entry 'ST', 'Osaka'
  name.add_entry 'DC', 'Kita-ku'
  name.add_entry 'O',  'INGAGE Inc.'
  name.add_entry 'CN', 'Masm11 CA'

この辺は、いつも openssl コマンドで CSR を作る時に入力している項目ですね。

  # CA の秘密鍵/公開鍵を生成
  rsa = OpenSSL::PKey::RSA.generate(2048)

RSA 2048bit の秘密鍵/公開鍵を生成しています。

  # CA の秘密鍵を保存
  File.write('ca.pkey', rsa.export(OpenSSL::Cipher::Cipher.new('aes256'), CA_PASSPHRASE))

秘密鍵にパスフレーズを設定してファイルに保存しています。 起動ごとに作る必要はありませんので、作ったものはファイルに保存しておくことにしました。

  # CA 証明書を作成
  cert = OpenSSL::X509::Certificate.new
  cert.not_before = Time.now
  cert.not_after = Time.now + 3600 * 24 * 365

有効期限はとりあえず1年とします。

  cert.public_key = rsa.public_key
  cert.serial = 1
  cert.issuer = name
  cert.subject = name

公開鍵等を設定しています。

  ext = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
  cert.add_extension(ext)

CA の証明書として使えるようにします。

  cert.sign(rsa, sha1)

最後に署名します。

  # CA 証明書を保存
  File.write('ca.crt', cert.to_pem)

こちらも同様に保存しておきます。

CA 証明書の読み込み

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L48-L51

2回め以降の起動時は、CA 証明書はファイルから読み込むことにします。

read_ca_cert はファイルからオブジェクトを生成して返しているだけです。

サーバ証明書の生成

今度は、CA でなくサーバの証明書の生成です。

CA は本物のサーバの秘密鍵を持っていないので、自前で秘密鍵を用意し、 対応する証明書も用意します。その証明書は上で作った CA の秘密鍵で 署名されているわけです。

先程とよく似ているので、違う部分だけ説明します。

  begin
    return OpenSSL::PKey::RSA.new(File.read("mitm-proxy/#{domain}.pkey")),
           OpenSSL::X509::Certificate.new(File.read("mitm-proxy/#{domain}.crt"))
  rescue Errno::ENOENT
  end

こちらも、一度作ったものはファイルに保存することにしました。

ここでは、ファイルから読んでオブジェクトを生成して返しています。 ただしファイルがなくて ENOENT になった場合は次の処理へ進みます。 なお、他の例外が発生した場合は呼び出し元へそのまま投げます。

 name.add_entry 'CN', domain

サーバ証明書の CommonName はそのドメインです。

  crt.issuer = ca_cert.issuer
  crt.subject = name

発行者は CA です。また対象はそのサーバです。

起動時の初期化

ここからは、処理の順を追って説明します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L152-L154

if ARGV[0] == '--init'
  init_ca_cert
end

初回起動時は --init オプションを付けるものとします。 その場合は CA 証明書を生成します。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L156

ca_pkey, ca_cert = read_ca_cert

CA の秘密鍵と証明書を読み込みます。

Socket.tcp_server_loop('127.0.0.1', 8000) do |sock, addr|
  fork do
    serve(ca_pkey, ca_cert, sock)
    exit(0)
  end
  sock.close
end

TCP のサービスを書く時、接続を受け付けたらプロセスを fork し、 新しいプロセスに処理を任せて、本体は引き続き次の接続を待つ、 という作り方をよくします。今回もそうしました。

127.0.0.1:8000 をプロキシ待ち受けアドレスとします。 そこで接続を待ち受け、接続があったらブロックが呼ばれます。 ブロックの中では、fork して serve メソッドで処理します。 親プロセスではソケットが不要なので閉じておきます。

なお、ここで閉じ忘れると、通信相手 (ブラウザや curl) が 切断を認識できなくてハマります。

処理する

では serve メソッドを見ていきます。

https://github.com/masm11/mitm-proxy2/blob/master/mitm-proxy.rb#L99-L150

  line = sysread_line(sock).chomp
  if line !~ /\ACONNECT\s+(.*):(\d+)\s/
    raise 'Bad connect: ' + line
  end
  host = $1
  port = $2.to_i

ソケットから 1行読みます。読んだものが CONNECT であることを確認して、 接続先サーバ名とポート番号を取り出します。

なお、Ruby には IO#readIO#sysread が用意されていますが、 これらは混ぜて使うとおかしな挙動になります。 OpenSSL が sysread を使っているようなので、私も sysread を使うことにしました。

IO#readlineIO#read を使っていて、ここでは使えませんので、 sysread 版を自分で作りました。それが sysread_line です。

  loop do
    line = sysread_line(sock).chomp
    break if line == ''
  end

CONNECT 以降の HTTP ヘッダの処理です。ここでは読み飛ばしているだけです。 空行が現れたら HTTP ヘッダの終わりです。

  cs = TCPSocket.open(host, port)
  cs = OpenSSL::SSL::SSLSocket.new(cs)
  cs.connect
  cs.post_connection_check(host)

接続先が得られたので、接続します。TCP で接続し、それを OpenSSL に渡して、 cs.connect で TLS ハンドシェイクをしています。cs.post_connection_check では 証明書の中の CommonName が正しいかをチェックしています。

  sock.syswrite "HTTP/1.1 200 OK\r\n\r\n"

ここまで成功したら、ブラウザに成功レスポンスを返します。

sysread と同様、syswrite を使います。

pkey, cert = get_cert(ca_pkey, ca_cert, host)

サーバの自前秘密鍵と証明書を取得しています。このメソッドは先程上で用意したものです。

  ctxt = OpenSSL::SSL::SSLContext.new('TLSv1_2_server')
  ctxt.cert = cert
  ctxt.key = pkey

ブラウザと TLS ハンドシェイクするための準備です。 TLS のバージョン、証明書、秘密鍵をセットしています。

サーバ側としてハンドシェイクするので、少々手間がかかります。

  sock = OpenSSL::SSL::SSLSocket.new(sock, ctxt)
  sock.accept

ソケットを OpenSSL に渡して、ハンドシェイクしています。

以上で、プロキシ-ブラウザとプロキシ-サーバが TLS で接続できました。 ここから先は、ソケット間の橋渡しをしているだけです。

      rs = IO.select([sock, cs])

ブラウザ側接続またはサーバ側接続のどちらかからデータを読めるようになるまで待ちます。

      rs = rs.first.first

読めるようになったら、この場所にソケットが入っていますので、取り出します。

      if rs == sock
        buf = sock.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        cs.syswrite(buf)

ブラウザ側ソケットから読めるようになった場合の処理です。 最大 1024バイト読んで、EOF でなければサーバ側ソケットへ送ります。

      elsif rs == cs
        buf = cs.sysread(1024)
        if buf.nil?    # EOF
          break
        end
        sock.syswrite(buf)

逆向きの場合の処理です。

いずれかが EOF になった場合は、ループを抜け、プロセスを終了します。 その時に接続が切れます。

試す!

ブラウザで試せばいいのでしょうが、CA 証明書をブラウザに設定する必要がある等、 手間ですので、今回は curl で試します。

https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/

プロキシの場所を環境変数で渡しています。

--cacert ./ca.crt で CA 証明書を指定しています。この ca.crt は MITM プロキシが 生成した CA 証明書のファイル名です。

これで試したところ、

luna:mitm-proxy % https_proxy=http://localhost:8000 curl --cacert ./ca.crt https://www.masm11.me/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
(以下略)

通信できました!

まとめ

以上、MITM プロキシの説明をしてみました。

最後に説明したループの中で、buf には通信内容が平文で入っています。

今回はブラウザではテストしませんでした。実はブラウザでのテストは大変なのです。 serial が戻ってはいけないし、同じ serial で別の証明書ではいけないし...

インゲージではエンジニアを募集しています。 こんな話題に花を咲かせたいそこのあなた! ぜひうちで働きませんか? (業務ではこんなコードは書きませんが・・) 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!

Rails 5.2 で Rails 6 の credentials 機能を使う

f:id:masm11:20210205220317p:plain

こんにちは、masm11 です。 今回は久しぶりに Rails について書きたいと思います。

Rails 5.1~5.2 で credentials 機能が一新され、 credentials.yml.enc と master.key になりましたね。 そして Rails 6 ではそれを環境ごとに用意できるようになりました。

ただ、もろもろの事情でもうしばらく Rails 5.2 から離れられそうにないので、 Rails 5.2 で Rails 6 のこの機能を使う方法を探してみました。

1. 各ファイルを作る

とりあえず、

bin/rails credentials:edit

と実行すると、config/credentials.yml.encconfig/master.key が 作成されます。

これを config/credentials/ に置きます。

mv config/credentials.yml.enc config/credentials/development.yml.enc
mv config/master.key config/credentials/development.key

以上を development, test, staging, production と、必要なだけ繰り返します。

そして、.gitignore

/config/credentials/*.key

の一行を追加します。*.key は暗号化/復号に使う鍵なので、公開しないようにしましょう。

2. Rails 6 の機能を backport

以下に backport したものが公開されています。

https://gist.github.com/palkan/e27e4885535ff25753aefce45378e0cb

このページに書いてあるとおりに進めます。

  • backport_rails_six_credentials.rb をダウンロードして lib/ に置く。
  • backport_rails_six_credentials_command.rb をダウンロードして lib/ に置く。
  • config/application.rbrequire "rails" っぽい行の下に以下の行を追加。
require_relative '../lib/backport_rails_six_credentials'
  • config/boot.rbrequire "bundler/setup" の下に以下の行を追加。
require_relative '../lib/backport_rails_six_credentials_command'

config/environments/*.rb に以下を追加します。

config.require_master_key = true

3. コマンドを使う

config/credentials/development.yml.enc は暗号化されているので、 そのままでは中身を見ることはできません。中身を表示するには以下のようにします。

bin/rails credentials:show --environment=development

編集するには以下のようにします。環境変数 EDITOR に指定してあるエディタが起動します。

bin/rails credentials:edit --environment=development

例えば、今まで secrets.yml が

development:
  foo: 123
  bar: 456
production:
  foo: 789
  bar: 012

だったなら、--environment=development では

foo: 123
bar: 456

と書き、--environment=production では

foo: 789
bar: 012

と書きます。

4. コード内から使う

Rails.application.credentials.foo とかで読み出せます。

もし、

foo:
  bar: 123

とネストしている場合は、Rails.application.credentials.foo[:bar] です。

まとめ

以上、Rails 6 の credentials 機能を Rails 5.2 で使う方法をご紹介しました。

インゲージではエンジニアを募集中です。詳しくは以下のページへ!

https://ingage.co.jp/recruit

ではまた!

Exchange Online の先進認証に対応しました(3)

f:id:kizashi1122:20201110142108j:plain

id:kizashi1122 です。 前回の記事から2ヶ月が経ってしまいました。 続きを書きましょう。

blog.ingage.jp

時間がだいぶ経ったので、ここで目的と前回までのまとめを整理しておきます。

目的

目的は、「Exchange Online の POP3/SMTP を利用する際に、パスワードではなく先進認証で得られたトークンを使う」です。 Rest API は使いません。

前回までのまとめ

  • Azure AD 側に POP3/SMTP 利用のための設定をおこなう
  • Rails で OAuth ができるところまでもっていく

というところまでです。アクセストークンがゲットできて、手元にあるという状態になります。

SMTP / POP3 にトークンを使ってアクセスする

そんなことができるのかと思いますができます。 Gmail では以下のページに書かれています。大変わかりやすいです。IMAP/POP3/SMTP それぞれの認証の仕方が書かれています。

developers.google.com

SASL XOAUTH2 という方式があるのですね。

次に、Exchange Online のドキュメントを確認します。

docs.microsoft.com

なんだみんなこの SASL XOAUTH2に則って作ってるのか、じゃあGmailもExchange Onlineも同じだなと思うとハマります

SASL XOAUTH2 format と言われる、ユーザ名とアクセストークンを使って一つの文字列を作る、これは同じなんです。これはいいのです。

base64("user=" {User} "^Aauth=Bearer " {Access Token} "^A^A")

実際の認証部分が大事なのです。

SMTP の認証

Gmail のドキュメントでは以下のようなサンプルが書かれています。

[connection begins]
S: 220 mx.google.com ESMTP 12sm2095603fks.9
C: EHLO sender.example.com
S: 250-mx.google.com at your service, [172.31.135.47]
S: 250-SIZE 35651584
S: 250-8BITMIME
S: 250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
S: 250-ENHANCEDSTATUSCODES
S: 250 PIPELINING
C: AUTH XOAUTH2 dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==
S: 235 2.7.0 Accepted
[connection continues...]

本家ページ上では改行されていますが、注釈に改行は実際はないよと書いているのでここでは改行は削除してます。

なるほど、つまり認証コマンドを投げるときは

AUTH XOAUTH2 <SASL XOAUTH2 フォーマットで生成した文字列>

を発行するんだなと。当然、Exchange Online も同じだろって思うんです。ですが、違います。 Exchange Online のドキュメントにはこう書かれています。

[connection begins]
C: auth xoauth2
S: 334
C: dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==
S: 235 2.7.0 Authentication successful
[connection continues...]

本家では読みやすさのための改行だったので、ここでは改行は削除しています。

Exchange Online では Gmail と違って認証のために2回コマンドを投げる必要があることがわかります。

AUTH XOAUTH2

dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==

の2回に分ける必要があるんです。

なんやねん!

せっかく処理を共通化できると思ったのに分岐させる必要がでてくるわけです。

POP3 の認証

想像に難くないですが、POP3 についても、Gmail と Exchange Online には同じような差があります。

Gmail。

[connection begins]
C: AUTH XOAUTH2 dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYX
JlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0
Q2cBAQ==
S: +OK Welcome.
[connection continues...]

1行。

Exchange Online。

[connection begins] 
C: AUTH XOAUTH2     
S: +    
C: dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYX   
JlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0  
Q2cBAQ==    
S: +OK User successfully authenticated. 
[connection continues...]

2行(2回)

この個性はハマりました。
その個性いるかね?と思います。

インゲージではこんなことにもめげないエンジニアを募集しています。 ご応募お待ちしてます。