Supabase で TCE（透過的列暗号化）を軽く試してみた

こちらは **[PostgreSQL Advent Calendar 2022](https://qiita.com/advent-calendar/2022/postgresql)** カレンダー 2 枚目・8 日目の記事です。

もともと 12 日目のネタを書こうとして、1 つの記事にすると長くなりそうだったので分割しようと思い、急遽 2 枚目のカレンダーを作ってしまいました。

PostgreSQL の Extensions（拡張機能）の中に **[pgsodium](https://github.com/michelp/pgsodium)** というものがあり、**[Supabase](https://supabase.io/)** ではこれを使って透過的列暗号化が利用できるようなので、まずは簡単に試してみました（そしてハマりました）。

:::note
[Supabase](https://supabase.io/) は Firebase Alternative(s) の 1 つで、PostgreSQL を利用した BaaS / mBaaS です。
（セルフホスティングも可能です） 
:::

## ざっくり、どんな機能？

**[こちら](https://supabase.com/blog/transparent-column-encryption-with-postgres)** のブログ記事には、

https://supabase.com/blog/transparent-column-encryption-with-postgres

- 任意の数のテーブルの 1 つまたは複数の`text`（または`bytea`）列を暗号化可能
- データを暗号化してログに漏らさないようにできる
- ユーザーに行レベルの暗号化を提供することが可能

と書かれています。

:::note
「ログ」は WAL のことでしょう。
:::

## やってみる

まずは、この[ブログ記事](https://supabase.com/blog/transparent-column-encryption-with-postgres)に（概ね）沿って試してみます。

:::note
ちょっとだけテーブル名・列名などをアレンジしています。
:::

### [pgsodium](https://github.com/michelp/pgsodium) を有効化する

有効になっていない場合は、「Database」→「Extensions」で有効化します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/50acdc32-1ff4-77c7-2892-281b5c042a92.png)

### 鍵を生成し、鍵 ID を取得する

「SQL Editor」で生成するための SQL 文を流します。

:::note
鍵の導出についての説明は、先の[ブログ記事](https://supabase.com/blog/transparent-column-encryption-with-postgres#how-key-derivation-works)をご覧ください。ここでは省略します。
:::

```sql:鍵生成(1)
select * from pgsodium.create_key();
```

:::note warn
`SELECT`ですが何度も実行するとたくさん生成されてしまうので気をつけましょう。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/f27d166a-ab71-91f2-0576-db5d87686e77.png)

`id`列の値をコピーします。

### 対象列について、テーブル内の全行分を 1 つの鍵 ID で暗号化してみる

:::note warn
**2022/12/18 追記：**
2022/12/16 の[ブログ記事](https://supabase.com/blog/vault-now-in-beta#transparent-column-encryption-tce)を見ると、Table Editor から設定できるようになったようです。
（私のプロジェクトでは対象項目が表示されませんでした）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/e29d759a-48ab-b161-ad7c-b243efd69822.png)
（同ブログ記事の画像を引用）
:::

最も単純なパターンです。

```sql:全行同じ鍵IDを使う
CREATE TABLE credit_cards (
	id bigserial primary key,
  	credit_card_number text,
	key_id uuid not null DEFAULT 'f3c621ed-d16c-431d-8c8d-c21904a0cb0f'::uuid
);

SECURITY LABEL FOR pgsodium
	ON COLUMN credit_cards.credit_card_number
  	IS 'ENCRYPT WITH KEY COLUMN key_id';

INSERT INTO credit_cards (credit_card_number) VALUES ('1234567890123456');
INSERT INTO credit_cards (credit_card_number) VALUES ('1234567890123456');
INSERT INTO credit_cards (credit_card_number) VALUES ('1234567890123456');
```

`SECURITY LABEL`という、見慣れないものが使われています。

試しに検索してみたところ、なんと海外さんの 10 年前のアドベントカレンダー記事が出てきました（笑）。

- **[SE-PostgreSQLでリモートプロセスのセキュリティラベルを使用する](https://kaigai.hatenablog.com/entry/20121206/1354771579)**

https://kaigai.hatenablog.com/entry/20121206/1354771579

本題に戻って…`INSERT`された行を「Table Editor」から（普通に）見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/dd8dd3c4-27f4-6b03-d137-a83d4bc44947.png)

暗号化後の列値（`credit_card_number`）が全行同じですね。

そしてこれを、透過的に復号するビューで確認してみます。

```sql:ビューで復号(1)
select * from pgsodium_masks.credit_cards;
```

:::note warn
先の[ブログ記事](https://supabase.com/blog/transparent-column-encryption-with-postgres#using-an-encrypted-table)では`decrypted_credit_cards`ビューを使うように書かれていますが（2022/12/7 現在）、手元で試した限りでは`pgsodium_masks.credit_cards`が正しいようです。
:::

:::note
**2022/12/9 追記：**
TABLE Editor で schema として`pgsodium_masks`を選択することで`credit_cards`ビューを見ることもできます（以降同様）。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/990f4479-7c88-5e26-5aa8-b6de043a19fd.png)

`decrypted_credit_card_number`列に復号後の値が見えますね。

### 対象列について、1 行ごとに鍵 ID を変えて暗号化してみる

先の例の場合、暗号化した後の文字列が全て同じになりました。

この状態で、ある特定の行の暗号が解読された場合、 **同じ（暗号化後の）列値を持つ行が別に存在すると、その行の（暗号化前の）列値も同じである** ことが簡単にわかってしまいます。

というわけで、今度は行ごとに鍵 ID を変えて暗号化してみます（`NONCE`を追加）。

ここで、1 つ目のテーブルを残したまま、2 つ目のテーブルを作ろうと思い、

```sql:鍵生成(2)
select * from pgsodium.create_key();
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/c58a3b46-6b58-bb9c-9c5c-bc1de0ea5d36.png)

```sql:全行別の鍵IDを使う
CREATE TABLE credit_cards_2 (
	id bigserial primary key,
  	credit_card_number_2 text,
	key_id uuid not null DEFAULT '045d3605-b39f-495c-8b87-4b334837d6ad'::uuid,
  	nonce bytea default pgsodium.crypto_aead_det_noncegen()
);

SECURITY LABEL FOR pgsodium
	ON COLUMN credit_cards_2.credit_card_number_2
  	IS 'ENCRYPT WITH KEY COLUMN key_id NONCE nonce';

INSERT INTO credit_cards_2 (credit_card_number_2) VALUES ('1234567890123456');
INSERT INTO credit_cards_2 (credit_card_number_2) VALUES ('1234567890123456');
INSERT INTO credit_cards_2 (credit_card_number_2) VALUES ('1234567890123456');
```

としてみたところ、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/40a746d2-0d84-25ea-f8a5-999caba10cff.png)

怒られてしまいました。

:::note
SQL Editor に書いた SQL 文のインデントが一部荒ぶっていますが、気にしないでください。以降同様。
:::

使い方が間違っているだけなのかもしれませんが、試した範囲では同時に複数のテーブルに対して暗号化列を作成できませんでした。

というわけで、仕方なく最初のテーブルを消そうとしたら、また怒られました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/0218919e-2da1-f48c-0938-12f3fdbb7870.png)

他に正しい方法があるかもしれませんが、ワークアラウンド的に先ほどの復号用ビューを先に消してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/cea8c749-d992-c847-4c9a-c9e3b486dde5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/94c1bf8a-ddcd-25a0-aed9-c36de5e69b45.png)

今度はうまく消せました。

気を取り直して、`CREATE TABLE`再チャレンジです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/83189f08-a274-3a98-12b4-fab7ea5e85ae.png)

今度は問題なく作成できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/9a818f07-c54a-6a45-a897-bfb9f8b18883.png)

暗号化後の列値（`credit_card_number_2`）が行ごとに違う値になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/63044df2-0f76-22ad-c4af-5da185e7269d.png)

復号用のビューから見てみると、暗号化前の列値が表示されました。

### 別の列を関連付けて暗号化する

例えば「A さんのカード番号は XXX」のように、人とカード番号の組み合わせを暗号化の処理（鍵 ID）に紐づけることによって、列値の取り違えや（悪意を持った人による）すり替えなどを防ぎたいケースもあるでしょう。

:::note
行レベルセキュリティー（RLS）と組み合わせることによってそれを防止することも考えられますが、ここでは暗号化を使うケースで考えます。
もちろん、両者を組み合わせて使うこともできるはずです（というか、通常は RLS が必須で、列暗号化は RLS と組み合わせて使うべきでしょう）。
:::

まずは先ほどと同様に 2 つ目のテーブル（と復号用のビュー）を削除します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/64b3efe3-577c-63aa-f324-1e85c9d325cc.png)

そして新たな鍵 ID を発行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/2c78a0b1-7ac6-5009-5a91-13d8b991222f.png)

これを使って新たなテーブルを作ります。

```sql:accound_idを関連付けて暗号化
CREATE TABLE credit_cards_3 (
	id bigserial primary key,
  	credit_card_number_3 text,
  	account_id integer,
	key_id uuid not null DEFAULT '18a31832-6216-4b23-ada6-16b9ac82b16a'::uuid,
  	nonce bytea default pgsodium.crypto_aead_det_noncegen()
);

SECURITY LABEL FOR pgsodium
	ON COLUMN credit_cards_3.credit_card_number_3
  	IS 'ENCRYPT WITH KEY COLUMN key_id ASSOCIATED (account_id) NONCE nonce';

INSERT INTO credit_cards_3 (
	credit_card_number_3,
	account_id
)
VALUES
    ('1234567890123456', 123);
INSERT INTO credit_cards_3 (
	credit_card_number_3,
	account_id
)
VALUES
    ('7890123456789012', 456);
INSERT INTO credit_cards_3 (
	credit_card_number_3,
	account_id
)
VALUES
    ('3456789012345678', 789);
```

`ASSOCIATED (account_id)`が前回と異なる部分です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/b3f16df7-130e-9cf2-4812-0e20bfa97193.png)

テーブルの中身を見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/63f04fde-c64d-68af-0f67-f53dfc7d7c81.png)

復号用のビューを見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/868fa494-0a2f-eeb8-ca85-7d4c3cc5aeaf.png)

特に問題なく復号できました。

## 余談

ツイートでぼやいたら、中の人から **「来週またブログ記事とちゃんとしたドキュメント出すね」** というリプをいただきました。

https://twitter.com/kiwicopple/status/1600212478004121600

---

次回、1 枚目のカレンダーの 12 日目の記事で、アプリケーションからの利用を試してみます。

- **[Supabase で TCE（透過的列暗号化）をアプリケーションから使ってみた](https://qiita.com/hmatsu47/items/d3cf24f0e462628cd700)**

https://qiita.com/hmatsu47/items/d3cf24f0e462628cd700