True passkey implement

This commit is contained in:
qingjie.du
2026-03-31 15:59:59 +09:00
parent 73ce0ef5a2
commit ec5eb0a447
13 changed files with 1195 additions and 412 deletions

1
.gitignore vendored
View File

@@ -136,3 +136,4 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.DS_Store

195
PASSKEY.md Normal file
View File

@@ -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 サーバーに問い合わせ → OKallowCredentials 返却)
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` | ログアウト |
エンコーディング: Base64URLdfido2-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);
}
```

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -170,6 +170,8 @@ export abstract class BaseAgent {
return null; return null;
} }
try { 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( const assertion = await this.deps.passkeyServer.startCeremony(
tool.meta.auth?.promptMessage ?? `Authorize action: ${tool.name}`, tool.meta.auth?.promptMessage ?? `Authorize action: ${tool.name}`,
); );

View File

@@ -48,13 +48,14 @@ export class MCPletHost {
// Initialize Passkey services if configured // Initialize Passkey services if configured
if (config.passkey) { if (config.passkey) {
const rpId = config.passkey.rpId || 'localhost'; const rpId = config.passkey.rpId || 'localhost';
const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me';
const origin = config.passkey.mode === 'https' const origin = config.passkey.mode === 'https'
? `https://${rpId}` ? `https://${rpId}`
: 'http://127.0.0.1'; : 'http://127.0.0.1';
// Create Passkey Platform Service // Create Passkey Platform Service (delegates to external FIDO2 server)
this.passkeyPlatformService = new PasskeyPlatformService(rpId, origin); this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
console.log(`[host] PasskeyPlatformService initialized (mode: ${config.passkey.mode})`); console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl})`);
// Create and start Passkey API Server // Create and start Passkey API Server
this.passkeyAPIServer = new PasskeyAPIServer( this.passkeyAPIServer = new PasskeyAPIServer(
@@ -65,8 +66,8 @@ export class MCPletHost {
const apiPort = config.passkey.apiPort || 8443; const apiPort = config.passkey.apiPort || 8443;
this.passkeyAPIServer.start(apiPort); this.passkeyAPIServer.start(apiPort);
// For backward compatibility, also create PasskeyServer for interactive ceremonies // PasskeyServer for interactive browser ceremonies
this.passkeyServer = new PasskeyServer(rpId, origin); this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
} }
// Connect MCPlet servers declared in config (each is a stdio child process) // Connect MCPlet servers declared in config (each is a stdio child process)

View File

@@ -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<string, unknown>;
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<AmiProAttestationOptions> {
const body = {
username,
displayName: encodeURIComponent(displayName),
authenticatorSelection: {
userVerification: 'preferred',
requireResidentKey: false,
},
rp: { id: this.rpId },
};
return this.post<AmiProAttestationOptions>('/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<AmiProResult> {
return this.post<AmiProResult>('/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<AmiProAssertionOptions> {
const body: Record<string, unknown> = {
username,
authenticatorSelection: {
userVerification: 'preferred',
},
rp: { id: this.rpId },
};
return this.post<AmiProAssertionOptions>('/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<AmiProResult> {
return this.post<AmiProResult>('/assertion/result', assertionResult);
}
// ─── Session Management ──────────────────────────────────────────────
/**
* Validate an existing session.
*/
async validateSession(session: string): Promise<boolean> {
try {
const result = await this.post<AmiProResult>('/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<void> {
await this.post('/usr/delsession', { session, username });
}
// ─── Device Management ───────────────────────────────────────────────
/**
* List user's registered devices/credentials.
*/
async listDevices(session: string): Promise<AmiProDeviceList> {
return this.post<AmiProDeviceList>('/usr/dvc/lst', {
session,
rp: { id: this.rpId },
});
}
/**
* Delete a user device/credential.
*/
async deleteDevice(session: string, deviceId: string): Promise<AmiProResult> {
return this.post<AmiProResult>('/usr/dvc/rm', {
session,
device_id: deviceId,
rp: { id: this.rpId },
});
}
// ─── HTTP Transport ──────────────────────────────────────────────────
private async post<T>(path: string, body: unknown): Promise<T> {
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<T>;
}
}

View File

@@ -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: * Proxies browser FIDO2 requests to PasskeyPlatformService (→ amiPro FIDO2 server).
* - POST /api/passkey/register/begin — Start registration *
* - POST /api/passkey/register/complete — Finish registration * Endpoints:
* - POST /api/passkey/authenticate/begin — Start authentication * POST /api/passkey/attestation/options — Get registration challenge
* - POST /api/passkey/authenticate/complete — Finish authentication * 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'; import http from 'node:http';
@@ -38,8 +42,8 @@ export class PasskeyAPIServer {
const url = new URL(req.url ?? '/', 'http://localhost'); const url = new URL(req.url ?? '/', 'http://localhost');
// CORS headers // CORS headers
res.setHeader('Access-Control-Allow-Origin', this.origin); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
@@ -56,94 +60,108 @@ export class PasskeyAPIServer {
try { try {
const body = await parseJsonBody(req) as Record<string, unknown>; const body = await parseJsonBody(req) as Record<string, unknown>;
if (url.pathname === '/api/passkey/register/begin') { switch (url.pathname) {
await this.handleRegisterBegin(res, body); case '/api/passkey/attestation/options':
} else if (url.pathname === '/api/passkey/register/complete') { await this.handleAttestationOptions(res, body);
await this.handleRegisterComplete(res, body); break;
} else if (url.pathname === '/api/passkey/authenticate/begin') { case '/api/passkey/attestation/result':
await this.handleAuthenticateBegin(res, body); await this.handleAttestationResult(res, body);
} else if (url.pathname === '/api/passkey/authenticate/complete') { break;
await this.handleAuthenticateComplete(res, body); case '/api/passkey/assertion/options':
} else { await this.handleAssertionOptions(res, body);
sendJson(res, 404, { error: 'Not Found' }); 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) { } catch (err) {
sendJson(res, 400, { error: (err as Error).message }); sendJson(res, 400, { error: (err as Error).message });
} }
} }
private async handleRegisterBegin( // ─── Registration ────────────────────────────────────────────────────
private async handleAttestationOptions(
res: http.ServerResponse, res: http.ServerResponse,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const userId = body.userId as string; const username = body.username as string;
const displayName = body.displayName as string || userId; const displayName = (body.displayName as string) || username;
if (!userId) { if (!username) {
sendJson(res, 400, { error: 'userId is required' }); sendJson(res, 400, { error: 'username is required' });
return; return;
} }
const result = await this.platformService.startRegistration(userId, displayName); const result = await this.platformService.getAttestationOptions(username, displayName);
sendJson(res, result.success ? 200 : 400, result); sendJson(res, result.success ? 200 : 400, result.success ? result.options : { error: result.error });
} }
private async handleRegisterComplete( private async handleAttestationResult(
res: http.ServerResponse, res: http.ServerResponse,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const userId = body.userId as string; const result = await this.platformService.submitAttestationResult(body as any);
const challengeB64 = body.challenge as string; sendJson(res, result.success ? 200 : 400, result);
const attestationResponse = body.attestationResponse as Record<string, unknown>; }
if (!userId || !challengeB64 || !attestationResponse) { // ─── Authentication ──────────────────────────────────────────────────
sendJson(res, 400, { error: 'Missing required fields' });
private async handleAssertionOptions(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<void> {
const session = body.session as string;
if (!session) {
sendJson(res, 400, { error: 'session is required' });
return; return;
} }
const result = await this.platformService.completeRegistration( const valid = await this.platformService.validateSession(session);
userId, sendJson(res, 200, { valid });
challengeB64,
attestationResponse as any,
this.rpId,
this.origin,
);
sendJson(res, result.success ? 200 : 400, result);
} }
private async handleAuthenticateBegin( private async handleSessionDelete(
res: http.ServerResponse, res: http.ServerResponse,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const userId = body.userId as string | undefined; const session = body.session as string;
const username = body.username as string;
const result = await this.platformService.startAuthentication(userId); if (!session || !username) {
sendJson(res, result.success ? 200 : 400, result); sendJson(res, 400, { error: 'session and username are required' });
}
private async handleAuthenticateComplete(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
const credentialId = body.credentialId as string;
const challengeB64 = body.challenge as string;
const assertionResponse = body.assertionResponse as Record<string, unknown>;
if (!credentialId || !challengeB64 || !assertionResponse) {
sendJson(res, 400, { error: 'Missing required fields' });
return; return;
} }
const result = await this.platformService.completeAuthentication( await this.platformService.deleteSession(session, username);
credentialId, sendJson(res, 200, { ok: true });
challengeB64,
assertionResponse as any,
this.rpId,
this.origin,
);
sendJson(res, result.success ? 200 : 400, result);
} }
} }

View File

@@ -1,31 +1,32 @@
/** /**
* Passkey Authentication Module — Public API * Passkey Authentication Module — Public API
* Exports all passkey-related types and services
*/ */
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js'; // External FIDO2 server client
export { InMemoryPasskeyStorage } from './storage.js';
export type { PasskeyChallenge } from './challenge-manager.js';
export { PasskeyChallengeManager } from './challenge-manager.js';
export type { export type {
RegistrationOptions, AmiProAttestationOptions,
AuthenticationOptions, AmiProAssertionOptions,
AttestationResponse, AmiProResult,
AssertionResponse, AmiProDeviceList,
AttestationVerificationResult, } from './amipro-fido2-client.js';
AssertionVerificationResult, export { AmiProFIDO2Client } from './amipro-fido2-client.js';
} from './fido2-backend.js';
export { FIDO2Backend } from './fido2-backend.js';
// Platform service (orchestrates FIDO2 flows)
export type { RegistrationResult, AuthenticationResult } from './platform-service.js'; export type { RegistrationResult, AuthenticationResult } from './platform-service.js';
export { PasskeyPlatformService } from './platform-service.js'; export { PasskeyPlatformService } from './platform-service.js';
// Servers
export { PasskeyServer } from './passkey-server.js'; export { PasskeyServer } from './passkey-server.js';
export { PasskeyAPIServer } from './api-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 type { PasskeyAuthResult, PasskeyRegistrationResult } from './client.js';
export { PasskeyClient } from './client.js'; export { PasskeyClient } from './client.js';
export type { MCPletPasskeyHelperConfig } from './mcplet-helper.js';
export { MCPletPasskeyHelper } from './mcplet-helper.js';

View File

@@ -1,4 +1,5 @@
import http from 'node:http'; import http from 'node:http';
import https from 'node:https';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 * Passkey Server — Spec Section 3.7, 16.3
* *
* Provides two modes: * Opens a self-contained browser page that handles the full passkey flow:
* 1. Interactive Ceremony Mode: Opens a browser page for WebAuthn user interaction * 1. User enters userId (or uses pre-filled value)
* - Binds exclusively to 127.0.0.1 * 2. Page checks with FIDO2 server whether user is registered
* - Port is dynamically allocated per ceremony * 3. If not registered → register passkey first
* - Server closes immediately after assertion delivery or timeout * 4. Authenticate with passkey
* 2. REST API Mode: Provides HTTP endpoints for remote passkey operations * 5. Return assertion payload to Host via loopback callback
*
* Integrates with PasskeyPlatformService for FIDO2 backend operations.
*/ */
export class PasskeyServer { export class PasskeyServer {
private readonly timeoutMs: number; private readonly timeoutMs: number;
private platformService: PasskeyPlatformService; private platformService: PasskeyPlatformService;
private apiServer: http.Server | null = null;
constructor( constructor(
rpId: string = 'localhost', rpId: string = 'localhost',
origin: string = 'http://127.0.0.1', fido2ServerUrl: string = 'https://fido2.epk.amipro.me',
timeoutMs = 55_000, timeoutMs = 120_000,
) { ) {
this.timeoutMs = timeoutMs; 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. * Opens the Passkey Web Page for the complete authentication flow.
* Returns the assertion payload on success, throws on timeout or cancellation. * The browser page handles registration + authentication automatically.
*/ */
async startCeremony(promptMessage: string): Promise<MCPletAuthPayload> { async startCeremony(
promptMessage: string,
username?: string,
): Promise<MCPletAuthPayload> {
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) => { return new Promise((resolve, reject) => {
let server: http.Server | null = null; let server: http.Server | https.Server | null = null;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => { const cleanup = () => {
@@ -48,26 +66,36 @@ export class PasskeyServer {
server?.close(); server?.close();
}; };
server = http.createServer((req, res) => { const requestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => {
const url = new URL(req.url ?? '/', `http://localhost`); const url = new URL(req.url ?? '/', `${protocol}://${hostname}`);
// Serve the Passkey Web Page
if (req.method === 'GET' && url.pathname === '/passkey') { 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; return;
} }
// Receive assertion callback (POST /passkey/callback)
if (req.method === 'POST' && url.pathname === '/passkey/callback') { if (req.method === 'POST' && url.pathname === '/passkey/callback') {
let data = ''; let data = '';
req.on('data', (chunk: Buffer) => { data += chunk.toString(); }); req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
req.on('end', () => { req.on('end', () => {
try { try {
const assertion = JSON.parse(data) as MCPletAuthPayload; const payload = JSON.parse(data) as MCPletAuthPayload;
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true })); res.end(JSON.stringify({ ok: true }));
cleanup(); cleanup();
resolve(assertion); resolve(payload);
} catch { } catch {
res.writeHead(400); res.writeHead(400);
res.end(); res.end();
@@ -76,7 +104,6 @@ export class PasskeyServer {
return; return;
} }
// User cancelled
if (req.method === 'POST' && url.pathname === '/passkey/cancel') { if (req.method === 'POST' && url.pathname === '/passkey/cancel') {
res.writeHead(200); res.writeHead(200);
res.end(); res.end();
@@ -87,23 +114,32 @@ export class PasskeyServer {
res.writeHead(404); res.writeHead(404);
res.end(); 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', () => { server.listen(0, '127.0.0.1', () => {
const addr = server!.address(); const addr = server!.address();
if (!addr || typeof addr === 'string') { if (!addr || typeof addr === 'string') {
reject(new Error('Failed to bind Passkey server')); reject(new Error('Failed to bind Passkey server'));
return; return;
} }
const port = addr.port; 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}`); console.log(`[passkey] Opening ceremony page: ${pageUrl}`);
if (isCustomRpId) {
console.log(`[passkey] Ensure /etc/hosts has: 127.0.0.1 ${rpId}`);
}
openBrowser(pageUrl); openBrowser(pageUrl);
}); });
// Timeout — close page and reject (Spec Section 3.7)
timer = setTimeout(() => { timer = setTimeout(() => {
cleanup(); cleanup();
reject(new Error('Passkey ceremony timed out')); reject(new Error('Passkey ceremony timed out'));
@@ -112,75 +148,285 @@ export class PasskeyServer {
} }
} }
function servePasskeyPage(res: http.ServerResponse, promptMessage: string, port: string): void { // ─── Browser Page ──────────────────────────────────────────────────────
// Try to serve static file first, fall back to inline
const staticPath = path.resolve(__dirname, '../../public/passkey/index.html'); function buildPasskeyPage(
if (fs.existsSync(staticPath)) { promptMessage: string,
const html = fs.readFileSync(staticPath, 'utf-8') port: string,
.replace('{{PROMPT_MESSAGE}}', escapeHtml(promptMessage)) prefilledUsername: string,
.replace('{{PORT}}', port); fido2ServerUrl: string,
res.writeHead(200, { rpId: string,
'Content-Type': 'text/html', protocol: string,
'Content-Security-Policy': hostname: string,
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'", ): string {
}); const callbackBase = `${protocol}://${hostname}:${port}`;
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(inlinePasskeyPage(promptMessage, port));
}
}
function inlinePasskeyPage(promptMessage: string, port: string): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>認証</title> <title>Passkey 認証 — MCPlet</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">
<style> <style>
body { font-family: system-ui; display: flex; flex-direction: column; * { box-sizing: border-box; }
align-items: center; justify-content: center; height: 100vh; margin: 0; } body { font-family: system-ui, -apple-system, sans-serif; display: flex;
button { padding: 12px 32px; font-size: 16px; cursor: pointer; } align-items: center; justify-content: center; height: 100vh; margin: 0;
.msg { margin-bottom: 24px; font-size: 18px; text-align: center; } background: #f8f9fa; color: #333; }
.cancel { margin-top: 12px; color: #666; background: none; border: none; cursor: pointer; } .card { background: #fff; border-radius: 12px; padding: 36px 40px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08); max-width: 440px;
width: 100%; text-align: center; }
h2 { margin: 0 0 8px; font-size: 20px; }
.msg { margin-bottom: 20px; font-size: 14px; color: #555; line-height: 1.5; }
.field { margin-bottom: 20px; text-align: left; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: #444; }
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid #ccc;
border-radius: 6px; outline: none; }
input:focus { border-color: #0066ff; box-shadow: 0 0 0 2px rgba(0,102,255,0.15); }
.btn { width: 100%; padding: 14px; font-size: 16px; cursor: pointer; border: none;
border-radius: 8px; font-weight: 600; transition: all 0.2s; }
.btn-primary { background: #0066ff; color: #fff; }
.btn-primary:hover { background: #0052cc; }
.btn-primary:disabled { background: #ccc; cursor: not-allowed; }
.cancel { margin-top: 12px; color: #888; background: none; border: none;
cursor: pointer; font-size: 13px; }
.status { margin-top: 16px; font-size: 13px; min-height: 18px; }
.error { color: #d32f2f; }
.success { color: #2e7d32; }
.info { color: #1565c0; }
.step { color: #666; }
.icon { font-size: 40px; margin-bottom: 12px; }
.hidden { display: none; }
</style> </style>
</head> </head>
<body> <body>
<p class="msg">${escapeHtml(promptMessage)}</p> <div class="card">
<button id="authBtn">Passkey で認証する</button> <div class="icon">🔐</div>
<button class="cancel" id="cancelBtn">キャンセル</button> <h2>Passkey 認証</h2>
<script> <p class="msg">${escapeHtml(promptMessage)}</p>
const PORT = ${port};
document.getElementById('authBtn').addEventListener('click', async () => { <div class="field" id="userField">
try { <label for="userId">ユーザー ID</label>
// In a real deployment: fetch challenge from FIDO2 server, then call navigator.credentials.get() <input type="text" id="userId" value="${escapeHtml(prefilledUsername)}"
// For demo: simulate a successful assertion placeholder="your-username" autocomplete="username webauthn">
const mockAssertion = { </div>
type: 'passkey_assertion',
challenge: 'demo-challenge-' + Date.now(), <button class="btn btn-primary" id="startBtn">認証を開始する</button>
clientDataJSON: btoa(JSON.stringify({ type: 'webauthn.get', challenge: 'demo' })), <br>
authenticatorData: btoa('demo-authenticator-data'), <button class="cancel" id="cancelBtn">キャンセル</button>
signature: btoa('demo-signature'), <div class="status" id="status"></div>
userHandle: btoa('demo-user') </div>
};
await fetch('http://127.0.0.1:' + PORT + '/passkey/callback', { <script>
method: 'POST', var CALLBACK_BASE = '${callbackBase}';
headers: { 'Content-Type': 'application/json' }, var FIDO2_SVR = '${fido2ServerUrl}';
body: JSON.stringify(mockAssertion) var RP_ID = '${rpId}';
});
document.body.innerHTML = '<p>認証完了。このウィンドウを閉じてください。</p>'; var statusEl = document.getElementById('status');
setTimeout(() => window.close(), 1500); var startBtn = document.getElementById('startBtn');
} catch (e) { var userIdInput = document.getElementById('userId');
alert('認証エラー: ' + e.message);
} function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'status ' + (type || '');
}
// ── Base64URL helpers (matching dfido2-lib.js) ──
function toB64U(s) { return s.split('=')[0].replace(/[+]/g,'-').replace(/[/]/g,'_'); }
function fromB64U(s) {
s = s.replace(/-/g,'+').replace(/_/g,'/');
var p = s.length % 4;
if (p) { if (p===1) throw new Error('bad b64u'); s += '===='.slice(p); }
return s;
}
function b64ToBuf(b) {
var d = atob(b), a = new Uint8Array(d.length);
for (var i=0;i<d.length;i++) a[i]=d.charCodeAt(i);
return a;
}
function bufToStr(b) { return String.fromCharCode.apply(null, new Uint8Array(b)); }
function strToBuf(s) { return new Uint8Array([].map.call(s,function(c){return c.charCodeAt(0)})).buffer; }
// ── FIDO2 server helpers ──
async function fido2Post(path, body) {
var r = await fetch(FIDO2_SVR + path, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
});
return r.json();
}
// ── Check if user is registered ──
async function isUserRegistered(userId) {
try {
var resp = await fido2Post('/assertion/options', {
username: userId,
authenticatorSelection: { userVerification: 'preferred' },
rp: { id: RP_ID }
}); });
document.getElementById('cancelBtn').addEventListener('click', async () => { return resp.status === 'ok';
await fetch('http://127.0.0.1:' + PORT + '/passkey/cancel', { method: 'POST' }); } catch(e) { return false; }
window.close(); }
// ── Registration ──
async function doRegister(userId) {
setStatus('登録オプションを取得中...', 'step');
var opts = await fido2Post('/attestation/options', {
username: userId,
displayName: encodeURIComponent(userId),
authenticatorSelection: { userVerification:'preferred', requireResidentKey:false },
rp: { id: RP_ID }
});
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
var pk = {
rp: opts.rp,
user: { id: strToBuf(opts.user.id), name: opts.user.name||opts.user.id, displayName: opts.user.displayName||opts.user.name||opts.user.id },
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout,
challenge: b64ToBuf(fromB64U(opts.challenge))
};
if (opts.excludeCredentials) pk.excludeCredentials = opts.excludeCredentials.map(function(x){ return {id:b64ToBuf(fromB64U(x.id)),type:x.type}; });
if (opts.authenticatorSelection) pk.authenticatorSelection = opts.authenticatorSelection;
setStatus('認証器を使って登録してください...', 'info');
var cred = await navigator.credentials.create({ publicKey: pk });
if (!cred) throw new Error('Credential not returned');
setStatus('登録結果を検証中...', 'step');
var att = {
id: cred.id,
rawId: toB64U(btoa(bufToStr(cred.rawId))),
type: 'public-key',
response: {
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
attestationObject: toB64U(btoa(bufToStr(cred.response.attestationObject)))
}
};
if (cred.response.getTransports) att.transports = cred.response.getTransports();
var result = await fido2Post('/attestation/result', att);
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Registration failed');
return result;
}
// ── Authentication ──
async function doAuthenticate(userId) {
setStatus('認証オプションを取得中...', 'step');
var opts = await fido2Post('/assertion/options', {
username: userId,
authenticatorSelection: { userVerification:'preferred' },
rp: { id: RP_ID }
});
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
var allowCreds = (opts.allowCredentials||[]).map(function(x){
return { id: b64ToBuf(fromB64U(x.id)), type: x.type, transports: x.transports };
});
setStatus('認証器を使って認証してください...', 'info');
var cred = await navigator.credentials.get({
publicKey: {
challenge: b64ToBuf(fromB64U(opts.challenge)),
timeout: opts.timeout,
rpId: opts.rpId || RP_ID,
userVerification: opts.userVerification || 'preferred',
allowCredentials: allowCreds
}
});
if (!cred) throw new Error('Credential not returned');
setStatus('認証結果を検証中...', 'step');
var auth = {
id: cred.id,
rawId: Array.from(new Uint8Array(cred.rawId)),
type: cred.type,
response: {
authenticatorData: toB64U(btoa(bufToStr(cred.response.authenticatorData))),
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
signature: toB64U(btoa(bufToStr(cred.response.signature))),
userHandle: toB64U(btoa(bufToStr(cred.response.userHandle)))
}
};
var result = await fido2Post('/assertion/result', auth);
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Authentication failed');
return {
opts: opts,
auth: auth,
result: result
};
}
// ── Main Flow ──
async function startFlow() {
var userId = userIdInput.value.trim();
if (!userId) { setStatus('ユーザー ID を入力してください', 'error'); return; }
startBtn.disabled = true;
userIdInput.disabled = true;
try {
// Step 1: Check if user is registered
setStatus('ユーザーを確認中...', 'step');
var registered = await isUserRegistered(userId);
// Step 2: If not registered, register first
if (!registered) {
setStatus('Passkey が未登録です。登録を開始します...', 'info');
await doRegister(userId);
setStatus('登録成功! 続けて認証します...', 'success');
}
// Step 3: Authenticate
var authData = await doAuthenticate(userId);
// Step 4: Send payload back to Host
var payload = {
type: 'passkey_assertion',
challenge: authData.opts.challenge,
clientDataJSON: authData.auth.response.clientDataJSON,
authenticatorData: authData.auth.response.authenticatorData,
signature: authData.auth.response.signature,
userHandle: authData.auth.response.userHandle,
session: authData.result.session,
username: authData.result.username
};
await fetch(CALLBACK_BASE + '/passkey/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}); });
</script>
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);
}
</script>
</body> </body>
</html>`; </html>`;
} }
@@ -200,11 +446,7 @@ function platformOpenCmd(url: string): string {
} }
function openBrowser(url: string): void { function openBrowser(url: string): void {
import('node:child_process').then(({ exec }) => { exec(platformOpenCmd(url), (err) => {
exec(platformOpenCmd(url), (err) => { if (err) console.warn(`[passkey] Could not open browser: ${err.message}`);
if (err) console.warn(`[passkey] Could not open browser: ${err.message}`);
});
}).catch(() => {
console.warn('[passkey] child_process not available');
}); });
} }

View File

@@ -1,262 +1,190 @@
/** /**
* Passkey Platform Service — Unified Passkey Management * Passkey Platform Service — Unified Passkey Management
* Consolidates storage, challenge management, and FIDO2 backend
* Spec Section 3.7, 7.3.1 * 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 { AmiProFIDO2Client } from './amipro-fido2-client.js';
import { InMemoryPasskeyStorage, type IPasskeyStorage } from './storage.js'; import type {
import { PasskeyChallengeManager } from './challenge-manager.js'; AmiProAttestationOptions,
import { FIDO2Backend, type AttestationResponse, type AssertionResponse } from './fido2-backend.js'; AmiProAssertionOptions,
AmiProResult,
} from './amipro-fido2-client.js';
export interface RegistrationResult { export interface RegistrationResult {
success: boolean; success: boolean;
credentialId?: string; session?: string;
userId?: string; username?: string;
error?: string; error?: string;
} }
export interface AuthenticationResult { export interface AuthenticationResult {
success: boolean; success: boolean;
userId?: string; session?: string;
credentialId?: string; username?: string;
error?: string; error?: string;
} }
/** /**
* Passkey Platform Service * 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 { export class PasskeyPlatformService {
private storage: IPasskeyStorage; private fido2Client: AmiProFIDO2Client;
private challengeManager: PasskeyChallengeManager;
private fido2Backend: FIDO2Backend;
constructor( constructor(
rpId: string, private readonly rpId: string,
origin: string, private readonly fido2ServerUrl: string,
storage?: IPasskeyStorage,
) { ) {
this.storage = storage ?? new InMemoryPasskeyStorage(); this.fido2Client = new AmiProFIDO2Client(fido2ServerUrl, rpId);
this.challengeManager = new PasskeyChallengeManager(rpId, origin);
this.fido2Backend = new FIDO2Backend();
} }
// ─── Registration ────────────────────────────────────────────────────
/** /**
* Start registration ceremony * Step 1: Get attestation options from FIDO2 server.
* Returns challenge for the client to use in WebAuthn.create() * Returns challenge + publicKey params for browser's navigator.credentials.create()
*/ */
async startRegistration(userId: string, displayName: string): Promise<{ async getAttestationOptions(
success: boolean; username: string,
challenge?: string; displayName: string,
userId?: string; ): Promise<{ success: boolean; options?: AmiProAttestationOptions; error?: string }> {
error?: string;
}> {
try { try {
// Create user if it doesn't exist const options = await this.fido2Client.getAttestationOptions(username, displayName);
if (!(await this.storage.userExists(userId))) { if (options.status === 'failed') {
await this.storage.createUser(userId, displayName); return { success: false, error: options.errorMessage };
} }
return { success: true, options };
// 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<RegistrationResult> {
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<AuthenticationResult> {
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<PasskeyCredential[]> {
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 };
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message }; 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<RegistrationResult> {
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<AuthenticationResult> {
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<boolean> {
return this.fido2Client.validateSession(session);
}
/**
* Logout / invalidate a session.
*/
async deleteSession(session: string, username: string): Promise<void> {
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;
}
} }

View File

@@ -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);

View File

@@ -95,8 +95,10 @@ directorAgent:
externalAgents: [] externalAgents: []
passkey: passkey:
mode: localhost mode: https
rpId: localhost rpId: a2a-demo.mcplet.ai
fido2ServerUrl: https://fido2.epk.amipro.me
apiPort: 8443
dashboard: dashboard:
port: 4000 port: 4000