tencent cloud

Tencent Cloud EdgeOne

Web Bot Auth

ダウンロード
フォーカスモード
フォントサイズ
最終更新日: 2026-10-15 18:25:52
AI翻訳
Web Bot Authは、HTTPメッセージ署名に基づくAgentの身元認証メカニズムであり、Agentがデジタル署名によって自身の身元を証明することを可能にし、サーバー側はAgentリクエストに含まれる署名情報によってAgentの身元を認証します。本サンプルは、エッジ関数にWeb Bot Auth認証をデプロイするソリューションを提供し、IETF関連標準草案に厳密に準拠しています。

サンプルコード

// ==================== 設定エリア ====================
// キー格納先取得設定
const KEY_DIRECTORY_CONFIG = {
// キャッシュを有効にするかどうか(デフォルトでは有効)
enableCache: true,
// オリジンサーバーのCache-Controlヘッダーに従うかどうか(デフォルトでは従います)
respectCacheControl: true,
// リクエストタイムアウト時間、単位:ミリ秒(デフォルト5秒)
timeout: 5000,
// デフォルトのキーディレクトリパス
defaultPath: '/.well-known/http-message-signatures-directory'
};

// ==================== 定数 ====================

// HTTPメッセージ署名ディレクトリのメディアタイプ(RFC 9421)
const MEDIA_TYPE_DIRECTORY = 'application/http-message-signatures-directory+json';

// ==================== エッジ関数エントリーポイント ====================

addEventListener('fetch', (event) => {
handleRequest(event);
});

async function handleRequest(event) {
const request = event.request;

try {
// すべてのパスで署名検証が必要です。
const status = await verifySignature(request, event);
if (status === 'valid') {
// 署名が有効なため、オリジンサーバーへ転送します。
return;
} else {
// 署名がない、または署名が無効なリクエストを拒否します。
const errorMessage = status === 'neutral'
? 'Missing signature headers'
: status.replace('invalid: ', '');
return event.respondWith(
new Response(JSON.stringify({
error: 'authentication_failed',
message: errorMessage,
timestamp: new Date().toISOString()
}), {
status: 401,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
})
);
}
} catch (error) {
console.error(`[handleRequest] ${error.stack}`);

return event.respondWith(
new Response(JSON.stringify({
error: 'internal_server_error',
message: error.message || 'An internal error occurred',
timestamp: new Date().toISOString()
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
);
}
}

// ==================== 署名検証のコアロジック ====================

/**
* リクエスト署名を検証します。
* @param {Request} request - リクエストオブジェクト
* @returns {Promise<string>} 検証ステータス: 'valid' | 'neutral' | 'invalid: <reason>'
*/
async function verifySignature(request, event) {
try {
// 署名ヘッダーがないため、'neutral'を返します。
if (!request.headers.has('Signature')) {
return 'neutral';
}

// 署名リクエストヘッダーを取得します。
const signature = request.headers.get('Signature');
const signatureInput = request.headers.get('Signature-Input');
if (!signature || !signatureInput) {
return 'invalid: Missing signature headers';
}

// Signature-Inputリクエストヘッダーを解析します。
const parsedSignatureInput = parseSignatureInput(signatureInput);
if (!!parsedSignatureInput.error) {
return `invalid: ${parsedSignatureInput.error}`;
}

}
const parsedSignature = parseSignature(signature, parsedSignatureInput.signatureId);
if (!!parsedSignature.error) {
return `invalid: ${parsedSignature.error}`;
};
const signatureBytes = parsedSignature.bytes;
// パラメータを検証します。
const signatureParamsValidation = validateSignatureParams(parsedSignatureInput.params);
if (!!signatureParamsValidation.error) {
return `invalid: ${signatureParamsValidation.error}`;
}
// キー格納先URLを取得します。
const keyDirectoryUrlResult = getKeyDirectoryUrl(request);
if (keyDirectoryUrlResult.error) {
return `invalid: ${keyDirectoryUrlResult.error}`;
}
const keyDirectoryUrl = keyDirectoryUrlResult.url;
// キー格納先データを取得し、予期されるkeyidを渡して整合性検証を行います。
const keyDirectoryResult = await fetchKeyDirectory(keyDirectoryUrl, event, parsedSignatureInput.params.keyid);
if (keyDirectoryResult.error) {
return `invalid: ${keyDirectoryResult.error}`;
}
const keyDirectory = keyDirectoryResult.directory;
const verifiedKeyId = keyDirectoryResult.verifiedKeyId;
// 公開鍵を検索します(検証済みのkeyidを使用)。
const jwk = findPublicKey(keyDirectory.keys, verifiedKeyId);
if (!jwk) {
return `invalid: Public key not found for keyid: ${verifiedKeyId}`;
}

// 署名ベース文字列を構築します。
const signatureBase = buildSignatureBase(request, parsedSignatureInput.components, parsedSignatureInput.signatureParams);
console.log(`[verifySignature] signatureBase: ${signatureBase}`);
// Ed25519署名を検証します。
const [isValid, reason] = await verifyEd25519(signatureBase, signatureBytes, jwk);
if (isValid) {
return 'valid';
} else {
return !!reason ? `invalid: ${reason}` : 'invalid: Signature verification failed';
}
} catch (error) {
console.error(`[verifySignature] ${error.stack}`);

return `invalid: ${error.message}`;
}
}

/**
* リクエストヘッダーからキー格納先URLを抽出します。
* @param {Request} request - リクエストオブジェクト
* @returns {{ error?: string; url?: string }} 解析結果
*/
function getKeyDirectoryUrl(request) {
try {
const signatureAgent = request.headers.get('Signature-Agent');
if (!signatureAgent) {
return { error: 'Missing Signature-Agent header' };
}
// 値は二重引用符で囲む必要があります。
if (!signatureAgent.startsWith('"') || !signatureAgent.endsWith('"')) {
return { error: 'Signature-Agent header value must be enclosed in double quotes' };
}
// 二重引用符を削除します。
const urlValue = signatureAgent.slice(1, -1);
// HTTPSプロトコルである必要があります。
if (!urlValue.startsWith('https://')) {
return { error: 'Signature-Agent header value must be a valid HTTPS URL' };
}
// URL形式を検証します。
let url;
try {
url = new URL(urlValue);
} catch (error) {
return { error: `Invalid URL in Signature-Agent header: ${error.message}` };
}
// URLが正しいキー格納先パスを指していることを確認します。
// URLがデフォルトパスで終わっていない場合、自動的に追加します。
if (!url.pathname.endsWith(KEY_DIRECTORY_CONFIG.defaultPath)) {
// パスがデフォルトパスでない場合、ドメイン名のルートパスであるかどうかを確認します。
if (url.pathname === '/' || url.pathname === '') {
url.pathname = KEY_DIRECTORY_CONFIG.defaultPath;
} else {
// それ以外の場合は元のパスを維持しますが、警告を記録します。
console.warn(`[getKeyDirectoryUrl] URL path may not be standard key directory: ${url.pathname}`);
}
}
return { url: url.toString() };
} catch (error) {
console.error(`[getKeyDirectoryUrl] ${error.stack}`);
return { error: `Failed to extract key directory URL: ${error.message}` };
}
}

/**
* キー格納先データを取得し、キャッシュに対応します。
* @param {string} url - キー格納先URL
* @param {any} event - イベントオブジェクト。キャッシュ書き込みに使用されます。
* @param {string|null} expectedKeyId - 期待されるkeyid。応答の整合性を検証するために使用されます(任意)。
* @returns {Promise<{ error?: string; directory?: { keys: Array }, verifiedKeyId?: string }>} 結果を取得します。検証済みのkeyidが含まれます。
*/
async function fetchKeyDirectory(url, event, expectedKeyId = null) {
try {
const cache = caches.default;
const cacheKey = new Request(url, { eo: { cacheKey: url } });
// キャッシュが有効な場合、まずキャッシュからの読み取りを試みます。
if (KEY_DIRECTORY_CONFIG.enableCache) {
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
console.log(`[fetchKeyDirectory] Cache hit for ${url}`);
try {
// キャッシュレスポンスに必要なSignature-Inputヘッダーが含まれているかどうかを検証します。
const cachedSignatureInput = cachedResponse.headers.get('Signature-Input');
if (!cachedSignatureInput) {
console.warn(`[fetchKeyDirectory] Cached response missing Signature-Input header, skipping cache`);
// キャッシュをスキップし、ネットワーク取得を続行します。
} else {
// レスポンスヘッダーのkeyidパラメータを解析します。
const parsedCachedInput = parseSignatureInput(cachedSignatureInput);
if (parsedCachedInput.error) {
console.warn(`[fetchKeyDirectory] Failed to parse cached Signature-Input header: ${parsedCachedInput.error}, skipping cache`);
} else {
const cachedKeyId = parsedCachedInput.params.keyid;
if (!cachedKeyId) {
console.warn(`[fetchKeyDirectory] Cached response missing keyid parameter, skipping cache`);
} else if (expectedKeyId !== null && cachedKeyId !== expectedKeyId) {
console.warn(`[fetchKeyDirectory] Cached response keyid mismatch: expected ${expectedKeyId}, got ${cachedKeyId}, skipping cache`);
} else {
// キャッシュの検証に成功しました。ディレクトリと検証済みのkeyidを返します。
const directory = await cachedResponse.json();
return { directory, verifiedKeyId: cachedKeyId };
}
}
}
} catch (error) {
console.error(`[fetchKeyDirectory] Failed to process cached response: ${error.message}`);
// キャッシュ処理に失敗したため、ネットワーク取得を続行します。
}
}
}
// リクエストオプションを構築し、タイムアウトを設定します。
const fetchOptions = {
redirect: 'manual',
eo: {
timeoutSetting: {
connectTimeout: KEY_DIRECTORY_CONFIG.timeout,
readTimeout: KEY_DIRECTORY_CONFIG.timeout,
writeTimeout: KEY_DIRECTORY_CONFIG.timeout
}
}
};
// リクエストを開始します。
const response = await fetch(url, fetchOptions);
if (!response.ok) {
return { error: `HTTP ${response.status} fetching key directory from ${url}` };
}
// Content-Typeを検証します。
const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.includes(MEDIA_TYPE_DIRECTORY)) {
return { error: `Invalid Content-Type: ${contentType}, expected ${MEDIA_TYPE_DIRECTORY}` };
}
// Signature-Inputレスポンスヘッダーを検証します。
const responseSignatureInput = response.headers.get('Signature-Input');
if (!responseSignatureInput) {
return { error: 'Key directory response missing required Signature-Input header' };
}
// レスポンスヘッダーのkeyidパラメータを解析します。
const parsedResponseInput = parseSignatureInput(responseSignatureInput);
if (parsedResponseInput.error) {
return { error: `Failed to parse response Signature-Input header: ${parsedResponseInput.error}` };
}
const responseKeyId = parsedResponseInput.params.keyid;
if (!responseKeyId) {
return { error: 'Key directory response Signature-Input missing keyid parameter' };
}
// 予期されるkeyidが提供されている場合、整合性を検証します。
if (expectedKeyId !== null && responseKeyId !== expectedKeyId) {
return { error: `Key directory response keyid mismatch: expected ${expectedKeyId}, got ${responseKeyId}` };
}
// レスポンスボディを解析します。
let directory;
try {
if (KEY_DIRECTORY_CONFIG.enableCache) {
directory = await response.clone().json();
} else {
directory = await response.json();
}
} catch (error) {
return { error: `Failed to parse key directory response as JSON: ${error.message}` };
}
// ディレクトリ構造を検証します。
if (!directory || !Array.isArray(directory.keys)) {
return { error: 'Invalid key directory structure: missing or invalid "keys" array' };
}
// キャッシュが有効で、かつレスポンスがキャッシュ可能な場合、キャッシュに書き込みます。
if (KEY_DIRECTORY_CONFIG.enableCache) {
// オリジンサーバーのCache-Controlに従う必要があるかどうかを確認します。
let shouldCache = true;
if (KEY_DIRECTORY_CONFIG.respectCacheControl) {
const cacheControl = response.headers.get('Cache-Control');
if (cacheControl && cacheControl.includes('no-store')) {
shouldCache = false;
}
}
if (shouldCache) {
event.waitUntil(cache.put(cacheKey, response.clone()));
console.log(`[fetchKeyDirectory] Cached response for ${url}`);
}
}
return { directory, verifiedKeyId: responseKeyId };
} catch (error) {
console.error(`[fetchKeyDirectory] Error fetching ${url}:`, error);
return { error: `Failed to fetch key directory: ${error.message}` };
}
}

/**
* Signature-Inputリクエストヘッダーを解析します。
* @param {string} signatureInput - Signature-Inputリクエストヘッダー値。
* @returns {{ error?: string; signatureId: string; components: string[]; params: Record<string, string|number>; signatureParams: string;}} 解析結果。
*/
function parseSignatureInput(signatureInput) {
try {
const match = signatureInput.match(/^(\\w+)=(.+)$/);
if (!match) {
return { error: 'Failed to parse Signature-Input - invalid Signature-Input format' };
}

const [, signatureId, paramString] = match;
// コンポーネントリストを解析します。
const componentsMatch = paramString.match(/^\\(([^)]+)\\)/);
if (!componentsMatch) {
return { error: 'Failed to parse Signature-Input - missing components list' };
}

const componentsStr = componentsMatch[1];
const components = componentsStr.split(/\\s+/).map(comp => comp.replace(/"/g, ''));

// パラメータを解析します。
const paramsStr = paramString.substring(componentsMatch[0].length);
const params = {};
const paramRegex = /;(\\w+)=([^;]+)/g;
let paramMatch;

while ((paramMatch = paramRegex.exec(paramsStr)) !== null) {
const [, key, value] = paramMatch;
if (value.startsWith('"') && value.endsWith('"')) {
params[key] = value.slice(1, -1);
} else if (/^\\d+$/.test(value)) {
params[key] = parseInt(value, 10);
} else {
params[key] = value;
}
}

// パラメータ文字列を再構築します。
const signatureParams = `(${components.map(c => `"${c}"`).join(' ')})` +
Object.entries(params).map(([key, value]) => {
return typeof value === 'string' ? `;${key}="${value}"` : `;${key}=${value}`;
}).join('');

return { signatureId, components, params, signatureParams };
} catch (error) {
console.error(`[parseSignatureInput] ${error.stack}`);

return { error: `Failed to parse Signature-Input: ${error.message}` }
}
}

/**
* Signatureリクエストヘッダーを解析します。
* @param {string} signature - Signatureリクエストヘッダー値。
* @param {string} signatureId - 署名ID。
* @returns {{ error?: string; bytes: Uint8Array }} 解析結果。
*/
function parseSignature(signature, signatureId) {
try {
const pattern = new RegExp(`${signatureId}=:([A-Za-z0-9+/=_-]+):`);
const match = signature.match(pattern);

if (!match) {
return { error: `Failed to parse Signature - signature for ${signatureId} not found` };
}

let signatureB64 = match[1];
// base64urlを標準のbase64に変換します。
signatureB64 = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
while (signatureB64.length % 4 !== 0) {
signatureB64 += '=';
}

// バイト配列にデコードします。
const binaryString = atob(signatureB64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}

return { bytes };
} catch (error) {
console.error(`[parseSignature] ${error.stack}`);

return { error: `Failed to parse Signature: ${error.message}` };
}
}

/**
* 署名パラメータを検証します。
* @param {Record<string, string|number>} params - 署名パラメータ。
* @returns {{ error?: string }} 検証結果。
*/
function validateSignatureParams(params) {
console.log(`[validateSignatureParams] ${JSON.stringify(params)}`);
try {
if (!params.keyid) {
return { error: 'Failed to validate signature parameters - missing keyid parameter' };
}
if (!params.alg || params.alg !== 'ed25519') {
return { error: 'Failed to validate signature parameters - invalid or missing algorithm parameter' };
}
if (!params.tag || params.tag !== 'web-bot-auth') {
return { error: 'Failed to validate signature parameters - invalid or missing tag parameter' };
}
if (!params.created || !params.expires) {
return { error: 'Failed to validate signature parameters - missing created or expires parameter' };
}

const now = Math.floor(Date.now() / 1000);
const clockSkew = 300; // 5分間のクロックスキュー許容値。
if (params.created > now + clockSkew) {
return { error: 'Failed to validate signature parameters - created time is in the future' };
}

if (params.expires < now - clockSkew) {
return { error: 'Failed to validate signature parameters - signature has expired' };
}

const maxAge = 3600; // 1時間の最大有効期限。
if (params.expires - params.created > maxAge) {
return { error: 'Failed to validate signature parameters - signature validity period too long' };
}

return {};
} catch (error) {
console.error(`[validateSignatureParams] ${error.stack}`);

return { error: `Failed to validate signature parameters - ${error.message}` };
}
}

/**
* 公開鍵を検索します。
* @param {Array} keys - キー格納先の公開鍵配列。
* @param {string} keyid - 公開鍵ID。
* @returns {{ kid: string; kty: string; crv: string; x: string; }|undefined} 公開鍵。
*/
function findPublicKey(keys, keyid) {
for (const key of keys) {
if (key.kid === keyid) {
return key;
}
}
return null;
}

/**
* 署名ベース文字列を構築します。
* @param {Request} request - リクエストオブジェクト。
* @param {string[]} components - コンポーネントリスト。
* @param {string} signatureParams - 署名パラメータ。
* @returns {string} 署名ベース文字列。
*/
function buildSignatureBase(request, components, signatureParams) {
const lines = [];
for (const component of components) {
const value = extractComponent(request, component);
lines.push(`"${component.toLowerCase()}": ${value}`);
}
lines.push(`"@signature-params": ${signatureParams}`);
return lines.join('\\n');
}

/**
* Ed25519署名検証。
* @param {string} signatureBase - 署名ベース文字列。
* @param {Uint8Array} signature - 署名バイト配列。
* @param {{ kid: string; kty: string; crv: string; x: string; }} jwk - 公開鍵JWK。
* @returns {Promise<[boolean, string]>} 検証結果、[検証成功かどうか、エラーメッセージ]。
*/
async function verifyEd25519(signatureBase, signature, jwk) {
try {
// JWK形式を検証します。
if (!jwk || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
return [false, 'Failed to verify Ed25519 signature - invalid JWK format'];
}
// 署名の長さを検証します。
if (!signature || signature.length !== 64) {
return [false, `Failed to verify Ed25519 signature - invalid signature length (${signature?.length || 'null'}), expected 64`];
}

// 公開鍵をデコードします。
const publicKeyBytes = base64UrlDecode(jwk.x);
if (publicKeyBytes.length !== 32) {
return [false, `Failed to verify Ed25519 signature - invalid public key length (${publicKeyBytes.length}), expected 32`];
}

// メッセージをエンコードします。
const message = new TextEncoder().encode(signatureBase);

const result = await _verifyEd25519(signature, message, publicKeyBytes);

return [result];
} catch (error) {
console.error(`[verifyEd25519] ${error.stack}`);
return [false, `Failed to verify Ed25519 signature - ${error.message}`];
}
}

/**
* Ed25519検証のコアロジック。
* @param {Uint8Array} signature - 64バイトの署名。
* @param {Uint8Array} message - メッセージ。
* @param {Uint8Array} publicKey - 32バイト公開鍵。
* @returns {Promise<boolean>} 検証結果。
*/
async function _verifyEd25519(signature, message, publicKey) {
if (signature.length !== 64) throw new Error('Signature must be 64 bytes');
if (publicKey.length !== 32) throw new Error('Public key must be 32 bytes');

try {
const POINT_G = Point.BASE;
// 公開鍵ポイントAを解析します。
const A = Point.fromBytes(publicKey);
// 署名のR部分を解析します。
const R = Point.fromBytes(signature.slice(0, 32));
// 署名のS部分を解析します。
const S = Ed25519.bytesToNumLE(signature.slice(32, 64));
if (S >= Ed25519.N) throw new Error('S out of range');
// k = H(R || A || M)を計算します。
const hashable = concatBytes(R.toBytes(), A.toBytes(), message);
const hashed = await sha512(hashable);
const k = Ed25519.mod(Ed25519.bytesToNumLE(hashed), Ed25519.N);
// 等式を検証します:[8][S]B = [8]R + [8][k]A。
// 以下と同等です:([8]R + [8][k]A) - [8][S]B = 0。
const SB = POINT_G.multiply(S);
const kA = A.multiply(k);
const RkA = R.add(kA);
// RkA - SBを計算して補因子を除去し、ゼロ点かどうかを確認します。
const diff = RkA.add(SB.negate()).clearCofactor();
return diff.isZero();
} catch (error) {
console.error(`[Ed25519] ${error.stack}`);
return false;
}
}

// ==================== 補助関数 ====================。

/**
* コンポーネント値を抽出します。
* @param {Request} request - リクエストオブジェクト。
* @param {string} component - コンポーネント。
* @returns {string} コンポーネント値。
*/
function extractComponent(request, component) {
if (component.startsWith('@')) {
// 派生コンポーネント。
const url = new URL(request.url);
switch (component) {
case '@method':
return request.method.toUpperCase();
case '@authority':
return url.host;
case '@scheme':
return url.protocol.slice(0, -1);
case '@target-uri':
return request.url;
case '@request-target':
return `${url.pathname}${url.search}`;
case '@path':
return url.pathname;
case '@query':
return url.search.slice(1);
default:
throw new Error(`Failed to extract component - component ${component} is not supported`);
}
} else {
// HTTPヘッダー。
const value = request.headers.get(component.toLowerCase());
return value || '';
}
}

/**
* Base64URLデコード。
* @param {string} str - Base64URLエンコードされた文字列。
* @returns {Uint8Array} デコードされたバイト配列。
*/
function base64UrlDecode(str) {
// base64urlを標準のbase64に変換します。
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padding = base64.length % 4;
if (padding) {
base64 += '='.repeat(4 - padding);
}

// デコード。
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}

return bytes;
}

// SHA-512ハッシュ。
async function sha512(message) {
const msgBuffer = message instanceof Uint8Array ? message : new Uint8Array(message);
const hashBuffer = await crypto.subtle.digest('SHA-512', msgBuffer);
return new Uint8Array(hashBuffer);
}

// バイト配列を連結します。
function concatBytes(...arrays) {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}

// ==================== 補助クラス ====================。

// Ed25519ユーティリティクラス - すべての曲線パラメータと数学関数をカプセル化します。
class Ed25519 {
// Ed25519曲線パラメータ(静的プロパティ)。
static P = BigInt('0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed');
static N = BigInt('0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed');
static Gx = BigInt('0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51a');
static Gy = BigInt('0x6666666666666666666666666666666666666666666666666666666666666658');
static D = BigInt('0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3');
static RM1 = BigInt('0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0');
static B256 = BigInt(2) ** BigInt(256);

// モジュロ演算。
static mod(a, b = Ed25519.P) {
const r = a % b;
return r >= 0n ? r : b + r;
}

// モジュラ逆数。
static invert(num, md = Ed25519.P) {
if (num === 0n || md <= 0n) throw new Error('Invalid inverse');
let a = Ed25519.mod(num, md), b = md, x = 0n, y = 1n, u = 1n, v = 0n;
while (a !== 0n) {
const q = b / a, r = b % a;
const m = x - u * q, n = y - v * q;
b = a, a = r, x = u, y = v, u = m, v = n;
}
if (b !== 1n) throw new Error('No inverse');
return Ed25519.mod(x, md);
}

// バイトを大整数に変換します(リトルエンディアン)。
static bytesToNumLE(bytes) {
let result = 0n;
for (let i = 0; i < bytes.length; i++) {
result += BigInt(bytes[i]) << (8n * BigInt(i));
}
return result;
}

// 大整数をバイトに変換します(リトルエンディアン)。
static numTo32bLE(num) {
const bytes = new Uint8Array(32);
let n = num;
for (let i = 0; i < 32; i++) {
bytes[i] = Number(n & 0xFFn);
n >>= 8n;
}
return bytes;
}

// pow2(x, k) = x^(2^k) mod P
static pow2(x, power) {
let r = x;
while (power-- > 0n) {
r = (r * r) % Ed25519.P;
}
return r;
}

// (p+3)/8乗を計算します。
static pow_2_252_3(x) {
const x2 = (x * x) % Ed25519.P;
const b2 = (x2 * x) % Ed25519.P;
const b4 = (Ed25519.pow2(b2, 2n) * b2) % Ed25519.P;
const b5 = (Ed25519.pow2(b4, 1n) * x) % Ed25519.P;
const b10 = (Ed25519.pow2(b5, 5n) * b5) % Ed25519.P;
const b20 = (Ed25519.pow2(b10, 10n) * b10) % Ed25519.P;
const b40 = (Ed25519.pow2(b20, 20n) * b20) % Ed25519.P;
const b80 = (Ed25519.pow2(b40, 40n) * b40) % Ed25519.P;
const b160 = (Ed25519.pow2(b80, 80n) * b80) % Ed25519.P;
const b240 = (Ed25519.pow2(b160, 80n) * b80) % Ed25519.P;
const b250 = (Ed25519.pow2(b240, 10n) * b10) % Ed25519.P;
const pow_p_5_8 = (Ed25519.pow2(b250, 2n) * x) % Ed25519.P;
return { pow_p_5_8, b2 };
}

// 平方根の計算。
static uvRatio(u, v) {
const v3 = Ed25519.mod(v * v * v);
const v7 = Ed25519.mod(v3 * v3 * v);
const pow = Ed25519.pow_2_252_3(u * v7).pow_p_5_8;
let x = Ed25519.mod(u * v3 * pow);
const vx2 = Ed25519.mod(v * x * x);
const root1 = x;
const root2 = Ed25519.mod(x * Ed25519.RM1);
const useRoot1 = vx2 === u;
const useRoot2 = vx2 === Ed25519.mod(-u);
const noRoot = vx2 === Ed25519.mod(-u * Ed25519.RM1);
if (useRoot1) x = root1;
if (useRoot2 || noRoot) x = root2;
if ((Ed25519.mod(x) & 1n) === 1n) x = Ed25519.mod(-x);
return { isValid: useRoot1 || useRoot2, value: x };
}
}

// 座標点クラスを拡張します。
class Point {
constructor(X, Y, Z, T) {
this.X = X;
this.Y = Y;
this.Z = Z;
this.T = T;
}

}
static fromBytes(bytes) {
if (bytes.length !== 32) throw new Error('Invalid point bytes length');
const normed = new Uint8Array(bytes);
const lastByte = bytes[31];
normed[31] = lastByte & ~0x80;
const y = Ed25519.bytesToNumLE(normed);
if (y >= Ed25519.P) throw new Error('Y coordinate out of range');
const y2 = Ed25519.mod(y * y);
const u = Ed25519.mod(y2 - 1n);
const v = Ed25519.mod(Ed25519.D * y2 + 1n);
let { isValid, value: x } = Ed25519.uvRatio(u, v);
if (!isValid) throw new Error('Invalid point: y is not a square root');
const isXOdd = (x & 1n) === 1n;
const isLastByteOdd = (lastByte & 0x80) !== 0;
if (isLastByteOdd !== isXOdd) x = Ed25519.mod(-x);
return new Point(x, y, 1n, Ed25519.mod(x * y));
}

// 点の加算。
add(other) {
const A = Ed25519.mod(this.X * other.X);
const B = Ed25519.mod(this.Y * other.Y);
const C = Ed25519.mod(this.T * Ed25519.D * other.T);
const D2 = Ed25519.mod(this.Z * other.Z);
const E = Ed25519.mod((this.X + this.Y) * (other.X + other.Y) - A - B);
const F = Ed25519.mod(D2 - C);
const G = Ed25519.mod(D2 + C);
const H = Ed25519.mod(B + A);
const X3 = Ed25519.mod(E * F);
const Y3 = Ed25519.mod(G * H);
const T3 = Ed25519.mod(E * H);
const Z3 = Ed25519.mod(F * G);
return new Point(X3, Y3, Z3, T3);
}

// 点のスカラー倍。
double() {
const A = Ed25519.mod(this.X * this.X);
const B = Ed25519.mod(this.Y * this.Y);
const C = Ed25519.mod(2n * Ed25519.mod(this.Z * this.Z));
const D = Ed25519.mod(-A);
const E = Ed25519.mod((this.X + this.Y) * (this.X + this.Y) - A - B);
const G = D + B;
const F = G - C;
const H = D - B;
const X3 = Ed25519.mod(E * F);
const Y3 = Ed25519.mod(G * H);
const T3 = Ed25519.mod(E * H);
const Z3 = Ed25519.mod(F * G);
return new Point(X3, Y3, Z3, T3);
}

// スカラー乗算。
multiply(scalar) {
const POINT_ZERO = Point.ZERO;
let p = POINT_ZERO;
let d = this;
let n = scalar;
while (n > 0n) {
if (n & 1n) p = p.add(d);
d = d.double();
n >>= 1n;
}
return p;
}

// 負の値を取得します。
negate() {
return new Point(Ed25519.mod(-this.X), this.Y, this.Z, Ed25519.mod(-this.T));
}

// ゼロ点かどうかを判定します。
isZero() {
return this.X === 0n && this.Y === this.Z;
}

// 補因子をクリアします。
clearCofactor() {
return this.double().double().double();
}

// バイトに変換します。
toBytes() {
const iz = Ed25519.invert(this.Z);
const x = Ed25519.mod(this.X * iz);
const y = Ed25519.mod(this.Y * iz);
const bytes = Ed25519.numTo32bLE(y);
bytes[31] |= (x & 1n) ? 0x80 : 0;
return bytes;
}

// 基点(遅延初期化)。
static get BASE() {
if (!this._BASE) {
this._BASE = new Point(Ed25519.Gx, Ed25519.Gy, 1n, Ed25519.mod(Ed25519.Gx * Ed25519.Gy));
}
return this._BASE;
}

// 零点(遅延初期化)。
static get ZERO() {
if (!this._ZERO) {
this._ZERO = new Point(0n, 1n, 1n, 0n);
}
return this._ZERO;
}
}


サンプルプレビュー

1. 署名情報を付与してオリジンサーバーへリクエストし、署名認証が成功した場合、オリジンサーバーのコンテンツを応答します:

2. 署名情報を付与せずにオリジンサーバーへリクエストした場合、401が応答されます:


デプロイガイド

アーキテクチャ準備

Web Bot Auth認証には、以下の三大コアコンポーネントが必要です:
エッジ関数:EdgeOneエッジノードにデプロイされ、リクエストに含まれる署名情報を検証し、Agentの身元認証を行います。
Agent公開鍵ディレクトリサービス:Agent側が独立してホスティングする公開鍵ディレクトリサービスです。
Agent 客户端:身元確認が必要なAgentクライアントです。

Agentによるキーペア生成

説明:
現在のソリューションはEd25519鍵アルゴリズムのみをサポートしています。
署名鍵のペアを生成する必要があります。秘密鍵はAgentクライアントのリクエストへの署名に使用され、公開鍵は特定のディレクトリサービスにホスティングされ、エッジ関数が署名を検証するために使用されます。
公開鍵は JSON Web Key (JWK) 形式に変換する必要があります。変換後には kty、crv、x の3つの必須フィールドを含める必要があり、またキー識別子 kid として JWK フィンガープリント(Thumbprint)を計算する必要があります。

公開鍵ディレクトリサービスのデプロイ

Agent公開鍵ディレクトリサービスは、以下のインターフェース仕様を満たす必要があります:
1. レスポンスヘッダー Content-Type の値は application/http-message-signatures-directory+json である必要があります。
2. 応答ヘッダー Signature-Input を必ず含める必要があり、その形式は以下の通りです:
sig1=("@method" "@target-uri" "@authority");alg="ed25519";keyid="<kid>";tag="web-bot-auth";created=<timestamp>;expires=<timestamp>
ここで、kidは公開鍵の識別子です。
3. レスポンスボディの形式は以下の通りです:

{
"keys": [
{
"kid": "公開鍵の識別子、オプション",
"kty": "OKP",
"crv": "Ed25519",
"x": "公開鍵のx座標(Base64URLエンコード)"
}
]
}

クライアントリクエスト署名

クライアントは署名を正しく生成し、必要なリクエストヘッダー Signature、Signature-Input、および Signature-Agent を追加する必要があります。
本実装は以下の派生コンポーネントをサポートしており、セキュリティ要件に応じて選択できます:
コンポーネント
説明
サポート可否
@method
HTTPリクエストメソッド
対応
@target-uri
完全なリクエストURI(RFC 9421標準)
対応
@authority
リクエストホスト
対応
@scheme
プロトコル(HTTP / HTTPS)
対応
@request-target
リクエストパス+クエリパラメータ
対応
@path
リクエストパス
対応
@query
クエリパラメータ
対応
@query-param
単一クエリパラメータ
非対応
content-digest
リクエストボディのダイジェスト
非対応

関連参考



ヘルプとサポート

この記事はお役に立ちましたか?

フィードバック