はじめに
前回の記事1では、IoT在庫管理デバイス「スマートマットライト」のサービス終了をきっかけに、クラウド連携機器がサポート終了とともに利用不能となる、いわゆる「文鎮化」の問題を取り上げました。デバイスの通信内容やデバッグログを解析し、ラズベリーパイをWi-Fiアクセスポイントとして構成変更したうえで、Python、Node.js、Node-REDによる代替サーバを実装することで、既存デバイスを継続利用する方法を紹介しました。サービス終了後のIoT機器を救済するための、実践的なハック事例としてまとめました。
しかし、「環境の変化」は、IoTサービスの終了だけではありません。インターネットのセキュリティ基盤もまた、継続的に変化しています。特にHTTPS通信を支えるSSL/TLS証明書の運用ルールは年々見直されており、証明書の最大有効期間は段階的に短縮される方向に進んでいます。
このような変化はセキュリティ向上につながる一方で、運用面では新たな課題を生みます。証明書の更新頻度が増えることで、更新忘れや設定ミスによるサービス停止のリスクが高まるためです。環境の変化に対応するためには、運用の仕組みそのものを見直す必要があります。
今回は、Google Apps Script(GAS)を利用したSSL証明書の有効期限監視を紹介します。証明書の有効期限を日次で確認し、期限が近づいた場合に通知する仕組みを構築することで、証明書期限切れによるサービス停止を未然に防ぐことができます。
SSL証明書とは
Webサービスでは、HTTPSによって通信内容を暗号化することが一般的になっています。
このHTTPS通信を支えているのがSSL/TLSサーバ証明書です。
SSL証明書には主に次の役割があります。
- 通信内容の暗号化
- サーバの正当性証明
- 中間者攻撃の防止
ユーザーがWebサイトへアクセスすると、ブラウザとサーバの間でTLSハンドシェイクが行われます。
このとき、サーバは自身のSSL証明書を提示し、ブラウザはそれを検証します。
証明書には次のような情報が含まれています。
- ドメイン名(Subject)
- 発行者(Issuer)
- 有効期間
- 公開鍵
特に重要なのが有効期間(NotBefore / NotAfter)です。
証明書の期限が切れると、ブラウザには警告が表示されます。
この状態になると、多くのユーザーはサイトへアクセスできなくなります。
つまり、証明書の期限切れはサービス停止に近い影響を持ちます。
SSL証明書の有効期間は短くなっている
SSL証明書の有効期間は年々短縮されています。
| 時期 | 最大有効期間 |
|---|---|
| 2012年頃 | 約5年 |
| 2015年 | 約3年 |
| 2018年 | 約2年 |
| 2020年 | 398日 |
さらにCA/Browser Forum2では、証明書の最大有効期間を段階的に短縮する方針が決定されています。
| 時期 | 最大有効期間 |
|---|---|
| 2026年 | 200日 |
| 2027年 | 100日 |
| 2029年 | 47日 |
最終的には約1.5か月ごとに証明書更新が必要になる可能性があります。
なぜ証明書の有効期間は短くなるのか
証明書の有効期間短縮の背景には、インターネットのセキュリティ運用を改善する目的があります。
量子コンピュータによる暗号解読への対策ではありません。
主な理由は次の4つです。
秘密鍵漏えいリスクを減らす
SSL証明書には秘密鍵が存在します。もし秘密鍵が漏えいすると、攻撃者は次のことが可能になります。
- サーバのなりすまし
- HTTPS通信の盗聴
- フィッシングサイトの構築
証明書の有効期間が長い場合、攻撃が長期間続く可能性があります。
証明書の寿命を短くすることで、秘密鍵漏えいの影響を短期間に抑えることができます。
証明書失効が完全ではない
秘密鍵が漏えいした場合、本来は証明書を失効(revocation)させます。
しかし実際のインターネットでは次の問題があります。
- OCSPサーバの障害
- ネットワーク制限
- ブラウザの失効チェック省略
つまり、失効システムは理論上は有効でも実運用では完全ではありません。
そのため現在では失効に頼るより証明書の寿命を短くするという考え方が採用されています。
証明書の誤発行の影響を抑える
過去にはCAによる誤発行事件が発生しています。
例えば、
- 誤ったドメインへの証明書発行
- CAの内部不正
- 認証プロセスのミス
証明書の寿命が長い場合、問題は長期間続きます。
しかし短期間証明書なら自然に期限切れとなり問題が収束します。
証明書運用の自動化が普及した
かつては証明書更新は手動作業でした。
CSR作成
↓
CAへ申請
↓
証明書取得
↓
サーバ設定
↓
期限前に手動更新
この作業は非常に手間がかかりました。
しかし現在はACME(Automated Certificate Management Environment)という仕組みにより証明書管理の自動化が可能になりました。
ACMEとは何か
ACMEはSSL証明書の取得・更新・失効を自動化するプロトコルです。
Let's Encryptプロジェクト3によって普及し、現在はRFC85554として標準化されています。
ACMEクライアントは次の処理を自動で実行します。
- 秘密鍵生成
- 証明書申請
- ドメイン認証
- 証明書取得
- 証明書更新
つまり、証明書管理をAPIベースで自動化する仕組みです。
代表的なACMEクライアントには次のものがあります。
| ソフト | 特徴 |
|---|---|
| certbot | Let's Encrypt公式 |
| acme.sh | 軽量 |
| lego | Go実装 |
ACMEにより証明書更新は次のように変わりました。
(従来)
証明書取得
↓
1〜3年放置
↓
手動更新
(現在)
ACMEクライアント
↓
証明書取得
↓
定期的に自動更新
Let's Encryptの証明書は90日間有効ですが、多くの環境では30日程度で自動更新されます。
このように証明書管理が自動化されたことで、短期間証明書でも運用負担が増えない環境が整いました。
その結果、証明書の有効期間短縮が進められています。
GASで証明書期限を監視する
証明書更新が自動化されていても、更新失敗の監視は依然として重要です。
今回は、Google Apps Scriptを利用して証明書期限を監視する仕組みを作成します。
処理の流れは次の通りです。
- 毎日証明書期限をチェック
- 残りXX日以内なら通知
- Gmailで警告メール送信
Google Apps Scriptには、SSL/TLS証明書を直接取得するAPIが用意されていません。
そのため本スクリプトでは公開証明書ログを検索できるサービスCertificate Transparencyログ5を利用します。
このAPIから取得した証明書の中から、NotAfterが最も新しい証明書を採用します。
Certificate Transparencyとは
Certificate Transparency(CT)は、公開証明書を透明化する仕組みです。
CAが発行した証明書は、CTログに公開されます。
この仕組みにより次のメリットがあります。
- 不正発行証明書の検出
- 証明書監査
- 証明書管理の可視化
Google Chromeでは、CTログに登録されていない証明書は警告対象となります。
今回のスクリプトでは、このCTログを利用することでサーバに直接接続せずに証明書情報を取得しています。
スクリプトの全体
/**
* SSL証明書期限チェック
* 有効期限のXX日前にメール通知
*/
// 送り先(固定アドレスを指定可能)
const TO_EMAIL = Session.getActiveUser().getEmail();
// 複数ホストを指定可能
const TARGETS = [
{ host: "api.example.com", port: 443, sni: "api.example.com",
label: "例: API" },
];
// 有効期限のXX日前(3日前)
const DAYS_BEFORE = 3;
// 通知重複防止キー
const PROP_PREFIX = "cert_notify_last_";
function checkCertAndNotify() {
const props = PropertiesService.getScriptProperties();
const now = new Date();
const results = TARGETS.map(t => {
const info = getServerCertInfo_(t.host, t.port || 443, t.sni || t.host);
const daysLeft = Math.floor((info.notAfter.getTime() - now.getTime())
/ (24 * 3600 * 1000));
return { ...t, ...info, daysLeft };
});
// 有効期限が近い、または、切れてるものだけ
const alerts = results.filter(r => r.daysLeft <= DAYS_BEFORE);
if (alerts.length === 0) return;
// 通知の重複防止(同一ホストは同日に1回だけ送る)
const toSend = [];
for (const a of alerts) {
const key = PROP_PREFIX + a.host + ":" + (a.port || 443);
const last = props.getProperty(key); // "YYYY-MM-DD"
const today = Utilities.formatDate(now, Session.getScriptTimeZone(),
"yyyy-MM-dd");
if (last !== today) {
toSend.push(a);
props.setProperty(key, today);
}
}
if (toSend.length === 0) return;
const subject =
`[SSL期限通知] ${toSend.length}件(残り${DAYS_BEFORE}日以下)`;
const body = [
`SSL証明書の期限が近づいています(残り${DAYS_BEFORE}日以下)`,
"",
...toSend.map(a => {
return [
`■ ${a.label || a.host}`,
`Host: ${a.host}:${a.port || 443} (SNI: ${a.sni || a.host})`,
`CN/Subject: ${a.subject}`,
`Issuer: ${a.issuer}`,
`NotAfter: ${a.notAfter.toISOString()}`,
`残り日数: ${a.daysLeft}日`,
"",
].join("\n");
}),
"(このメールはGASの定期チェックで自動送信されています)",
].join("\n");
GmailApp.sendEmail(TO_EMAIL, subject, body);
}
/**
* サーバ証明書(リーフ)のNotAfter/Issuer/Subjectを取得
* crt.sh で最新のリーフ証明書の not_after を取得する
*/
function getServerCertInfo_(host, port, sni) {
const url = `https://crt.sh/?q=${encodeURIComponent(sni)}&output=json`;
const res = UrlFetchApp.fetch(url, { muteHttpExceptions: true,
followRedirects: true });
if (res.getResponseCode() !== 200) {
throw new Error(`crt.sh fetch failed: HTTP ${res.getResponseCode()}
for ${sni}`);
}
const data = JSON.parse(res.getContentText());
// crt.sh は複数ヒットするので、該当ドメインを含み、not_after が最新のものを選ぶ
const candidates = data
.filter(x => (x && x.name_value))
.filter(x => {
const names = String(x.name_value).split("\n").map(s =>
s.trim().toLowerCase());
return names.includes(sni.toLowerCase());
})
.map(x => ({
notAfter: new Date(x.not_after),
issuer: x.issuer_name || "",
subject: x.common_name || x.name_value || "",
}))
.filter(x => !isNaN(x.notAfter.getTime()));
if (candidates.length === 0) {
const relaxed = data
.map(x => ({
notAfter: new Date(x.not_after),
issuer: x.issuer_name || "",
subject: x.common_name || x.name_value || "",
}))
.filter(x => !isNaN(x.notAfter.getTime()))
.sort((a, b) => b.notAfter - a.notAfter);
if (relaxed.length === 0) throw new Error(`No cert info found
for ${sni}`);
return relaxed[0];
}
candidates.sort((a, b) => b.notAfter - a.notAfter);
return candidates[0];
}
/**
* 毎日朝9時に実行するトリガーを作成(初回だけ手動で実行)
*/
function setupDailyTrigger() {
ScriptApp.newTrigger("checkCertAndNotify")
.timeBased()
.everyDays(1)
.atHour(9)
.create();
}
スクリプトの解説
証明書期限の取得
function getServerCertInfo_(host, port, sni) は証明書情報を取得する関数です。
この処理により、証明書の失効日(NotAfter)を取得できます。
証明書期限の判定
取得した失効日から、現在時刻との差分を計算します。
const daysLeft =
Math.floor(
(info.notAfter.getTime() - now.getTime())
/(24*3600*1000)
);
残り日数が設定値以下であれば通知対象になります。
例では、証明書期限の3日前に警告メールが送信されます。
const DAYS_BEFORE = 3; // 3日前
通知の重複を防ぐ
証明書の期限が近づくと、毎日通知が送られる可能性があります。
これを防ぐため、通知済みの日付を保存し、同じ日に再通知しないようにします。
const PROP_PREFIX = "cert_notify_last_"; // 通知重複防止キー
const key = PROP_PREFIX + a.host + ":" + (a.port || 443);
const last = props.getProperty(key); // "YYYY-MM-DD"
これにより、1日1回だけ通知しメールの大量送信を防止できます。
メール通知
通知はGmailAppを利用して送信します。
GmailApp.sendEmail(
TO_EMAIL,
subject,
body
);
GASの定期実行
スクリプトは時間トリガーで毎日実行します。
ScriptApp.newTrigger("checkCertAndNotify")
.timeBased()
.everyDays(1)
.atHour(9)
.create();
例では、毎日朝9時に証明書期限チェックが実行されます。
まとめ
SSL証明書の有効期間は急速に短縮されており、将来的には47日証明書になる可能性があります。
そのため、証明書管理では次の運用が重要になります。
- 証明書期限の自動監視
- 期限前通知
- 更新作業の自動化
Google Apps Scriptを利用すれば、専用サーバを用意することなく証明書を簡単に監視できます。
証明書期限切れはサービス停止の原因にもなります。
ぜひ本記事のスクリプトを参考に、自動監視環境を構築してみてください。