From ec5eb0a447afe4ddea173f6602f3fad8c86ad44a Mon Sep 17 00:00:00 2001 From: "qingjie.du" Date: Tue, 31 Mar 2026 15:59:59 +0900 Subject: [PATCH] True passkey implement --- .gitignore | 1 + PASSKEY.md | 195 ++++++++ platform_impl/certs/cert.pem | 20 + platform_impl/certs/key.pem | 28 ++ platform_impl/src/agents/base-agent.ts | 2 + platform_impl/src/host/mcplet-host.ts | 11 +- .../src/passkey/amipro-fido2-client.ts | 205 +++++++++ platform_impl/src/passkey/api-server.ts | 150 +++--- platform_impl/src/passkey/index.ts | 37 +- platform_impl/src/passkey/passkey-server.ts | 428 ++++++++++++++---- platform_impl/src/passkey/platform-service.ts | 384 +++++++--------- platform_impl/test-passkey.ts | 140 ++++++ reference_impl/config/reference.yaml | 6 +- 13 files changed, 1195 insertions(+), 412 deletions(-) create mode 100644 PASSKEY.md create mode 100644 platform_impl/certs/cert.pem create mode 100644 platform_impl/certs/key.pem create mode 100644 platform_impl/src/passkey/amipro-fido2-client.ts create mode 100644 platform_impl/test-passkey.ts diff --git a/.gitignore b/.gitignore index 2309cc8..4a71931 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ dist .yarn/install-state.gz .pnp.* +.DS_Store diff --git a/PASSKEY.md b/PASSKEY.md new file mode 100644 index 0000000..232990a --- /dev/null +++ b/PASSKEY.md @@ -0,0 +1,195 @@ +# MCPletA2A Passkey 認証 + +真正の WebAuthn/FIDO2 Passkey 認証。外部 amiPro FIDO2 サーバーと連携。 + +## アーキテクチャ + +``` +Host (Node.js) + │ + ├── PasskeyServer ──→ ブラウザページ起動(自己完結型フロー) + │ │ + │ ├── userId 入力 + │ ├── FIDO2 サーバーにユーザー確認 + │ ├── 未登録 → 自動で Passkey 登録(Touch ID 等) + │ ├── 認証(Touch ID 等) + │ └── 結果を Host に HTTP callback + │ + ├── PasskeyPlatformService ──→ AmiProFIDO2Client ──→ FIDO2 Server + │ (統一管理: 登録/認証/セッション) (https://fido2.epk.amipro.me) + │ + └── PasskeyAPIServer (REST proxy, port 8443) + /api/passkey/attestation/options + /api/passkey/attestation/result + /api/passkey/assertion/options + /api/passkey/assertion/result +``` + +## ファイル構成 + +``` +platform_impl/src/passkey/ +├── amipro-fido2-client.ts # amiPro FIDO2 サーバー REST クライアント +├── platform-service.ts # 統一 Passkey 管理サービス +├── passkey-server.ts # ブラウザ WebAuthn 式典サーバー(自己完結型ページ) +├── api-server.ts # REST API プロキシ +├── index.ts # エクスポート +├── storage.ts # IPasskeyStorage インターフェース (ローカル用、予備) +├── challenge-manager.ts # チャレンジ管理 (ローカル用、予備) +├── fido2-backend.ts # FIDO2 検証 (ローカル用、予備) +├── client.ts # ブラウザ側 WebAuthn クライアント +└── mcplet-helper.ts # MCPlet ツール統合ヘルパー +``` + +## 設定 + +```yaml +# reference_impl/config/reference.yaml +passkey: + mode: https # localhost | https + rpId: a2a-demo.mcplet.ai # WebAuthn Relying Party ID + fido2ServerUrl: https://fido2.epk.amipro.me # 外部 FIDO2 サーバー + apiPort: 8443 # REST API ポート +``` + +## ユーザーフロー + +### 新規ユーザー(未登録) + +``` +ブラウザページ起動 + ↓ +1. userId 入力(または自動入力) + ↓ +2. FIDO2 サーバーに問い合わせ → "ユーザーが存在しません" + ↓ +3. 自動で登録モードに切替 → Touch ID 等で Passkey 登録 + ↓ +4. 登録成功 → 自動で認証に進む + ↓ +5. Touch ID 等で認証 + ↓ +6. session + username を Host に返却 +``` + +### 既存ユーザー(登録済み) + +``` +ブラウザページ起動 + ↓ +1. userId 入力(または自動入力) + ↓ +2. FIDO2 サーバーに問い合わせ → OK(allowCredentials 返却) + ↓ +3. Touch ID 等で認証 + ↓ +4. session + username を Host に返却 +``` + +## amiPro FIDO2 Server API + +| エンドポイント | 説明 | +|--------------|------| +| `POST /attestation/options` | 登録オプション取得(challenge + publicKey params) | +| `POST /attestation/result` | 登録結果送信(attestationObject 検証) | +| `POST /assertion/options` | 認証オプション取得(challenge + allowCredentials) | +| `POST /assertion/result` | 認証結果送信(signature 検証) | +| `POST /usr/validsession` | セッション検証 | +| `POST /usr/delsession` | ログアウト | + +エンコーディング: Base64URL(dfido2-lib.js 互換) + +## 開発環境セットアップ + +### 1. /etc/hosts + +``` +127.0.0.1 a2a-demo.mcplet.ai +``` + +### 2. 自己署名証明書の生成 + +```bash +cd platform_impl +mkdir -p certs +openssl req -x509 -newkey rsa:2048 \ + -keyout certs/key.pem -out certs/cert.pem \ + -days 365 -nodes -subj "/CN=a2a-demo.mcplet.ai" \ + -addext "subjectAltName=DNS:a2a-demo.mcplet.ai,IP:127.0.0.1" +``` + +### 3. 証明書の信頼(macOS) + +```bash +# Admin 権限あり +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain certs/cert.pem + +# Admin 権限なし(Edge で有効、Chrome は完全再起動が必要) +security add-trusted-cert -r trustRoot \ + -k ~/Library/Keychains/login.keychain-db certs/cert.pem +``` + +### 4. テスト + +```bash +cd platform_impl + +# API 接続テストのみ +npx tsx test-passkey.ts + +# 統一フロー(自動登録+認証) +npx tsx test-passkey.ts test a2a-test-user + +# 新規ユーザー(userId 手入力) +npx tsx test-passkey.ts new +``` + +## 重要な注意事項 + +### WebAuthn rpId とドメインの一致 + +WebAuthn は rpId がブラウザページのドメインと一致することを要求する。 +`http://127.0.0.1` のページでは rpId `a2a-demo.mcplet.ai` は使用不可。 +必ず /etc/hosts + HTTPS(または Chrome flag)で一致させること。 + +### Passkey のブラウザ間共有 + +macOS の Touch ID / iCloud キーチェーンで作成された Passkey はブラウザ間で共有される。 +ただし Chrome の `--user-data-dir=tmp` で起動すると隔離プロファイルとなり、 +プラットフォーム Passkey にアクセスできない。常にデフォルトブラウザを使用すること。 + +### 本番環境 vs 開発環境 + +| 項目 | 開発環境 | 本番環境 | +|------|---------|---------| +| 証明書 | 自己署名 + キーチェーン信頼 | 正規 SSL 証明書 | +| rpId | a2a-demo.mcplet.ai (hosts) | 実ドメイン (DNS) | +| FIDO2 server | fido2.epk.amipro.me | 同 or 専用サーバー | +| `PASSKEY_DEV_HTTP` | 設定可(Chrome flag 必要) | 不使用 | + +## Platform 統合 + +### base-agent.ts での Passkey 呼び出し + +```typescript +// action 型ツールで auth.required === 'passkey' の場合、自動で仪式を開始 +if (tool.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') { + const assertion = await this.performPasskeyCeremony(tool); + if (!assertion) { + return this.errorResult(toolName, 'Passkey authentication failed', 'AUTH_FAILED'); + } + callParams = { ...args, _mcplet_auth: assertion }; +} +``` + +### MCPletHost 初期化 + +```typescript +if (config.passkey) { + this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl); + this.passkeyAPIServer = new PasskeyAPIServer(platformService, rpId, origin); + this.passkeyAPIServer.start(config.passkey.apiPort || 8443); + this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl); +} +``` diff --git a/platform_impl/certs/cert.pem b/platform_impl/certs/cert.pem new file mode 100644 index 0000000..3e97cd9 --- /dev/null +++ b/platform_impl/certs/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTTCCAjWgAwIBAgIUJhMaUllayBlEWhHMSqGee7bgnS0wDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSYTJhLWRlbW8ubWNwbGV0LmFpMB4XDTI2MDMzMTAwMDMy +NFoXDTI3MDMzMTAwMDMyNFowHTEbMBkGA1UEAwwSYTJhLWRlbW8ubWNwbGV0LmFp +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1SW40G66aT4eU4Gg0hCD +BrGWiYPiqmWpEbhbReYaONjn7Fr70YDuPWQjWA4xF3FP7gZVLpJyfhkA5kllKwNG +ngLpQWTJ2Fd++yoWxPu+pMXaaQwXT74RF+G5hx3+NNBj06bwwzlg9EbqeQhn2k/S +GZrRyQLGiQB1A8mWnF63EwEvRgZKgLmvA+LngzRxIhwwK5yqqdgT2NRV90aB5mVM +92/BqV8Gp6hdemPII8Z6t4EzcHxdnjvnz1DQoo4gySV36vuEDxPXV2Xdg9eNDuNY +zu2iUR2y6nZ2ED+gztu0okws4yn/vFg++PAWkz89RZex+6cJaZxnOO5Eb2RJ0+mh +1wIDAQABo4GEMIGBMB0GA1UdDgQWBBSKllweg2yz5okdZyZ4b7q2AG1RojAfBgNV +HSMEGDAWgBSKllweg2yz5okdZyZ4b7q2AG1RojAPBgNVHRMBAf8EBTADAQH/MC4G +A1UdEQQnMCWCEmEyYS1kZW1vLm1jcGxldC5haYIJbG9jYWxob3N0hwR/AAABMA0G +CSqGSIb3DQEBCwUAA4IBAQCiv3kOE5eIQhrLvgXqYIefPMyN9bPcvwp97AkB1/IL +tF7lgVnQ8pWCQoVpCZAM42ztaV+UlZR41Kc2H1BrSTwWaHPGcHXInWkLQ1zy24KA +E2kKwPuQi0R11Hn1zRRw2wMkqGdZ9G1xNQfLRNbCFdJPro+QOpN42yMqOyYrmJZj +GU48CkL0t0KtP02I9pISiU+SFAJDj2sG0Okw2y7JLordmEyAPOC3lnZfwwscmmjP +Abl7D433I01DFHFCWwcnka7t7Ofm36msr90NREIM71LLSbWOkLDWJD8JgKVrci+x +YYba808FxrWMtt2qNMklPmYAjoRLZnBGMKBQpjarqaTD +-----END CERTIFICATE----- diff --git a/platform_impl/certs/key.pem b/platform_impl/certs/key.pem new file mode 100644 index 0000000..5519c3c --- /dev/null +++ b/platform_impl/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDVJbjQbrppPh5T +gaDSEIMGsZaJg+KqZakRuFtF5ho42OfsWvvRgO49ZCNYDjEXcU/uBlUuknJ+GQDm +SWUrA0aeAulBZMnYV377KhbE+76kxdppDBdPvhEX4bmHHf400GPTpvDDOWD0Rup5 +CGfaT9IZmtHJAsaJAHUDyZacXrcTAS9GBkqAua8D4ueDNHEiHDArnKqp2BPY1FX3 +RoHmZUz3b8GpXwanqF16Y8gjxnq3gTNwfF2eO+fPUNCijiDJJXfq+4QPE9dXZd2D +140O41jO7aJRHbLqdnYQP6DO27SiTCzjKf+8WD748BaTPz1Fl7H7pwlpnGc47kRv +ZEnT6aHXAgMBAAECggEAAaQLMquPiszcHedzDfrrj0shrghSX95teUHyjeyyCfr9 +eg/PPXMhIl7ZeM2PKi+Innv0/ulIsVjO7XbmLPkW+5NpKUQ125D83MEbsMOBWbJe +No3NxiLf7c+ihnxAHzb1dcUkuFQCP48mMe1TI1aW9vR+pe38CkTzIabVHnoKLb7C +fgq3Nbzo1sMeS5dBWgSgMK6sMHUmWcsKWzz9TnP6NFez4PuvYFO4b3QOsPppT+Ub +pIf4u7kLP/CUPBCBEtphJNZwq5F1aH8AgSi9BbYio1FbLlR99ikM890M/jlrKCTC +1xqNd8nK3Zr8d6r4s/43xE9zUs3M/ZNZV6eu8Y3FMQKBgQDuMksDZyR5tYuCfDI7 +D5IpllDUw9tD/b6No/b0/unQP3G3h0DRT/E9ewUjBPvDt2lEiiCPKcV9CtNNvxAV +f4PwsYFNaY5y85BeyynF3+zUxzeQ0ohzqCExCF7mgQ3kPtblNCvNtM0SLUuP/CVU +ppMHpCY8S9i1sowk/F0wtNMaZwKBgQDlFCIorXBhXZYIkYMg4A+g8LQtnkR1DeIz +woRM/zANrqbnBq8m3+l7P09ZPbFzGkRQgpcKvaVeV/yI568x7Mtkm+B1Dn6NxMyd +Vt/r/3AfVBU2cU1MHw9wtq/VT+DnMQKbu3q74Ni78YWJDJpaz8WcoHaVrU4Swtlp +R2AaRQl3EQKBgQClDwjYNNMb2+fu5e1Q6/tXAijFJ2t79AvlzudE4phXjH9atEkA +Qqti9SqcF8n219QEgMsLKeEGQ0glqe6VVyWw1vBJGopxscIrThGGYyOUVvB0VM/l +hW5qsehBRtC/h6QWdE6eX1lz7RtdjVa5ECz2sJMmWVC0qCNhRde19rgKpQKBgBAF +4H3H49xrl1ryEqHyCiXPsEqgj1lAp1nHeUmJb+sFFFeEeCvX7ZTZUMuFLSxH4g9f +kwEFUtPOg7NvwSlUzsUywfhuExwHb+hxcygmrckDMJimRCnW4lWX8aSR+cEyBGSw +MF2D1KUQt65mW0WO0tupvaGqhZN6XYqnm2k6+vaBAoGAH+GA7r1a5Bfjaao+WRtC +9neuvZxS8vuiveglx9HWDtPvdBMOlPLZAgwJMueJfUqzZVbV8+oItRCy1p78DawF +GNmgCaxCEHgY/ELjRaCgo1L6ToXZdsbpWZTongc0Osd1kCunb50KfBgaEPS8VmCz +pYVd+Le5rIL9/XESBMrqtgY= +-----END PRIVATE KEY----- diff --git a/platform_impl/src/agents/base-agent.ts b/platform_impl/src/agents/base-agent.ts index 17c11c6..6510f6f 100644 --- a/platform_impl/src/agents/base-agent.ts +++ b/platform_impl/src/agents/base-agent.ts @@ -170,6 +170,8 @@ export abstract class BaseAgent { return null; } try { + // Opens a browser page that handles the full flow: + // userId input → auto-detect registered → register if needed → authenticate const assertion = await this.deps.passkeyServer.startCeremony( tool.meta.auth?.promptMessage ?? `Authorize action: ${tool.name}`, ); diff --git a/platform_impl/src/host/mcplet-host.ts b/platform_impl/src/host/mcplet-host.ts index 399c094..c17dadc 100644 --- a/platform_impl/src/host/mcplet-host.ts +++ b/platform_impl/src/host/mcplet-host.ts @@ -48,13 +48,14 @@ export class MCPletHost { // Initialize Passkey services if configured if (config.passkey) { const rpId = config.passkey.rpId || 'localhost'; + const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me'; const origin = config.passkey.mode === 'https' ? `https://${rpId}` : 'http://127.0.0.1'; - // Create Passkey Platform Service - this.passkeyPlatformService = new PasskeyPlatformService(rpId, origin); - console.log(`[host] PasskeyPlatformService initialized (mode: ${config.passkey.mode})`); + // Create Passkey Platform Service (delegates to external FIDO2 server) + this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl); + console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl})`); // Create and start Passkey API Server this.passkeyAPIServer = new PasskeyAPIServer( @@ -65,8 +66,8 @@ export class MCPletHost { const apiPort = config.passkey.apiPort || 8443; this.passkeyAPIServer.start(apiPort); - // For backward compatibility, also create PasskeyServer for interactive ceremonies - this.passkeyServer = new PasskeyServer(rpId, origin); + // PasskeyServer for interactive browser ceremonies + this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl); } // Connect MCPlet servers declared in config (each is a stdio child process) diff --git a/platform_impl/src/passkey/amipro-fido2-client.ts b/platform_impl/src/passkey/amipro-fido2-client.ts new file mode 100644 index 0000000..1a1f0ad --- /dev/null +++ b/platform_impl/src/passkey/amipro-fido2-client.ts @@ -0,0 +1,205 @@ +/** + * AmiPro FIDO2 Server Client — Node.js HTTP Client + * + * Wraps REST API calls to the amiPro FIDO2 server (e.g., https://fido2.epk.amipro.me). + * This server handles: + * - Challenge generation + * - Attestation/Assertion verification + * - Session management + * - Credential storage + * + * API Endpoints: + * POST /attestation/options — Get registration options (challenge + publicKey params) + * POST /attestation/result — Submit attestation response for verification + * POST /assertion/options — Get authentication options (challenge + allowCredentials) + * POST /assertion/result — Submit assertion response for verification + * POST /usr/validsession — Validate an existing session + * POST /usr/delsession — Logout / delete session + * POST /usr/dvc/lst — List user devices + * POST /usr/dvc/rm — Delete a user device + */ + +export interface AmiProAttestationOptions { + status: string; + challenge: string; // base64url-encoded + rp: { name: string; id: string }; + user: { id: string; name: string; displayName: string }; + pubKeyCredParams: Array<{ type: string; alg: number }>; + timeout: number; + excludeCredentials?: Array<{ id: string; type: string }>; + authenticatorSelection?: Record; + attestation?: string; + errorMessage?: string; +} + +export interface AmiProAssertionOptions { + status: string; + challenge: string; // base64url-encoded + rpId: string; + timeout: number; + allowCredentials: Array<{ id: string; type: string; transports?: string[] }>; + userVerification: string; + errorMessage?: string; +} + +export interface AmiProResult { + status: 'ok' | 'failed'; + session?: string; + username?: string; + errorMessage?: string; +} + +export interface AmiProDeviceList { + status: 'ok' | 'failed'; + devices?: Array<{ id: string; name?: string; createdAt?: string }>; + errorMessage?: string; +} + +export class AmiProFIDO2Client { + constructor( + private readonly serverUrl: string, // e.g. https://fido2.epk.amipro.me + private readonly rpId: string, // e.g. a2a-demo.mcplet.ai + ) {} + + // ─── Registration (Attestation) ────────────────────────────────────── + + /** + * Step 1: Request attestation options from server. + * Server generates challenge and returns PublicKeyCredentialCreationOptions. + */ + async getAttestationOptions( + username: string, + displayName: string, + ): Promise { + const body = { + username, + displayName: encodeURIComponent(displayName), + authenticatorSelection: { + userVerification: 'preferred', + requireResidentKey: false, + }, + rp: { id: this.rpId }, + }; + + return this.post('/attestation/options', body); + } + + /** + * Step 3: Submit attestation result to server for verification. + * Called after browser's navigator.credentials.create() returns. + */ + async submitAttestationResult(attestationResult: { + id: string; + rawId: string; // base64url + type: string; + response: { + clientDataJSON: string; // base64url + attestationObject: string; // base64url + }; + transports?: string[]; + }): Promise { + return this.post('/attestation/result', attestationResult); + } + + // ─── Authentication (Assertion) ────────────────────────────────────── + + /** + * Step 1: Request assertion options from server. + * Server generates challenge and returns allowCredentials list. + */ + async getAssertionOptions( + username: string | null = null, + ): Promise { + const body: Record = { + username, + authenticatorSelection: { + userVerification: 'preferred', + }, + rp: { id: this.rpId }, + }; + + return this.post('/assertion/options', body); + } + + /** + * Step 3: Submit assertion result to server for verification. + * Called after browser's navigator.credentials.get() returns. + */ + async submitAssertionResult(assertionResult: { + id: string; + rawId: number[] | string; // array of bytes or base64url + type: string; + response: { + authenticatorData: string; // base64url + clientDataJSON: string; // base64url + signature: string; // base64url + userHandle: string; // base64url + }; + }): Promise { + return this.post('/assertion/result', assertionResult); + } + + // ─── Session Management ────────────────────────────────────────────── + + /** + * Validate an existing session. + */ + async validateSession(session: string): Promise { + try { + const result = await this.post('/usr/validsession', { + session, + rp: { id: this.rpId }, + }); + return result.status === 'ok'; + } catch { + return false; + } + } + + /** + * Logout / delete a session. + */ + async deleteSession(session: string, username: string): Promise { + await this.post('/usr/delsession', { session, username }); + } + + // ─── Device Management ─────────────────────────────────────────────── + + /** + * List user's registered devices/credentials. + */ + async listDevices(session: string): Promise { + return this.post('/usr/dvc/lst', { + session, + rp: { id: this.rpId }, + }); + } + + /** + * Delete a user device/credential. + */ + async deleteDevice(session: string, deviceId: string): Promise { + return this.post('/usr/dvc/rm', { + session, + device_id: deviceId, + rp: { id: this.rpId }, + }); + } + + // ─── HTTP Transport ────────────────────────────────────────────────── + + private async post(path: string, body: unknown): Promise { + const url = `${this.serverUrl}${path}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`FIDO2 server error: ${response.status} ${response.statusText}`); + } + + return response.json() as Promise; + } +} diff --git a/platform_impl/src/passkey/api-server.ts b/platform_impl/src/passkey/api-server.ts index b47d2d2..7051d7b 100644 --- a/platform_impl/src/passkey/api-server.ts +++ b/platform_impl/src/passkey/api-server.ts @@ -1,11 +1,15 @@ /** - * Passkey REST API Endpoints — Spec Section 3.7, 16.3 + * Passkey REST API Server — Spec Section 3.7, 16.3 * - * Provides REST endpoints for: - * - POST /api/passkey/register/begin — Start registration - * - POST /api/passkey/register/complete — Finish registration - * - POST /api/passkey/authenticate/begin — Start authentication - * - POST /api/passkey/authenticate/complete — Finish authentication + * Proxies browser FIDO2 requests to PasskeyPlatformService (→ amiPro FIDO2 server). + * + * Endpoints: + * POST /api/passkey/attestation/options — Get registration challenge + * POST /api/passkey/attestation/result — Submit registration result + * POST /api/passkey/assertion/options — Get authentication challenge + * POST /api/passkey/assertion/result — Submit authentication result + * POST /api/passkey/session/validate — Validate a session + * POST /api/passkey/session/delete — Delete/logout a session */ import http from 'node:http'; @@ -38,8 +42,8 @@ export class PasskeyAPIServer { const url = new URL(req.url ?? '/', 'http://localhost'); // CORS headers - res.setHeader('Access-Control-Allow-Origin', this.origin); - res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { @@ -56,94 +60,108 @@ export class PasskeyAPIServer { try { const body = await parseJsonBody(req) as Record; - if (url.pathname === '/api/passkey/register/begin') { - await this.handleRegisterBegin(res, body); - } else if (url.pathname === '/api/passkey/register/complete') { - await this.handleRegisterComplete(res, body); - } else if (url.pathname === '/api/passkey/authenticate/begin') { - await this.handleAuthenticateBegin(res, body); - } else if (url.pathname === '/api/passkey/authenticate/complete') { - await this.handleAuthenticateComplete(res, body); - } else { - sendJson(res, 404, { error: 'Not Found' }); + switch (url.pathname) { + case '/api/passkey/attestation/options': + await this.handleAttestationOptions(res, body); + break; + case '/api/passkey/attestation/result': + await this.handleAttestationResult(res, body); + break; + case '/api/passkey/assertion/options': + await this.handleAssertionOptions(res, body); + break; + case '/api/passkey/assertion/result': + await this.handleAssertionResult(res, body); + break; + case '/api/passkey/session/validate': + await this.handleSessionValidate(res, body); + break; + case '/api/passkey/session/delete': + await this.handleSessionDelete(res, body); + break; + default: + sendJson(res, 404, { error: 'Not Found' }); } } catch (err) { sendJson(res, 400, { error: (err as Error).message }); } } - private async handleRegisterBegin( + // ─── Registration ──────────────────────────────────────────────────── + + private async handleAttestationOptions( res: http.ServerResponse, body: Record, ): Promise { - const userId = body.userId as string; - const displayName = body.displayName as string || userId; + const username = body.username as string; + const displayName = (body.displayName as string) || username; - if (!userId) { - sendJson(res, 400, { error: 'userId is required' }); + if (!username) { + sendJson(res, 400, { error: 'username is required' }); return; } - const result = await this.platformService.startRegistration(userId, displayName); - sendJson(res, result.success ? 200 : 400, result); + const result = await this.platformService.getAttestationOptions(username, displayName); + sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error }); } - private async handleRegisterComplete( + private async handleAttestationResult( res: http.ServerResponse, body: Record, ): Promise { - const userId = body.userId as string; - const challengeB64 = body.challenge as string; - const attestationResponse = body.attestationResponse as Record; + const result = await this.platformService.submitAttestationResult(body as any); + sendJson(res, result.success ? 200 : 400, result); + } - if (!userId || !challengeB64 || !attestationResponse) { - sendJson(res, 400, { error: 'Missing required fields' }); + // ─── Authentication ────────────────────────────────────────────────── + + private async handleAssertionOptions( + res: http.ServerResponse, + body: Record, + ): Promise { + const username = body.username as string | undefined; + + const result = await this.platformService.getAssertionOptions(username); + sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error }); + } + + private async handleAssertionResult( + res: http.ServerResponse, + body: Record, + ): Promise { + const result = await this.platformService.submitAssertionResult(body as any); + sendJson(res, result.success ? 200 : 400, result); + } + + // ─── Session ───────────────────────────────────────────────────────── + + private async handleSessionValidate( + res: http.ServerResponse, + body: Record, + ): Promise { + const session = body.session as string; + if (!session) { + sendJson(res, 400, { error: 'session is required' }); return; } - const result = await this.platformService.completeRegistration( - userId, - challengeB64, - attestationResponse as any, - this.rpId, - this.origin, - ); - - sendJson(res, result.success ? 200 : 400, result); + const valid = await this.platformService.validateSession(session); + sendJson(res, 200, { valid }); } - private async handleAuthenticateBegin( + private async handleSessionDelete( res: http.ServerResponse, body: Record, ): Promise { - const userId = body.userId as string | undefined; - - const result = await this.platformService.startAuthentication(userId); - sendJson(res, result.success ? 200 : 400, result); - } - - private async handleAuthenticateComplete( - res: http.ServerResponse, - body: Record, - ): Promise { - const credentialId = body.credentialId as string; - const challengeB64 = body.challenge as string; - const assertionResponse = body.assertionResponse as Record; - - if (!credentialId || !challengeB64 || !assertionResponse) { - sendJson(res, 400, { error: 'Missing required fields' }); + const session = body.session as string; + const username = body.username as string; + if (!session || !username) { + sendJson(res, 400, { error: 'session and username are required' }); return; } - const result = await this.platformService.completeAuthentication( - credentialId, - challengeB64, - assertionResponse as any, - this.rpId, - this.origin, - ); - - sendJson(res, result.success ? 200 : 400, result); + await this.platformService.deleteSession(session, username); + sendJson(res, 200, { ok: true }); } } diff --git a/platform_impl/src/passkey/index.ts b/platform_impl/src/passkey/index.ts index 8034042..0583123 100644 --- a/platform_impl/src/passkey/index.ts +++ b/platform_impl/src/passkey/index.ts @@ -1,31 +1,32 @@ /** * Passkey Authentication Module — Public API - * Exports all passkey-related types and services */ -export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js'; -export { InMemoryPasskeyStorage } from './storage.js'; - -export type { PasskeyChallenge } from './challenge-manager.js'; -export { PasskeyChallengeManager } from './challenge-manager.js'; - +// External FIDO2 server client export type { - RegistrationOptions, - AuthenticationOptions, - AttestationResponse, - AssertionResponse, - AttestationVerificationResult, - AssertionVerificationResult, -} from './fido2-backend.js'; -export { FIDO2Backend } from './fido2-backend.js'; + AmiProAttestationOptions, + AmiProAssertionOptions, + AmiProResult, + AmiProDeviceList, +} from './amipro-fido2-client.js'; +export { AmiProFIDO2Client } from './amipro-fido2-client.js'; +// Platform service (orchestrates FIDO2 flows) export type { RegistrationResult, AuthenticationResult } from './platform-service.js'; export { PasskeyPlatformService } from './platform-service.js'; +// Servers export { PasskeyServer } from './passkey-server.js'; export { PasskeyAPIServer } from './api-server.js'; + +// Storage (for local credential tracking if needed) +export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js'; +export { InMemoryPasskeyStorage } from './storage.js'; + +// Challenge manager (for custom flows) +export type { PasskeyChallenge } from './challenge-manager.js'; +export { PasskeyChallengeManager } from './challenge-manager.js'; + +// Client (browser-side) export type { PasskeyAuthResult, PasskeyRegistrationResult } from './client.js'; export { PasskeyClient } from './client.js'; - -export type { MCPletPasskeyHelperConfig } from './mcplet-helper.js'; -export { MCPletPasskeyHelper } from './mcplet-helper.js'; diff --git a/platform_impl/src/passkey/passkey-server.ts b/platform_impl/src/passkey/passkey-server.ts index c970385..6dbb73f 100644 --- a/platform_impl/src/passkey/passkey-server.ts +++ b/platform_impl/src/passkey/passkey-server.ts @@ -1,4 +1,5 @@ import http from 'node:http'; +import https from 'node:https'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,36 +12,53 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Passkey Server — Spec Section 3.7, 16.3 * - * Provides two modes: - * 1. Interactive Ceremony Mode: Opens a browser page for WebAuthn user interaction - * - Binds exclusively to 127.0.0.1 - * - Port is dynamically allocated per ceremony - * - Server closes immediately after assertion delivery or timeout - * 2. REST API Mode: Provides HTTP endpoints for remote passkey operations - * - * Integrates with PasskeyPlatformService for FIDO2 backend operations. + * Opens a self-contained browser page that handles the full passkey flow: + * 1. User enters userId (or uses pre-filled value) + * 2. Page checks with FIDO2 server whether user is registered + * 3. If not registered → register passkey first + * 4. Authenticate with passkey + * 5. Return assertion payload to Host via loopback callback */ export class PasskeyServer { private readonly timeoutMs: number; private platformService: PasskeyPlatformService; - private apiServer: http.Server | null = null; constructor( rpId: string = 'localhost', - origin: string = 'http://127.0.0.1', - timeoutMs = 55_000, + fido2ServerUrl: string = 'https://fido2.epk.amipro.me', + timeoutMs = 120_000, ) { this.timeoutMs = timeoutMs; - this.platformService = new PasskeyPlatformService(rpId, origin); + this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl); + } + + getPlatformService(): PasskeyPlatformService { + return this.platformService; } /** - * Opens the Passkey Web Page and waits for the WebAuthn assertion. - * Returns the assertion payload on success, throws on timeout or cancellation. + * Opens the Passkey Web Page for the complete authentication flow. + * The browser page handles registration + authentication automatically. */ - async startCeremony(promptMessage: string): Promise { + async startCeremony( + promptMessage: string, + username?: string, + ): Promise { + const fido2ServerUrl = this.platformService.getFido2ServerUrl(); + const rpId = this.platformService.getRpId(); + + const isCustomRpId = rpId !== 'localhost' && rpId !== '127.0.0.1'; + const hostname = isCustomRpId ? rpId : '127.0.0.1'; + + const certDir = path.resolve(__dirname, '../../certs'); + const keyPath = path.join(certDir, 'key.pem'); + const certPath = path.join(certDir, 'cert.pem'); + const hasCerts = isCustomRpId && fs.existsSync(keyPath) && fs.existsSync(certPath); + const useHttps = hasCerts && !process.env.PASSKEY_DEV_HTTP; + const protocol = useHttps ? 'https' : 'http'; + return new Promise((resolve, reject) => { - let server: http.Server | null = null; + let server: http.Server | https.Server | null = null; let timer: ReturnType | null = null; const cleanup = () => { @@ -48,26 +66,36 @@ export class PasskeyServer { server?.close(); }; - server = http.createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://localhost`); + const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => { + const url = new URL(req.url ?? '/', `${protocol}://${hostname}`); - // Serve the Passkey Web Page if (req.method === 'GET' && url.pathname === '/passkey') { - servePasskeyPage(res, promptMessage, url.searchParams.get('port') ?? ''); + const port = url.searchParams.get('port') ?? ''; + const html = buildPasskeyPage( + promptMessage, port, username ?? '', fido2ServerUrl, rpId, + protocol, hostname, + ); + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': + "default-src 'self' 'unsafe-inline' https://*.amipro.me; " + + `connect-src 'self' https://*.amipro.me ${protocol}://${hostname}:*; ` + + "style-src 'self' 'unsafe-inline'", + }); + res.end(html); return; } - // Receive assertion callback (POST /passkey/callback) if (req.method === 'POST' && url.pathname === '/passkey/callback') { let data = ''; req.on('data', (chunk: Buffer) => { data += chunk.toString(); }); req.on('end', () => { try { - const assertion = JSON.parse(data) as MCPletAuthPayload; + const payload = JSON.parse(data) as MCPletAuthPayload; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); cleanup(); - resolve(assertion); + resolve(payload); } catch { res.writeHead(400); res.end(); @@ -76,7 +104,6 @@ export class PasskeyServer { return; } - // User cancelled if (req.method === 'POST' && url.pathname === '/passkey/cancel') { res.writeHead(200); res.end(); @@ -87,23 +114,32 @@ export class PasskeyServer { res.writeHead(404); res.end(); - }); + }; + + if (useHttps) { + server = https.createServer({ + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }, requestHandler); + } else { + server = http.createServer(requestHandler); + } - // Bind to loopback with port 0 (OS assigns dynamic port) server.listen(0, '127.0.0.1', () => { const addr = server!.address(); if (!addr || typeof addr === 'string') { reject(new Error('Failed to bind Passkey server')); return; } - const port = addr.port; - const pageUrl = `http://127.0.0.1:${port}/passkey?port=${port}`; + const pageUrl = `${protocol}://${hostname}:${port}/passkey?port=${port}`; console.log(`[passkey] Opening ceremony page: ${pageUrl}`); + if (isCustomRpId) { + console.log(`[passkey] Ensure /etc/hosts has: 127.0.0.1 ${rpId}`); + } openBrowser(pageUrl); }); - // Timeout — close page and reject (Spec Section 3.7) timer = setTimeout(() => { cleanup(); reject(new Error('Passkey ceremony timed out')); @@ -112,75 +148,285 @@ export class PasskeyServer { } } -function servePasskeyPage(res: http.ServerResponse, promptMessage: string, port: string): void { - // Try to serve static file first, fall back to inline - const staticPath = path.resolve(__dirname, '../../public/passkey/index.html'); - if (fs.existsSync(staticPath)) { - const html = fs.readFileSync(staticPath, 'utf-8') - .replace('{{PROMPT_MESSAGE}}', escapeHtml(promptMessage)) - .replace('{{PORT}}', port); - res.writeHead(200, { - 'Content-Type': 'text/html', - 'Content-Security-Policy': - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'", - }); - res.end(html); - } else { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(inlinePasskeyPage(promptMessage, port)); - } -} +// ─── Browser Page ────────────────────────────────────────────────────── + +function buildPasskeyPage( + promptMessage: string, + port: string, + prefilledUsername: string, + fido2ServerUrl: string, + rpId: string, + protocol: string, + hostname: string, +): string { + const callbackBase = `${protocol}://${hostname}:${port}`; -function inlinePasskeyPage(promptMessage: string, port: string): string { return ` - 認証 - + Passkey 認証 — MCPlet -

${escapeHtml(promptMessage)}

- - - + + setStatus('認証成功! (' + (authData.result.username||userId) + ')', 'success'); + setTimeout(function(){ window.close(); }, 2000); + + } catch (e) { + if (e.name === 'InvalidStateError') { + setStatus('このデバイスは既に登録済みです', 'error'); + } else if (e.name === 'NotAllowedError') { + setStatus('操作がキャンセルされました', 'error'); + } else { + setStatus('エラー: ' + e.message, 'error'); + } + startBtn.disabled = false; + userIdInput.disabled = false; + } +} + +// ── Events ── +startBtn.addEventListener('click', startFlow); +userIdInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') startFlow(); }); +document.getElementById('cancelBtn').addEventListener('click', async function() { + await fetch(CALLBACK_BASE + '/passkey/cancel', { method: 'POST' }); + window.close(); +}); + +// Auto-start if username is pre-filled +if (userIdInput.value.trim()) { + // Give browser a moment to render, then auto-start + setTimeout(startFlow, 500); +} + `; } @@ -200,11 +446,7 @@ function platformOpenCmd(url: string): string { } function openBrowser(url: string): void { - import('node:child_process').then(({ exec }) => { - exec(platformOpenCmd(url), (err) => { - if (err) console.warn(`[passkey] Could not open browser: ${err.message}`); - }); - }).catch(() => { - console.warn('[passkey] child_process not available'); + exec(platformOpenCmd(url), (err) => { + if (err) console.warn(`[passkey] Could not open browser: ${err.message}`); }); } diff --git a/platform_impl/src/passkey/platform-service.ts b/platform_impl/src/passkey/platform-service.ts index 32c06e6..83aeaf6 100644 --- a/platform_impl/src/passkey/platform-service.ts +++ b/platform_impl/src/passkey/platform-service.ts @@ -1,262 +1,190 @@ /** * Passkey Platform Service — Unified Passkey Management - * Consolidates storage, challenge management, and FIDO2 backend * Spec Section 3.7, 7.3.1 + * + * Delegates challenge generation and cryptographic verification to an external + * FIDO2 server (amiPro). This service: + * - Proxies attestation/assertion options from FIDO2 server to browser + * - Proxies attestation/assertion results from browser to FIDO2 server + * - Manages session lifecycle */ -import type { PasskeyCredential } from './storage.js'; -import { InMemoryPasskeyStorage, type IPasskeyStorage } from './storage.js'; -import { PasskeyChallengeManager } from './challenge-manager.js'; -import { FIDO2Backend, type AttestationResponse, type AssertionResponse } from './fido2-backend.js'; +import { AmiProFIDO2Client } from './amipro-fido2-client.js'; +import type { + AmiProAttestationOptions, + AmiProAssertionOptions, + AmiProResult, +} from './amipro-fido2-client.js'; export interface RegistrationResult { success: boolean; - credentialId?: string; - userId?: string; + session?: string; + username?: string; error?: string; } export interface AuthenticationResult { success: boolean; - userId?: string; - credentialId?: string; + session?: string; + username?: string; error?: string; } /** * Passkey Platform Service - * High-level API for passkey registration and authentication - * Agnostic to transport layer (HTTP, WebSocket, etc.) + * + * High-level API for passkey registration and authentication. + * All cryptographic operations are performed by the external FIDO2 server. */ export class PasskeyPlatformService { - private storage: IPasskeyStorage; - private challengeManager: PasskeyChallengeManager; - private fido2Backend: FIDO2Backend; + private fido2Client: AmiProFIDO2Client; constructor( - rpId: string, - origin: string, - storage?: IPasskeyStorage, + private readonly rpId: string, + private readonly fido2ServerUrl: string, ) { - this.storage = storage ?? new InMemoryPasskeyStorage(); - this.challengeManager = new PasskeyChallengeManager(rpId, origin); - this.fido2Backend = new FIDO2Backend(); + this.fido2Client = new AmiProFIDO2Client(fido2ServerUrl, rpId); } + // ─── Registration ──────────────────────────────────────────────────── + /** - * Start registration ceremony - * Returns challenge for the client to use in WebAuthn.create() + * Step 1: Get attestation options from FIDO2 server. + * Returns challenge + publicKey params for browser's navigator.credentials.create() */ - async startRegistration(userId: string, displayName: string): Promise<{ - success: boolean; - challenge?: string; - userId?: string; - error?: string; - }> { + async getAttestationOptions( + username: string, + displayName: string, + ): Promise<{ success: boolean; options?: AmiProAttestationOptions; error?: string }> { try { - // Create user if it doesn't exist - if (!(await this.storage.userExists(userId))) { - await this.storage.createUser(userId, displayName); + const options = await this.fido2Client.getAttestationOptions(username, displayName); + if (options.status === 'failed') { + return { success: false, error: options.errorMessage }; } - - // Generate challenge - const challenge = this.challengeManager.generateChallenge(userId, 'registration'); - - return { - success: true, - challenge, - userId, - }; - } catch (err) { - return { - success: false, - error: (err as Error).message, - }; - } - } - - /** - * Verify registration ceremony response - * Client sends attestation response after WebAuthn.create() - */ - async completeRegistration( - userId: string, - challengeB64: string, - attestationResponse: AttestationResponse, - rpId: string, - origin: string, - ): Promise { - try { - // Validate challenge - const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'registration'); - if (!challenge) { - return { success: false, error: 'Invalid or expired challenge' }; - } - - // Verify attestation - const attestResult = this.fido2Backend.verifyAttestation( - attestationResponse, - challengeB64, - rpId, - origin, - ); - - if (!attestResult.success) { - return { success: false, error: attestResult.error }; - } - - // Store credential - const credential: PasskeyCredential = { - id: attestResult.credentialId, - publicKey: attestResult.publicKey, - counter: attestResult.counter, - transports: attestResult.transports, - createdAt: new Date().toISOString(), - }; - - await this.storage.addCredential(userId, credential); - - return { - success: true, - credentialId: attestResult.credentialId, - userId, - }; - } catch (err) { - return { - success: false, - error: (err as Error).message, - }; - } - } - - /** - * Start authentication ceremony - * Returns challenge for the client to use in WebAuthn.get() - */ - async startAuthentication(userId?: string): Promise<{ - success: boolean; - challenge?: string; - allowCredentials?: Array<{ id: string; type: string }>; - error?: string; - }> { - try { - // If userId provided, generate user-specific challenge; - // otherwise, generate challenge for resident key - const ceremonyUserId = userId || 'any-user'; - - const challenge = this.challengeManager.generateChallenge(ceremonyUserId, 'authentication'); - - const allowCredentials = userId - ? (await this.storage.getCredentialsByUserId(userId)) - .map(cred => ({ id: cred.id, type: 'public-key' })) - : []; - - return { - success: true, - challenge, - allowCredentials, - }; - } catch (err) { - return { - success: false, - error: (err as Error).message, - }; - } - } - - /** - * Verify authentication ceremony response - * Client sends assertion response after WebAuthn.get() - */ - async completeAuthentication( - credentialId: string, - challengeB64: string, - assertionResponse: AssertionResponse, - rpId: string, - origin: string, - ): Promise { - try { - // Find credential and associated user - const credential = await this.storage.getCredential(credentialId); - if (!credential) { - return { success: false, error: 'Credential not found' }; - } - - // Determine user ID (credential is unique, so only one user can own it) - let userId = ''; - const allUsers = await this.storage.getUser(assertionResponse.response.userHandle || ''); - if (!allUsers) { - // Try to find user by credential - for (const [uid] of Object.entries({})) { - const creds = await this.storage.getCredentialsByUserId(uid); - if (creds.some(c => c.id === credentialId)) { - userId = uid; - break; - } - } - } else { - userId = allUsers.userId; - } - - if (!userId) { - return { success: false, error: 'Could not determine user for credential' }; - } - - // Validate challenge (note: we need to validate against the correct ceremony userId) - const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'authentication'); - if (!challenge) { - return { success: false, error: 'Invalid or expired challenge' }; - } - - // Verify assertion - const assertResult = this.fido2Backend.verifyAssertion( - assertionResponse, - challengeB64, - credential.publicKey, - credential.counter, - rpId, - origin, - ); - - if (!assertResult.success) { - return { success: false, error: assertResult.error }; - } - - // Update credential counter - await this.storage.updateCredentialCounter(credentialId, assertResult.counter); - - return { - success: true, - userId, - credentialId, - }; - } catch (err) { - return { - success: false, - error: (err as Error).message, - }; - } - } - - /** - * Get user's credentials (for management UI) - */ - async getUserCredentials(userId: string): Promise { - return this.storage.getCredentialsByUserId(userId); - } - - /** - * Delete a credential - */ - async deleteCredential(userId: string, credentialId: string): Promise<{ - success: boolean; - error?: string; - }> { - try { - await this.storage.deleteCredential(userId, credentialId); - return { success: true }; + return { success: true, options }; } catch (err) { return { success: false, error: (err as Error).message }; } } + + /** + * Step 3: Submit attestation result to FIDO2 server for verification. + * Called after browser completed navigator.credentials.create() + */ + async submitAttestationResult(attestationResult: { + id: string; + rawId: string; + type: string; + response: { + clientDataJSON: string; + attestationObject: string; + }; + transports?: string[]; + }): Promise { + try { + const result = await this.fido2Client.submitAttestationResult(attestationResult); + if (result.status === 'ok') { + return { + success: true, + session: result.session, + username: result.username, + }; + } + return { success: false, error: result.errorMessage }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + // ─── Authentication ────────────────────────────────────────────────── + + /** + * Step 1: Get assertion options from FIDO2 server. + * Returns challenge + allowCredentials for browser's navigator.credentials.get() + */ + async getAssertionOptions( + username?: string, + ): Promise<{ success: boolean; options?: AmiProAssertionOptions; error?: string }> { + try { + const options = await this.fido2Client.getAssertionOptions(username ?? null); + if (options.status === 'failed') { + return { success: false, error: options.errorMessage }; + } + return { success: true, options }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + /** + * Step 3: Submit assertion result to FIDO2 server for verification. + * Called after browser completed navigator.credentials.get() + */ + async submitAssertionResult(assertionResult: { + id: string; + rawId: number[] | string; + type: string; + response: { + authenticatorData: string; + clientDataJSON: string; + signature: string; + userHandle: string; + }; + }): Promise { + try { + const result = await this.fido2Client.submitAssertionResult(assertionResult); + if (result.status === 'ok') { + return { + success: true, + session: result.session, + username: result.username, + }; + } + return { success: false, error: result.errorMessage }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + + // ─── Session Management ────────────────────────────────────────────── + + /** + * Validate a FIDO2 session. + */ + async validateSession(session: string): Promise { + return this.fido2Client.validateSession(session); + } + + /** + * Logout / invalidate a session. + */ + async deleteSession(session: string, username: string): Promise { + await this.fido2Client.deleteSession(session, username); + } + + // ─── Device Management ─────────────────────────────────────────────── + + /** + * List user's registered devices. + */ + async listDevices(session: string) { + return this.fido2Client.listDevices(session); + } + + /** + * Delete a user device. + */ + async deleteDevice(session: string, deviceId: string) { + return this.fido2Client.deleteDevice(session, deviceId); + } + + // ─── Accessors ─────────────────────────────────────────────────────── + + getRpId(): string { + return this.rpId; + } + + getFido2ServerUrl(): string { + return this.fido2ServerUrl; + } } diff --git a/platform_impl/test-passkey.ts b/platform_impl/test-passkey.ts new file mode 100644 index 0000000..c3867c6 --- /dev/null +++ b/platform_impl/test-passkey.ts @@ -0,0 +1,140 @@ +/** + * End-to-end test for Passkey authentication against amiPro FIDO2 server. + * + * Tests: + * 1. AmiProFIDO2Client connectivity + * 2. PasskeyPlatformService flow + * 3. PasskeyServer interactive ceremony (opens browser) + * + * Usage: npx tsx test-passkey.ts [register|authenticate] + */ + +import { AmiProFIDO2Client } from './src/passkey/amipro-fido2-client.js'; +import { PasskeyPlatformService } from './src/passkey/platform-service.js'; +import { PasskeyServer } from './src/passkey/passkey-server.js'; + +const FIDO2_SERVER_URL = 'https://fido2.epk.amipro.me'; +const RP_ID = 'a2a-demo.mcplet.ai'; +const TEST_USER = 'a2a-test-user'; + +async function testAmiProClient() { + console.log('\n=== Test 1: AmiProFIDO2Client Connectivity ===\n'); + + const client = new AmiProFIDO2Client(FIDO2_SERVER_URL, RP_ID); + + // Test attestation options (registration) + console.log('[1a] Getting attestation options...'); + try { + const attOpts = await client.getAttestationOptions(TEST_USER, 'A2A Test User'); + console.log(' status:', attOpts.status); + if (attOpts.status !== 'failed') { + console.log(' challenge:', attOpts.challenge?.substring(0, 30) + '...'); + console.log(' rp:', JSON.stringify(attOpts.rp)); + console.log(' user.id:', attOpts.user?.id?.substring(0, 20) + '...'); + console.log(' pubKeyCredParams:', JSON.stringify(attOpts.pubKeyCredParams)); + console.log(' timeout:', attOpts.timeout); + } else { + console.log(' errorMessage:', attOpts.errorMessage); + } + } catch (err) { + console.error(' ERROR:', (err as Error).message); + } + + // Test assertion options (authentication) + console.log('\n[1b] Getting assertion options...'); + try { + const assertOpts = await client.getAssertionOptions(TEST_USER); + console.log(' status:', assertOpts.status); + if (assertOpts.status === 'ok') { + console.log(' challenge:', assertOpts.challenge?.substring(0, 30) + '...'); + console.log(' rpId:', assertOpts.rpId); + console.log(' timeout:', assertOpts.timeout); + console.log(' allowCredentials:', assertOpts.allowCredentials?.length, 'entries'); + console.log(' userVerification:', assertOpts.userVerification); + } else { + console.log(' errorMessage:', assertOpts.errorMessage); + console.log(' (Expected: user not registered yet)'); + } + } catch (err) { + console.error(' ERROR:', (err as Error).message); + } +} + +async function testPlatformService() { + console.log('\n=== Test 2: PasskeyPlatformService ===\n'); + + const service = new PasskeyPlatformService(RP_ID, FIDO2_SERVER_URL); + + // Test get attestation options + console.log('[2a] Getting attestation options via service...'); + const attResult = await service.getAttestationOptions(TEST_USER, 'A2A Test User'); + console.log(' success:', attResult.success); + if (attResult.success) { + console.log(' challenge present:', !!attResult.options?.challenge); + console.log(' rp:', JSON.stringify(attResult.options?.rp)); + } else { + console.log(' error:', attResult.error); + } + + // Test get assertion options + console.log('\n[2b] Getting assertion options via service...'); + const assertResult = await service.getAssertionOptions(TEST_USER); + console.log(' success:', assertResult.success); + if (assertResult.success) { + console.log(' challenge present:', !!assertResult.options?.challenge); + console.log(' allowCredentials:', assertResult.options?.allowCredentials?.length, 'entries'); + } else { + console.log(' error:', assertResult.error); + console.log(' (Expected if user not registered yet)'); + } +} + +async function testInteractiveCeremony(username?: string) { + console.log(`\n=== Test 3: Interactive Passkey Ceremony ===\n`); + console.log(`Opens a browser page that auto-detects register vs authenticate.`); + console.log(`rpId: ${RP_ID}, FIDO2 server: ${FIDO2_SERVER_URL}`); + if (username) console.log(`Pre-filled username: ${username}`); + console.log(); + + const server = new PasskeyServer(RP_ID, FIDO2_SERVER_URL, 180_000); + + try { + const payload = await server.startCeremony( + `この操作には Passkey 認証が必要です`, + username, + ); + + console.log('\n Ceremony succeeded!'); + console.log(' type:', payload.type); + console.log(' challenge:', payload.challenge?.substring(0, 30) + '...'); + console.log(' session:', (payload as any).session); + console.log(' username:', (payload as any).username); + } catch (err) { + console.error('\n Ceremony failed:', (err as Error).message); + } +} + +// ─── Main ────────────────────────────────────────────────────────────── + +const arg = process.argv[2]; + +await testAmiProClient(); +await testPlatformService(); + +if (arg === 'test') { + // Interactive test: opens browser with unified flow + // Pass a username to pre-fill, or omit to let user enter it + const username = process.argv[3] || undefined; + await testInteractiveCeremony(username); +} else if (arg === 'new') { + // Test with a brand new user (no pre-fill) + await testInteractiveCeremony(); +} else { + console.log('\n=== Interactive test skipped ==='); + console.log('Usage:'); + console.log(' npx tsx test-passkey.ts test [username] — unified flow (auto register+auth)'); + console.log(' npx tsx test-passkey.ts new — new user (empty username field)'); +} + +console.log('\nDone.'); +process.exit(0); diff --git a/reference_impl/config/reference.yaml b/reference_impl/config/reference.yaml index 956da6f..92821b3 100644 --- a/reference_impl/config/reference.yaml +++ b/reference_impl/config/reference.yaml @@ -95,8 +95,10 @@ directorAgent: externalAgents: [] passkey: - mode: localhost - rpId: localhost + mode: https + rpId: a2a-demo.mcplet.ai + fido2ServerUrl: https://fido2.epk.amipro.me + apiPort: 8443 dashboard: port: 4000