3 つの異なる操作
Base64 は、保護されていると思われがちな場所にたびたび登場するので、何をしているのかを正確に押さえておく価値があります。Base64 はエンコーディング、つまりバイト列からテキストへの固定された公開の対応付けです(Base64 の仕組みを参照)。この対応付けには鍵も秘密もなく、同じ入力からは常に同じ出力が得られます。
| エンコード(Base64、16進数) | 暗号化(AES-GCM) | ハッシュ(SHA-256、Argon2) | |
|---|---|---|---|
| 目的 | バイト列をテキストで表す | データを秘匿する | データの指紋を取る・検証する |
| 元に戻せるか | はい、誰でも | はい、鍵があれば | いいえ |
| 鍵が必要か | いいえ | はい | いいえ(HMAC では使う) |
| 同じ入力から同じ出力か | 常に同じ | いいえ(ランダムなノンスを使う場合) | はい(ソルトを使わない限り) |
| 出力サイズ | 入力の約 4/3 | 入力 + ノンスとタグ | 固定長 |
デコードには何の手間もかかりません。
echo ZGVwbG95Omh1bnRlcjI= | base64 -d
# deploy:hunter2
ちらっと見ただけの人には、この文字列は意味不明に見えます。しかしスクリプトやログスキャナー、攻撃者にとっては、手順が 1 つ増えただけの平文です。
Base64 のデータが実際に漏れる経路
パターンは毎回同じです。運ぶために Base64 エンコードした値を誰かが保護されたものと勘違いし、読める場所に置いてしまいます。以下はこの勘違いのよくある形です。
HTTP Basic 認証のヘッダー
Basic 認証(RFC 7617)は、ユーザー名とパスワードを Base64(user:password) としてリクエストのたびに送ります。
Authorization: Basic ZGVwbG95Omh1bnRlcjI=
保護は接続の TLS だけです。ヘッダーがそのトンネルを出た時点で、パスワードは事実上平文になります。よくあるのは、リクエストヘッダーをログに記録するリバースプロキシやアプリケーションフレームワーク、ブラウザの開発者ツールからエクスポートしてサポートチケットに添付した HAR ファイル、チャットに貼り付けた curl -v の出力、リクエスト全体を記録するエラートラッキングツールなどです。Basic 認証デコーダーを使えば、読むのにどれほど手間がかからないかがわかります。
Kubernetes の Secret
Kubernetes の Secret は値を Base64 エンコードして保存します。
apiVersion: v1
kind: Secret
metadata:
name: db
data:
password: czNjcjN0
czNjcjN0 は s3cr3t です。このエンコードは、YAML の中にバイナリデータを持てるようにするためのものです。Kubernetes のドキュメントには、Secret はデフォルトで etcd に暗号化されずに保存され、API で Secret を get できる人は誰でも読めると書かれています。よくある漏えいは、実際の data: の値を含むマニフェストを Git リポジトリにコミットすること、kubectl get secret -o yaml の出力をチケットに貼り付けること、etcd やクラスター状態のバックアップを暗号化せずに保存することです。Kubernetes Secret デコーダーは Secret のすべての値を一度にデコードするので、マニフェストが実際に何を露出しているかをすばやく確認する手段にもなります。
コンテナレジストリの認証情報も同じパターンです。Docker の config.json や .dockerconfigjson の Secret にある auth フィールドは Base64(user:password) で、dockerconfigjson デコーダーで読めます。
JWT のペイロード
署名付きの JWT は封印されたトークンのように見えますが、最初の 2 つの部分はただの Base64URL の JSON です。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImVtYWlsIjoiYW5hQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIn0.<signature>
ペイロードをデコードすると {"sub":"42","email":"ana@example.com","role":"admin"} になります。署名はトークンを改ざんから守りますが、秘匿性には何の役にも立ちません。トークンを目にするブラウザ拡張機能、プロキシ、ログファイル、アクセス解析のスクリプトのすべてが中身を読めます。個人情報、公開したくない内部 ID、秘密にすべきものは JWT のペイロードに入れてはいけません。どうしてもトークンで運ぶ必要があるなら、暗号化された JWT(JWE)を使ってください。詳しくは JWT での Base64 をご覧ください。
コードや設定で「難読化」されたシークレット
フロントエンドの JavaScript、モバイルアプリのバンドル、設定ファイルに Base64 で保存された API キーは定番です。よくある理屈は「平文」にならないから、というものです。確かに鍵そのものを単純に検索しても引っかからなくはなりますが、効果はそれだけです。クライアントに配布したものは、そのクライアントで読めます。
攻撃では同じ考え方が逆向きに使われます。PowerShell の -EncodedCommand は UTF-16LE テキストの Base64 を受け取り、悪意のあるコマンドを一見して読みにくくするためによく使われます。防御側はこれを数秒でデコードします。たとえば PowerShell エンコードコマンドのデコーダーを使えば簡単です。
代わりに使うべきもの
目的に合わせて道具を選んでください。以下のどの場合でも Base64 には出番があります。別の仕組みで保護されたバイト列を包むテキストの入れ物としてです。
データを秘匿するには:認証付き暗号
よくメンテナンスされたライブラリを通じて、AES-256-GCM か ChaCha20-Poly1305 を使ってください。どちらも認証付きなので、復号時に改ざんが検出されます。アルゴリズムより大切なルールが 2 つあります。同じ鍵でノンスを再利用しないこと、そして鍵をデータの隣以外の場所(KMS、シークレットマネージャー、攻撃者が同時には支配できない環境)に置くことです。
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
const key = randomBytes(32); // in real code, load this from a KMS or secret store
const iv = randomBytes(12); // 96-bit nonce, unique per message
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update("deploy:hunter2", "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Base64 carries the encrypted bytes as text: nonce + tag + ciphertext
const token = Buffer.concat([iv, tag, ct]).toString("base64");
const raw = Buffer.from(token, "base64");
const d = createDecipheriv("aes-256-gcm", key, raw.subarray(0, 12));
d.setAuthTag(raw.subarray(12, 28));
console.log(Buffer.concat([d.update(raw.subarray(28)), d.final()]).toString("utf8"));
// deploy:hunter2
出力は 56 文字の Base64 文字列で、ノンスがランダムなので実行するたびに変わります。鍵がなければ役に立ちません。コードを書かずに試すなら、AES 暗号化と AES 復号のツールがブラウザ内で動作します。
パスワードを保存するには:低速でソルト付きのハッシュ
パスワードは暗号化もエンコードもすべきではありません。パスワード用に設計された関数、つまり Argon2id、scrypt、bcrypt によるハッシュを、パスワードごとに一意のランダムなソルトを付けて保存してください。パスワードの単純な SHA-256 は計算が速すぎて、総当たり攻撃に負けます。Python の標準ライブラリには scrypt があります。
import hashlib, hmac, os
def hash_password(pw: str) -> bytes:
salt = os.urandom(16)
return salt + hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1)
def check_password(pw: str, stored: bytes) -> bool:
salt, digest = stored[:16], stored[16:]
candidate = hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1)
return hmac.compare_digest(candidate, digest)
結果をテキストのカラムや JSON に入れる必要があるなら、Base64 にしてください。
完全性を証明するには:署名と HMAC
目的が「誰にも読まれない」ではなく「誰にも変更されていない」なら、共有シークレットを使う HMAC か、デジタル署名を使ってください。結果はバイト列で、通常は Base64 か 16進数で運びます。HMAC ジェネレーターは出力を両方の形式で表示します。
送信中・保存中の認証情報には
Basic 認証やトークンを運ぶすべての接続で TLS を使い、固定のパスワードより有効期限の短いトークンを優先し、ログから Authorization ヘッダーを取り除き、Kubernetes の Secret の保存時暗号化を有効にし、本物のシークレットの値を Git に入れないでください。
よくある質問
Base64 は安全ですか?
Base64 にはセキュリティ上の性質が一切ありません。鍵がないので、デコードには文字列そのもの以外何も要りません。暗号文や署名のように、すでに保護されたデータを運ぶ形式として使うのは問題ありません。
設定ファイルのパスワードを隠すのに Base64 で十分ですか?
いいえ。ファイルを読める人なら誰でも base64 -d でパスワードをデコードできます。ファイルの権限を制限するか、シークレットマネージャーを使うか、別の場所に保管した鍵で値を暗号化してください。
セキュリティのためでないなら、Kubernetes の Secret はなぜ Base64 エンコードされているのですか?
TLS の鍵やキーストアのような任意のバイナリデータを、YAML や JSON の中で Secret の値として持てるようにするためです。保護は RBAC、etcd の保存時暗号化、マニフェストの慎重な取り扱いによって実現します。
シークレットがなくても JWT のペイロードは誰でも読めますか?
はい。ヘッダーとペイロードは Base64URL の JSON で、誰でも読めます。鍵が必要なのは署名の検証と作成だけです。JWT デコーダーで試してみてください。
エンコード、暗号化、ハッシュの違いは何ですか?
エンコードは誰でも元に戻せます。暗号化は鍵があるときだけ元に戻せます。ハッシュは一方向で、値がハッシュと一致するかは確認できますが、ハッシュから値に戻すことはできません。