HMACが通らない原因は、鍵でも計算式でもなく、Delugeが組み立てたJSON文字列でした。手元で同じsecret・timestamp・想定本文から再計算した署名は一致するのに、入口Lambdaだけが拒否します。暗号実装ではなく、署名対象の文字列が途中で変わっていました。
シリーズ「Zoho WidgetとLambdaの連携」の第2回です。単独でも読めるように、本稿で使う構成要素を先に示します。
前回: Zoho WidgetとLambdaの連携 第1回:Lambdaの3分割と公開URLの整理
次回: Zoho WidgetとLambdaの連携 第3回:PDFプレビューと直送保存の分離
この記事の内容
- HMACの定義と、署名対象がraw bodyである理由
- HTTP 401が出たときの切り分け手順
- payloadBase64ラッパーを誰が作るか、replayとidempotencyの違い
構成要素
| 要素 | 役割 |
|---|---|
| Widget | CRM上のUI。通常操作の起点。共有鍵は持たない |
| Deluge | Zoho側の中継。共有鍵で署名し、入口へPOSTする |
| 入口Lambda | 着弾本文と署名を検証する公開入口 |
| Secrets Manager(相当) | 共有鍵のサーバ側保管場所 |
画面はZoho CRMのWidget(Svelte)です。通常操作はDelugeがHMACして入口LambdaへPOSTします。
HMACとは何か
HMACは、共有秘密鍵とメッセージから認証符号を作り、改ざんやなりすましを検出する方式です。送り手が署名を付け、受け手が同じ計算結果と比較します。本稿ではHMAC-SHA256を使い、出力をhexで運びます。
署名対象は「意図したJSONオブジェクト」ではなく、HTTPで運ばれた本文のバイト列(raw body)です。1バイトでも異なれば、計算式が正しくても検証は失敗します。
最初に疑った場所
最初に疑ったのは鍵とHMACの計算です。Delugeが出した署名と、同じsecret・timestamp・「送ったつもり」の文字列から手元で再計算した署名は一致しました。それでも入口だけHTTP 401になります。
HTTP 401 Unauthorizedは、有効な認証情報が無いときに返すステータスです。この経路では、多くの場合HMAC検証失敗として扱います。
ヘッダ欠落、hexとBase64、時刻単位、許容幅、鍵の改行やBOMも確認しました。決め手は、入口Lambdaが受け取ったraw bodyをログ出力したことです。送った想定と着弾文字列が構造レベルで異なっていました。空白差だけではありませんでした。
Delugeで文字列が変わる
WidgetからZoho関数へ渡すところは次のとおりです。
ZOHO.CRM.FUNCTIONS.execute(fnName, {
arguments: JSON.stringify(payload),
});
ここまではブラウザの JSON.stringify です。その先でDelugeがargumentsをMap経由で扱ったり toString() したりすると、Lambdaへ送る最終文字列が変わります。中間層が再シリアライズすると、意味が近くても署名対象のoctet列は一致しません。
日本語、改行入りメモ、+09:00 付き時刻、入れ子構造、キー順や空白は特に壊れやすいです。
実ログの一字一句は載せません。失敗モードから組み立てた例です。
意図(イメージ):
{"action":"save_form","memo":"一行目\n二行目","at":"2026-07-15T10:00:00+09:00"}
壊れ方の例:
- "\n" が消える/別表現になる
- "+09:00" 周りが別文字列になる
- Map文字列化で引用符や構造が崩れる
結果:
署名に使った文字列 ≠ 着弾raw body
→ 計算式が正しくても401
切り分け手順は次のとおりです。
- secret/timestamp/signatureヘッダ欠落を潰す
- 手元で署名を再計算し、Delugeの署名と一致するか確認する
- 入口の着弾raw bodyと、Delugeが署名に使ったrelay文字列をdiffする
- diffがあれば暗号実装の疑いをやめる
本件は手順3で原因が確定しました。Deluge側でも、署名直前の文字列を残さないと比較できません。
payloadBase64ラッパー
Base64は、任意のバイト列をASCII文字へ可逆変換する符号化です。ここではaction JSONをBase64化する担当はDelugeです。Widgetは従来どおりaction JSONを渡すだけです。
送るのはaction本体ではなく、UTF-8のBase64と操作者IDを含む最小ラッパーです。HMAC対象はこのラッパーのraw bodyのみです。Lambdaは検証後にデコードします。
{
"payloadBase64": "<action JSONのBase64>",
"actorUserId": "<Zoho側で解決した操作者ID>"
}
中継の処理順は次です。arguments検証、ログインユーザーからCRMユーザID解決、action JSONのBase64化、ラッパーJSONの手組み立て、timestamp + "." + relay_body のHMAC-SHA256(hex)、bodyはrelay_bodyのままPOST。操作者を解決できない通常actionは拒否し、画面には認証・リクエスト失敗系の公開文言を返します。
payload_base64 = zoho.encryption.base64Encode(raw_body, "UTF-8");
relay_body = "{\"payloadBase64\":\"" + payload_base64 + "\",\"actorUserId\":\"" + actor_user_id + "\"}";
now_text = zoho.currenttime.toString("yyyy-MM-dd HH:mm:ss", "UTC");
epoch_ms = now_text.unixEpoch("UTC");
ts = (epoch_ms / 1000).toLong().toString();
sign_base = ts + "." + relay_body;
signature = zoho.encryption.hmacsha256(secret, sign_base, "hex");
headers = Map();
headers.put("Content-Type", "application/json; charset=utf-8");
headers.put("X-App-Timestamp", ts);
headers.put("X-App-Signature", signature);
res = invokeurl
[
url : endpoint
type : POST
body : relay_body
headers : headers
detailed : true
];
ラッパー自体を再びMapの toString() に載せると、同じ問題に戻ります。手組み立てが必要です。操作者IDはフロント申告を使わず、Delugeが解決した値のみを業務に使います。
HMACの仕様と公開面
- 署名対象:
timestamp + "." + raw_body - アルゴリズム: HMAC-SHA256、出力hex
- 時刻許容: 300秒
- 検証入力: パース後JSONではなく着弾バイト列
msg = ts.encode("utf-8") + b"." + body
expected = hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(sig, expected)
Function URLの認可が NONE でも、通常APIはraw body署名なしでは通りません。ただしエンドポイントが公開HTTPである事実は変わりません。「第三者に何もできない」ではなく、「署名なしでは業務APIが通らない」です。攻撃面の有無と、認可成功の可否は分けて考えます。
replayとidempotency
| 用語 | 意味 |
|---|---|
| replay | 有効な署名付き要求を、許容時間内にそのまま再利用すること |
| idempotency | 同一の業務要求を複数回送っても、副作用を1回に近づけること |
nonce台帳はありません。300秒以内の厳密なreplay防止は未実装です。更新系はクライアント発行のidempotencyKey(HMACのreplay台帳ではない)、更新時刻競合、履歴の状態ガードで副作用の重複を抑えます。
入口から副作用Lambdaへのサーバ間呼び出しにもHMACがあります。鍵の配置は環境依存ですが、ブラウザには出しません。確認用Lambdaはこの境界に入りません。大きいPDFもこの経路には載せません(第3回)。
よくある失敗
| 症状 | 確認箇所 |
|---|---|
| 401/認証失敗 | raw body一致、鍵、timestamp、ヘッダ |
| 署名は合うのにLambdaだけ失敗 | Delugeの文字列化(本稿) |
| 他処理により更新済み | 楽観的ロック/HTTP 412 |
| 大きいPDFだけ失敗 | 直送経路(第3回) |
| 既知の制限 | 扱い |
|---|---|
| 300秒以内のreplay台帳なし | 受容。更新系は別手段で抑止 |
| 片方だけの鍵更新 | 運用事故になる。同時更新が必要 |
| raw bodyデバッグログ | 秘密が混ざりやすい。取り扱いに注意 |
類似症状では、署名式の前に着弾raw bodyと署名対象文字列を一字一句比較してください。本件ではそこで、暗号ではなくJSON運搬の問題だと確定しました。