diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts new file mode 100644 index 000000000..b5e4e9cf5 --- /dev/null +++ b/packages/backend/test/e2e/2fa.ts @@ -0,0 +1,439 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as crypto from 'node:crypto'; +import * as cbor from 'cbor'; +import * as OTPAuth from 'otpauth'; +import { loadConfig } from '../../src/config.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('2要素認証', () => { + let p: INestApplicationContext; + let alice: unknown; + + const config = loadConfig(); + const password = 'test'; + const username = 'alice'; + + // https://datatracker.ietf.org/doc/html/rfc8152 + // 各値の定義は上記規格に基づく。鍵ペアは適当に生成したやつ + const coseKtyEc2 = 2; + const coseKid = 'meriadoc.brandybuck@buckland.example'; + const coseAlgEs256 = -7; + const coseEc2CrvP256 = 1; + const coseEc2X = '4932eaacc657565705e4287e7870ce3aad55545d99d35a98a472dc52880cfc8f'; + const coseEc2Y = '5ca68303bf2c0433473e3d5cb8586bc2c8c43a4945a496fce8dbeda8b23ab0b1'; + + // private key only for testing + const pemToSign = '-----BEGIN EC PRIVATE KEY-----\n' + + 'MHcCAQEEIHqe/keuXyolbXzgLOu+YFJjDBGWVgXc3QCXfyqwDPf2oAoGCCqGSM49\n' + + 'AwEHoUQDQgAESTLqrMZXVlcF5Ch+eHDOOq1VVF2Z01qYpHLcUogM/I9cpoMDvywE\n' + + 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + + '-----END EC PRIVATE KEY-----\n'; + + const otpToken = (secret: string): string => { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + }); + }; + + const rpIdHash = (): Buffer => { + return crypto.createHash('sha256') + .update(Buffer.from(config.hostname, 'utf-8')) + .digest(); + }; + + const keyDoneParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + attestationObject: string, + challengeId: string, + clientDataJSON: string, + password: string, + name: string, + } => { + // A COSE encoded public key + const credentialPublicKey = cbor.encode(new Map([ + [-1, coseEc2CrvP256], + [-2, Buffer.from(coseEc2X, 'hex')], + [-3, Buffer.from(coseEc2Y, 'hex')], + [1, coseKtyEc2], + [2, coseKid], + [3, coseAlgEs256], + ])); + + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const credentialIdLength = Buffer.allocUnsafe(2); + credentialIdLength.writeUInt16BE(param.credentialId.length); + const authData = Buffer.concat([ + rpIdHash(), // rpIdHash(32) + Buffer.from([0x45]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4) + Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16) + credentialIdLength, + param.credentialId, + credentialPublicKey, + ]); + + return { + attestationObject: cbor.encode({ + fmt: 'none', + attStmt: {}, + authData, + }).toString('hex'), + challengeId: param.challengeId, + clientDataJSON: JSON.stringify({ + type: 'webauthn.create', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + }), + password, + name: param.keyName, + }; + }; + + const signinParam = (): { + username: string, + password: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + return { + username, + password, + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + const signinWithSecurityKeyParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + authenticatorData: string, + credentialId: string, + challengeId: string, + clientDataJSON: string, + username: string, + password: string, + signature: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const authenticatorData = Buffer.concat([ + rpIdHash(), + Buffer.from([0x05]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x01]), // signCount(4) + ]); + const clientDataJSONBuffer = Buffer.from(JSON.stringify({ + type: 'webauthn.get', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + })); + const hashedclientDataJSON = crypto.createHash('sha256') + .update(clientDataJSONBuffer) + .digest(); + const privateKey = crypto.createPrivateKey(pemToSign); + const signature = crypto.createSign('SHA256') + .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) + .sign(privateKey); + return { + authenticatorData: authenticatorData.toString('hex'), + credentialId: param.credentialId.toString('base64'), + challengeId: param.challengeId, + clientDataJSON: clientDataJSONBuffer.toString('hex'), + username, + password, + signature: signature.toString('hex'), + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + beforeAll(async () => { + p = await startServer(); + alice = await signup({ username, password }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await p.close(); + }); + + test('が設定でき、OTPでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + assert.notEqual(registerResponse.body.qr, undefined); + assert.notEqual(registerResponse.body.url, undefined); + assert.notEqual(registerResponse.body.secret, undefined); + assert.strictEqual(registerResponse.body.label, username); + assert.strictEqual(registerResponse.body.issuer, config.host); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }, alice); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + assert.notEqual(registerKeyResponse.body.challengeId, undefined); + assert.notEqual(registerKeyResponse.body.challenge, undefined); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); + assert.strictEqual(keyDoneResponse.body.name, keyName); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + assert.notEqual(signinResponse.body.challengeId, undefined); + assert.notEqual(signinResponse.body.challenge, undefined); + assert.notEqual(signinResponse.body.securityKeys, undefined); + assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); + + const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + })); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const passwordLessResponse = await api('/i/2fa/password-less', { + value: true, + }, alice); + assert.strictEqual(passwordLessResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + password: '', + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + + const signinResponse2 = await api('/signin', { + ...signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + }), + password: '', + }); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const renamedKey = 'other-key'; + const updateKeyResponse = await api('/i/2fa/update-key', { + name: renamedKey, + credentialId: credentialId.toString('hex'), + }, alice); + assert.strictEqual(updateKeyResponse.status, 200); + + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex')); + assert.strictEqual(securityKeys.length, 1); + assert.strictEqual(securityKeys[0].name, renamedKey); + assert.notEqual(securityKeys[0].lastUsed, undefined); + }); + + test('が設定でき、設定したセキュリティキーを削除できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + // テストの実行順によっては複数残ってるので全部消す + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + for (const key of iResponse.body.securityKeysList) { + const removeKeyResponse = await api('/i/2fa/remove-key', { + password, + credentialId: key.id, + }, alice); + assert.strictEqual(removeKeyResponse.status, 200); + } + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, false); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const unregisterResponse = await api('/i/2fa/unregister', { + password, + }, alice); + assert.strictEqual(unregisterResponse.status, 204); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); +});