SOPS と age で暗号化されたファイルを Spring Boot から読み込む

SOPSage が何をするものなのか、はここでは説明しない。

また、公式ドキュメントを読み込んで理解したというより、手探りでやってみたことから推測しているだけなので、もし間違いがあればご指摘ください。

この文書、および サンプルプロジェクト ともに暗号化文書としては YAML のみ取り扱っているが、 JSON においてもほぼ同様に対応できると思われる。

github.com

SOPS で暗号化されたファイルの構造

サンプルファイルとして vault.yaml を挙げる。

暗号文の構造

暗号化された文の部分の構造はわりと自明で、

password: ENC[AES256_GCM,data:PSyc......Ofw=,tag:oMXi5h7kOSOx3oGQVqm67A==,type:str]

のようになっており、以下の情報が詰め込まれている。

  • AES256_GCM - 暗号化アルゴリズムは AES (鍵長 256 bit) の GCM モードである
  • data は暗号文
  • iv は IV (初期化ベクトル)
  • tagMAC (Message Authentication Code)
    • GCM モードに独特
  • type は元の値の型
    • ここでは str (文字列) であることを示しており、ほかに bool, int, float などがある

暗号文の AAD

GCM モードの場合、暗号化の際 AAD (追加認証データ) を指定することができ、整合性チェックに利用できる。

SOPS で暗号化された値には AAD が付与されており、復号時に AAD をきちんと指定しないと不整合エラーとなってしまう。

SOPS における暗号文の AAD はちょっと独特である。

基本的には tree の path を : でつないで、末尾に : を付与した形式となる。 たとえば上記の password フィールドの場合、 "password:" である。

ただし、配列の場合は配列の添え字 (数値) については無視する。

具体的には、

root: secret
first-level:
  second-level:
    third-level: secret
    arrays:
      - secret
      - secret
    objects:
      - key: secret
         value: secret
      - key: secret
         value: secret

のような場合、 AAD はそれぞれ

  • root の secret → root:
  • first-level.second-level.third-level の secret → first-level:second-level:third-level:
  • first-level.second-level.arrays[0] の secret → first-level:second-level:arrays:
  • first-level.second-level.arrays[1] の secret → first-level:second-level:arrays:
  • first-level.second-level.objects[0].key の secret → first-level:second-level:objects:key:
  • first-level.second-level.objects[1].value の secret → first-level:second-level:objects:value:

のようになる。

暗号化鍵

暗号文は age のキーで直接暗号化されている、わけではない。

当該ファイルで共通に利用される DEK (Data Encryption Key) で暗号化されており、おのおのの (age) key を KEK (Key Encryption Key) として暗号化した DEK が SOPS ファイルに記録されている。

このように DEK と KEK を分離することで、異なる (age) key をもつ人々の間で暗号文のやりとりができるし、また age に限らず多様な鍵プロバイダの利用者ともやりとりができることになる。

さらに age key を変更することなく DEK のキーローテーションも可能となっている。

age に限った話をすると、age公開鍵暗号方式を利用している。 KEK としては公開鍵のほうを利用するので、共同利用者として登録してもらうためには公開鍵だけわたせばよい (公開鍵だけでは DEK の復号は不可能)。

age の場合、 sops.ageKEK (の公開鍵) と、それによって暗号化された DEK が記録されている。

sops:
    age:
        - recipient: age10v42a8geamzvv6c0p6aakw2s5u24vhwl3uhged05fepxgfgewytq7eh98m
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBISWs4R1dZelJLeTNxMkRp
            WlhTa20wM3h0NTV1LzRzaWZNangxMW0vN1Q4CllMYWZaYjBOTjlLNytFN2dMdTBH
            V25lZURHK3JSMlpNNzRHZy94ajl6dVUKLS0tIGg1RXRLcG5ETVNEWi9zYTMzQWVF
            a0ZFd2VjZ2VrYlhrTTBZc1NxZWVEWXcKZGeB3UxdzFSmgRk68DiZ+i3Miw3sA5Vt
            Z9olYHBY3tZ98o1h/yzD1vMQUAK8bEPH3n7xAnEv2EnHT9WpRUsWgw==
            -----END AGE ENCRYPTED FILE-----

この場合は一件しか登録されていないが、 sops.age.[].recipientKEK (age の公開鍵) であり、 sops.age.[].enc が、それによって暗号化された DEK である。

(上記の構造からわかるとおり、先にあげたように、複数の recipients を指定することが可能である)

したがって、対応する age秘密鍵を保持している場合、 age コマンドを使うことで (生の) DEK を導出することができる。

$ age --decrypt -i keys.txt -o dek.bin dek.enc

暗号化対象プロパティ

sops:
    version: 3.9.4
    unencrypted_suffix: _unencrypted

sops.version はいうまでもなく SOPS 暗号ファイルのバージョンを示す。

sops.unencrypted_suffix は、元文書で暗号化の対象外となる "キー" のうち、暗号化の対象としないキーを示す。 sops コマンド実行時になにも指定しなかった場合、 _unencryptedunencrypted_suffix となる。

具体的には

foo: encrypted
bar:
  key: encrypted
  value_unencrypted: plain
baz_unencrypted:
  key: plain
  value_unencrypted: plain  

のように、 *_unencrypted となっているノードの子孫についても暗号化の対象外となる。

unencrypted_suffix だけではなく、以下の4つの指定子が存在する。

  • unencrypted_suffix
  • encrypted_suffix
  • unencrypted_regex
  • encrypted_regex

詳細については 公式ドキュメントの Encrypting only parts of a file 項 を参照してほしい。

全体の MAC

sops:
    lastmodified: "2025-02-16T06:24:31Z"
    mac: ENC[AES256_GCM,data:fAu8......yw==,type:str]

sops.lastmodified はファイルの最終更新日時を示す。

sops.mac はファイル全体の MAC (Message Authentication Code) を示す。 これにより、ファイルの一部の書き換え・削除や暗号文のおきかえ・コピー (たとえば foo.bar の暗号文を hoge.fuga にコピーする) といった改ざんを検出できる。

具体的には、各ノードの値を連結して DEK で暗号化した結果……だったと思うが、コードを読んだのがだいぶ前なので忘れてしまいました。

sops コマンドによる暗号化の際に --mac-only-encrypted オプションを付与すると、 (暗号化対象外も含めた) 全ノードではなく、暗号化対象ノードのみとなる、らしい。

くわしくは 公式ドキュメントの Message Authentication Code 項 を参照のこと。

ちなみに sops.mac の AAD (追加認証データ) は sops:mac: ではなく、上記の sops.lastmodified の値となる。

Spring Boot で SOPS (+age) 暗号文書を読み込む

JavaScript / TypeScript の場合 SOPS 暗号文書を読み込むライブラリがあるようだが (sops-age npm module, GitHub repository) JVM 言語用ライブラリは (自力で読み込むものが) ぱっと見当たらなかったので、上記の解析結果をもとに自力で書いてみた。

github.com

復号方法

age による復号 (および暗号化) については jagged というすばらしいライブラリがあるので、それを利用した。

SOPS (というより AES256-GCM) の復号については、 Java 標準の javax.crypto を利用している。

javax.crypto の場合、 cipher.update() に暗号化文と tag の双方をあわせて入れる必要がある。

application properties としての読み込み

Spring アプリケーションから properties として読み込むためには、まず java.org.springframework.core.env.PropertySource (@PropertySource とは別物) を定義する。 (SopsVaultPropertySource.kt)

この独自 PropertySource で SOPS 暗号文書を復号して(SopsVault.kt) properties を返すことにする。 (どうでもよいが、この springframework.core.env.PropertySource が型パラメータを必要とすることが納得いかない)

アプリケーション起動時に自動的に読み込まれるようにするためには、さらに、

手順を踏むことがプラガブルにする上でまっとうなやりかただと思う (くわしくは SpringBoot 標準の RandomValuePropertySource や、その EnvironmentPostProcessor 指定 が参考になる)。

ただこの方法だと PropertySource を configurable にするのがいささか難しいので (起動時に設定したいので、 configuration として application.properties を原則利用できない)、 (Spring Boot ではなく) Spring FrameworkApplicationContextInitializer<ConfigurableApplicationContext> を利用することにした。

github.com

具体的には ApplicationContextInitializerinitialize()environmentpropertySources (の先頭) に追加している。

これにより、プログラマティカルに設定をしやすくなったと思う。

ただ、 Spring Boot の application main で以下のように initializer を指定する必要がある。

    SpringApplicationBuilder(ExampleApplication::class.java)
        .initializers(SopsVaultApplicationContextInitializer())
        .run(*args)

任意の SOPS 暗号文書を application properties の元となる PropertySource として読み込むこともできるはずだが、 IntelliJ (Ultimate) の properties の補完等がうまくいかない可能性が高い。

このため、サンプルプロジェクトでは vault.* として SOPS 暗号文書を読み込み、実際の properties としてはその値を Property Placeholder に読み込むような利用方法としている。

app:
  password: ${vault.password}

(https://github.com/dayflower/sops-vault-example/blob/main/src/main/resources/application.yml#L3-L4)

placeholder は設定値の一部でも問題ないので、たとえばクレデンシャルが URI 等の一部に入っているケースでも活用できると思う。

おわりに

がんばって Spring Boot で直接読み込むサンプルを書いてみたが、コンテナ時代ではここまでやらなくても

  • initContainers で sops コマンドを利用して復号する
  • クレデンシャルのみ Secret にいれる
  • application properties 全体を Secret にいれる

などの方法で十分だと思う。