fix possible replay attack with passkey login

This commit is contained in:
NekoCH
2026-01-31 15:12:16 +08:00
parent c162fc81be
commit 8207f1ed6f
5 changed files with 62 additions and 51 deletions

View File

@@ -15,7 +15,25 @@ class UserPasskeyRepository extends BaseRepository
public static function createWebAuthn() public static function createWebAuthn()
{ {
$formats = ['android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm', 'none']; $formats = ['android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm', 'none'];
return new WebAuthn(get_setting('basic.SITENAME'), nexus()->getRequestHost(), $formats); $rpId = explode(':', nexus()->getRequestHost())[0];
return new WebAuthn(get_setting('basic.SITENAME'), $rpId, $formats);
}
private static function putChallenge($challenge): string
{
$challengeId = bin2hex(random_bytes(32));
NexusDB::cache_put("passkey_challenge_{$challengeId}", $challenge, 120);
return $challengeId;
}
private static function getChallenge($challengeId)
{
$challenge = NexusDB::cache_get("passkey_challenge_{$challengeId}") ?? null;
if ($challenge == null) {
throw new RuntimeException(nexus_trans('passkey.passkey_timeout'));
}
NexusDB::cache_del("passkey_challenge_{$challengeId}");
return $challenge;
} }
public static function getCreateArgs($userId, $userName) public static function getCreateArgs($userId, $userName)
@@ -28,20 +46,22 @@ class UserPasskeyRepository extends BaseRepository
return hex2bin($item['credential_id']); return hex2bin($item['credential_id']);
}, $passkey->toArray()); }, $passkey->toArray());
$createArgs = $WebAuthn->getCreateArgs(bin2hex($userId), $userName, $userName, 60 * 4, true, 'preferred', null, $credentialIds); $createArgs = $WebAuthn->getCreateArgs(bin2hex($userId), $userName, $userName, 120, true, 'preferred', null, $credentialIds);
$challengeId = self::putChallenge($WebAuthn->getChallenge());
NexusDB::cache_put("{$userId}_passkey_challenge", $WebAuthn->getChallenge(), 60 * 4); return [
'challengeId' => $challengeId,
return $createArgs; 'options' => $createArgs,
];
} }
public static function processCreate($userId, $clientDataJSON, $attestationObject) public static function processCreate($userId, $challengeId, $clientDataJSON, $attestationObject)
{ {
$WebAuthn = self::createWebAuthn(); $challenge = self::getChallenge($challengeId);
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null; $clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
$attestationObject = !empty($attestationObject) ? base64_decode($attestationObject) : null; $attestationObject = !empty($attestationObject) ? base64_decode($attestationObject) : null;
$challenge = NexusDB::cache_get("{$userId}_passkey_challenge") ?? null;
$WebAuthn = self::createWebAuthn();
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false); $data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true, false);
@@ -60,9 +80,13 @@ class UserPasskeyRepository extends BaseRepository
{ {
$WebAuthn = self::createWebAuthn(); $WebAuthn = self::createWebAuthn();
$getArgs = $WebAuthn->getGetArgs(null, 60 * 4, true, true, true, true, true, 'preferred'); $getArgs = $WebAuthn->getGetArgs(null, 120, true, true, true, true, true, 'preferred');
$challengeId = self::putChallenge($WebAuthn->getChallenge());
return $getArgs; return [
'challengeId' => $challengeId,
'options' => $getArgs,
];
} }
public static function insertUserPasskey($userId, $AAGUID, $credentialId, $publicKey, $counter) public static function insertUserPasskey($userId, $AAGUID, $credentialId, $publicKey, $counter)
@@ -77,21 +101,19 @@ class UserPasskeyRepository extends BaseRepository
Passkey::query()->create($params); Passkey::query()->create($params);
} }
public static function processGet($challenge, $id, $clientDataJSON, $authenticatorData, $signature, $userHandle) public static function processGet($challengeId, $id, $clientDataJSON, $authenticatorData, $signature, $userHandle)
{ {
$WebAuthn = self::createWebAuthn(); $challenge = self::getChallenge($challengeId);
$clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null; $clientDataJSON = !empty($clientDataJSON) ? base64_decode($clientDataJSON) : null;
$id = !empty($id) ? base64_decode($id) : null; $id = !empty($id) ? base64_decode($id) : null;
$authenticatorData = !empty($authenticatorData) ? base64_decode($authenticatorData) : null; $authenticatorData = !empty($authenticatorData) ? base64_decode($authenticatorData) : null;
$signature = !empty($signature) ? base64_decode($signature) : null; $signature = !empty($signature) ? base64_decode($signature) : null;
$userHandle = !empty($userHandle) ? base64_decode($userHandle) : null; $userHandle = !empty($userHandle) ? base64_decode($userHandle) : null;
$challenge = !empty($challenge) ? base64_decode($challenge) : null;
$WebAuthn = self::createWebAuthn();
$passkey = Passkey::query()->where('credential_id', '=', bin2hex($id))->first(); $passkey = Passkey::query()->where('credential_id', '=', bin2hex($id))->first();
$credentialPublicKey = $passkey->public_key; if ($passkey === null) {
if ($credentialPublicKey === null) {
throw new RuntimeException(nexus_trans('passkey.passkey_unknown')); throw new RuntimeException(nexus_trans('passkey.passkey_unknown'));
} }
@@ -100,7 +122,7 @@ class UserPasskeyRepository extends BaseRepository
} }
try { try {
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, 'preferred'); $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $passkey->public_key, $challenge, null, false, true);
} catch (Exception $e) { } catch (Exception $e) {
throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage()); throw new RuntimeException(nexus_trans('passkey.passkey_error') . "\n" . $e->getMessage());
} }

View File

@@ -194,7 +194,7 @@ class AjaxInterface{
{ {
global $CURUSER; global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository(); $rep = new \App\Repositories\UserPasskeyRepository();
return $rep->processCreate($CURUSER['id'], $params['clientDataJSON'], $params['attestationObject']); return $rep->processCreate($CURUSER['id'], $params['challengeId'], $params['clientDataJSON'], $params['attestationObject']);
} }
public static function deletePasskey($params) public static function deletePasskey($params)
@@ -222,7 +222,7 @@ class AjaxInterface{
{ {
global $CURUSER; global $CURUSER;
$rep = new \App\Repositories\UserPasskeyRepository(); $rep = new \App\Repositories\UserPasskeyRepository();
return $rep->processGet($params['challenge'], $params['id'], $params['clientDataJSON'], $params['authenticatorData'], $params['signature'], $params['userHandle']); return $rep->processGet($params['challengeId'], $params['id'], $params['clientDataJSON'], $params['authenticatorData'], $params['signature'], $params['userHandle']);
} }
} }

47
public/js/passkey.js vendored
View File

@@ -13,9 +13,9 @@ const Passkey = (() => {
return await PublicKeyCredential.isConditionalMediationAvailable(); return await PublicKeyCredential.isConditionalMediationAvailable();
} }
const getCreateArgs = async () => { const getArgs = async (type) => {
const getArgsParams = new URLSearchParams(); const getArgsParams = new URLSearchParams();
getArgsParams.set('action', 'getPasskeyCreateArgs'); getArgsParams.set('action', type === 'create' ? 'getPasskeyCreateArgs' : 'getPasskeyGetArgs');
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: 'POST', method: 'POST',
@@ -26,18 +26,22 @@ const Passkey = (() => {
throw new Error(data.msg); throw new Error(data.msg);
} }
const createArgs = data.data; const options = data.data.options;
recursiveBase64StrToArrayBuffer(createArgs); recursiveBase64StrToArrayBuffer(options);
return createArgs; return {
challengeId: data.data.challengeId,
options: options,
};
} }
const createRegistration = async () => { const createRegistration = async () => {
const createArgs = await getCreateArgs(); const args = await getArgs('create');
const cred = await navigator.credentials.create(createArgs); const cred = await navigator.credentials.create(args.options);
const processCreateParams = new URLSearchParams(); const processCreateParams = new URLSearchParams();
processCreateParams.set('action', 'processPasskeyCreate'); processCreateParams.set('action', 'processPasskeyCreate');
processCreateParams.set('params[challengeId]', args.challengeId);
processCreateParams.set('params[transports]', cred.response.getTransports ? cred.response.getTransports() : null) processCreateParams.set('params[transports]', cred.response.getTransports ? cred.response.getTransports() : null)
processCreateParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null); processCreateParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null);
processCreateParams.set('params[attestationObject]', cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null); processCreateParams.set('params[attestationObject]', cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null);
@@ -52,24 +56,6 @@ const Passkey = (() => {
} }
} }
const getGetArgs = async () => {
const getArgsParams = new URLSearchParams();
getArgsParams.set('action', 'getPasskeyGetArgs');
const response = await fetch(apiUrl, {
method: 'POST',
body: getArgsParams,
});
const data = await response.json();
if (data.ret !== 0) {
throw new Error(data.msg);
}
const getArgs = data.data;
recursiveBase64StrToArrayBuffer(getArgs);
return getArgs;
}
let abortController; let abortController;
const checkRegistration = async (conditional, showLoading) => { const checkRegistration = async (conditional, showLoading) => {
@@ -78,20 +64,21 @@ const Passkey = (() => {
abortController = null; abortController = null;
} }
if (!conditional) showLoading(); if (!conditional) showLoading();
const getArgs = await getGetArgs(); const args = await getArgs('get');
const options = args.options;
if (conditional) { if (conditional) {
abortController = new AbortController(); abortController = new AbortController();
getArgs.signal = abortController.signal; options.signal = abortController.signal;
getArgs.mediation = 'conditional'; options.mediation = 'conditional';
} }
const cred = await navigator.credentials.get(getArgs); const cred = await navigator.credentials.get(options);
if (conditional) showLoading(); if (conditional) showLoading();
const processGetParams = new URLSearchParams(); const processGetParams = new URLSearchParams();
processGetParams.set('action', 'processPasskeyGet'); processGetParams.set('action', 'processPasskeyGet');
processGetParams.set('params[challenge]', arrayBufferToBase64(getArgs['publicKey']['challenge'])); processGetParams.set('params[challengeId]', args.challengeId);
processGetParams.set('params[id]', cred.rawId ? arrayBufferToBase64(cred.rawId) : null); processGetParams.set('params[id]', cred.rawId ? arrayBufferToBase64(cred.rawId) : null);
processGetParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null); processGetParams.set('params[clientDataJSON]', cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null);
processGetParams.set('params[authenticatorData]', cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null); processGetParams.set('params[authenticatorData]', cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null);

View File

@@ -11,6 +11,7 @@ return [
'passkey_delete' => 'Delete', 'passkey_delete' => 'Delete',
'passkey_unknown' => 'An error occurred while processing your request.', 'passkey_unknown' => 'An error occurred while processing your request.',
'passkey_invalid' => 'Invalid passkey data.', 'passkey_invalid' => 'Invalid passkey data.',
'passkey_timeout' => 'Operation timeout, please try again.',
'passkey_error' => 'An error occurred while processing your request. Please try again later.', 'passkey_error' => 'An error occurred while processing your request. Please try again later.',
'passkey_user_not_found' => 'User not found.', 'passkey_user_not_found' => 'User not found.',
'passkey_not_supported' => 'Your browser does not support passkey. Please use a modern browser to create and manage your passkey.', 'passkey_not_supported' => 'Your browser does not support passkey. Please use a modern browser to create and manage your passkey.',

View File

@@ -11,6 +11,7 @@ return [
'passkey_delete' => '删除', 'passkey_delete' => '删除',
'passkey_unknown' => '处理您的请求时出错。', 'passkey_unknown' => '处理您的请求时出错。',
'passkey_invalid' => '通行密钥数据无效。', 'passkey_invalid' => '通行密钥数据无效。',
'passkey_timeout' => '操作超时,请重试。',
'passkey_error' => '处理您的请求时出错。请稍后重试。', 'passkey_error' => '处理您的请求时出错。请稍后重试。',
'passkey_user_not_found' => '未找到用户。', 'passkey_user_not_found' => '未找到用户。',
'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。', 'passkey_not_supported' => '您的浏览器不支持通行密钥。请使用现代浏览器创建和管理您的通行密钥。',