Passkeys

패스키는 Windows Hello, iCloud Keychain, Google Password Manager, 비밀번호 관리자, FIDO2 보안 키와 같은 사용자의 인증기가 생성하는 키 쌍으로 비밀번호를 대체합니다. 개인 키는 절대 인증기를 벗어나지 않고 서버는 공개 키만 저장하며, 서명은 사이트의 도메인에 종속되므로 패스키는 피싱되거나, 재사용되거나, 서버 데이터베이스에서 유출될 수 없습니다.

기술적으로 패스키는 검색 가능한 WebAuthn 자격 증명(resident key)입니다. 인증기는 사용자 핸들을 개인 키와 함께 보관하므로, 사용자는 사용자 이름을 입력하지 않고도 로그인할 수 있고, 브라우저는 사용자 이름 필드의 자동 완성 목록에서 해당 사이트의 패스키를 제공할 수 있습니다. TsgcWSAPIServer_WebAuthn 서버는 아래 설명된 설정과 이벤트로 패스키를 지원합니다.

Availability

WebAuthn 서버는 sgcWebSockets Enterprise와 sgcAuth 팩에 포함되어 있습니다. WebAuthn은 보안 컨텍스트가 필요합니다. 페이지를 https(또는 localhost)로 제공하고, WebAuthnOptions.RelyingParty를 브라우저에 표시되는 호스트 이름으로 설정하십시오.

검색 가능한 자격 증명 등록

WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential 속성은 등록 옵션에서 요청되는 residentKey을(를) 설정합니다.

값residentKeyMeaning
waundcPreferred (default)preferred인증기는 가능한 경우 검색 가능한 자격 증명을 생성합니다.
waundcRequiredrequired검색 가능한(discoverable) 자격 증명만 허용됩니다. 패스키와 사용자 이름 없는 로그인에 사용하십시오.
waundcDiscourageddiscouraged인증기는 서버 측 자격 증명을 생성해야 하며, 이는 로그인 시 사용자 이름이 필요합니다.

등록 요청은 discoverable_credential 필드("required", "preferred" 또는 "discouraged")로 기본값을 재정의할 수 있습니다. 이 설정은 요청 자체가 특정 residentKey을(를) 요구하지 않을 때만 적용됩니다.


WebAuthn.WebAuthnOptions.RelyingParty := 'www.example.com';
// ... passkeys are discoverable credentials: require a resident key
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential :=
  waundcRequired;
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.UserVerification :=
  waunuvPreferred;
WebAuthn.WebAuthnOptions.DefaultOptions.Authentication.UserVerification :=
  waunuvPreferred;

사용자당 여러 패스키

사용자는 보통 여러 개의 패스키를 가지고 있습니다. 예를 들어 컴퓨터에 하나, 휴대폰에 하나씩입니다. 각 패스키는 동일한 사용자 이름으로 등록하십시오. 사용자 이름에 이미 서버가 알고 있는 자격 증명이 있으면 등록 시 해당 사용자 핸들(user.id)을 재사용하므로, 해당 계정의 모든 패스키가 하나의 사용자 핸들을 공유하게 됩니다. 서버는 실행 중에 등록된 자격 증명과 AddCredential로 추가된 자격 증명을 알고 있으므로, 자격 증명이 데이터베이스에 저장되어 있다면 시작 시 이를 추가하여 계정당 하나의 사용자 핸들을 유지하십시오. 사용자 이름을 지정하면 인증 옵션에는 allowCredentials에 있는 해당 사용자의 모든 패스키가 포함됩니다.

사용자 이름 없는 로그인

빈 사용자 이름으로 인증 옵션을 요청하십시오. 그러면 옵션에는 allowCredentials이(가) 포함되지 않으며, 브라우저는 해당 relying party를 위해 보유한 패스키를 표시하고 사용자가 그중 하나를 선택합니다. 서버는 응답의 userHandle을(를) 요구하며 이것이 사용된 자격 증명에 속하는지 확인하고, 애플리케이션이 알지 못하는 자격 증명은 거부됩니다.


// ... "Sign in with a passkey" button: the browser lists the passkeys of the site
const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
const assertion = await SimpleWebAuthnBrowser.startAuthentication(options);
const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);

패스키 자동 완성

조건부 중재(conditional mediation)를 사용하면 패스키가 저장된 비밀번호 옆, 사용자 이름 필드의 자동 완성 목록에 나타납니다. 입력란에 autocomplete="username webauthn"을(를) 추가하고, 페이지 로드 시 /sgcWebAuthn.js(컴포넌트가 제공, 엔드포인트 EndpointsOptions.WebAuthn)을(를) 로드한 뒤 사용자 이름 없는 옵션으로 startAuthentication(options, true)을(를) 호출하십시오. 사용자가 패스키를 선택하면 프라미스가 완료됩니다.


<script src="/sgcWebAuthn.js"></script>
<input id="username" name="username" autocomplete="username webauthn">
<script>
async function post(url, body) {
  const r = await fetch(url, {method: "POST",
    headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
  return r.json();
}
async function passkeyAutofill() {
  if (!(await SimpleWebAuthnBrowser.browserSupportsWebAuthnAutofill())) return;
  // ... no user name: the options carry no allowCredentials
  const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
  // ... true: conditional mediation, the passkeys appear in the autofill list
  const assertion = await SimpleWebAuthnBrowser.startAuthentication(options, true);
  const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);
  if (result.verified) { location.href = "/"; }
}
passkeyAutofill();
</script>

자동 완성(autofill)에는 조건부 중재(conditional mediation)를 지원하는 브라우저(현재 Chrome, Edge, Safari)가 필요합니다. 다른 브라우저에서는 browserSupportsWebAuthnAutofill()이(가) False를 반환하므로, 해당 브라우저를 위해 "Sign in with a passkey" 버튼을 유지하십시오.

패스키를 자체 데이터베이스에 저장하기

이 컴포넌트는 자격 증명을 지속적으로 저장하지 않습니다. 다음 네 가지 이벤트가 저장소와 연결됩니다.

이벤트해야 할 일
OnWebAuthnRegistrationSuccessfulaCredentialRecord(예: aCredentialRecord.AsJSON)을(를) 해당 CredentialId, Username와(과) 함께 저장하십시오.
OnWebAuthnAuthenticationOptionsRequest사용자 이름을 지정하면 해당 사용자의 패스키를 CredentialRecords에 추가하십시오. 사용자 이름이 없으면 아무것도 추가하지 마십시오.
OnWebAuthnAuthenticationGetCredential브라우저가 사용한 자격 증명이 세리머니의 자격 증명 목록에 없을 때 발생하며, 이는 사용자 이름 없는 로그인이나 자동 완성 로그인의 경우입니다. 자격 증명 id를 조회하여 aCredentialRecord을(를) 채우고 Found을(를) True로 설정하십시오.
OnWebAuthnAuthenticationSuccessfulSignCount과(와) BackupState을(를) aAuthentication.Credential.CredentialRecord에 대해 갱신하여 저장하고 세션을 생성하십시오.

// ... 1. registration: store the new passkey (the whole record as JSON)
procedure TForm1.WebAuthnWebAuthnRegistrationSuccessful(Sender: TObject;
  const aRegistration: TsgcWebAuthn_Registration;
  const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Accept: Boolean);
begin
  DBInsertPasskey(aCredentialRecord.CredentialId, aCredentialRecord.Username,
    aCredentialRecord.AsJSON);
end;

// ... 2. sign in with a user name: the passkeys of that user
procedure TForm1.WebAuthnWebAuthnAuthenticationOptionsRequest(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationOptions_Request;
  var CredentialRecords: TsgcWebAuthn_CredentialRecords; var Accept: Boolean);
var
  i: Integer;
  oList: TStringList;
begin
  if aRequest.Username = '' then
    Exit; // ... usernameless or autofill: no allowCredentials
  oList := TStringList.Create;
  try
    DBGetPasskeysOfUser(aRequest.Username, oList); // ... one JSON per line
    for i := 0 to oList.Count - 1 do
      CredentialRecords.AddCredentialRecordFromJSON(oList[i]);
    Accept := oList.Count > 0;
  finally
    oList.Free;
  end;
end;

// ... 3. usernameless and autofill: find the passkey by its credential id
procedure TForm1.WebAuthnWebAuthnAuthenticationGetCredential(Sender: TObject;
  const aCredentialId: string;
  const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Found: Boolean);
var
  vJSON: string;
begin
  Found := DBFindPasskey(aCredentialId, vJSON);
  if Found then
    aCredentialRecord.ReadJSON(vJSON);
end;

// ... 4. signed in: save the new signature counter and backup state
procedure TForm1.WebAuthnWebAuthnAuthenticationSuccessful(Sender: TObject;
  const aRequest: TsgcWebAuthn_AuthenticationVerify_Request;
  const aAuthentication: TsgcWebAuthn_Authentication; var Accept: Boolean);
var
  oRecord: TsgcWebAuthn_CredentialRecord;
begin
  oRecord := aAuthentication.Credential.CredentialRecord;
  DBUpdatePasskey(oRecord.CredentialId, oRecord.AsJSON);
  CreateUserSession(oRecord.Username);
end;

OnWebAuthnAuthenticationGetCredential이(가) 반환하는 레코드는 요청된 CredentialId를 가져야 하며, 세리머니가 사용자 이름으로 시작된 경우 동일한 Username도 가져야 합니다. 그렇지 않으면 로그인이 실패합니다. 저장된 레코드에 UserId(사용자 핸들)를 유지하십시오. 이는 인증기가 전송하는 userHandle과(와) 비교됩니다. 이 이벤트는 서버 연결 스레드에서 실행되므로 공유 리소스를 적절히 보호하십시오.

동기화됨 또는 디바이스 종속

인증기 데이터 플래그는 서버가 수신한 패스키의 종류를 나타냅니다. 이는 자격 증명 레코드에 BackupEligible(BE 플래그)과 BackupState(BS 플래그)로 저장됩니다.

BackupEligibleBackupStateMeaning
TrueTrue플랫폼이나 비밀번호 관리자에 의해 백업되며, 사용자의 다른 디바이스에서도 사용할 수 있는 동기화된 패스키입니다.
TrueFalse아직 백업되지 않은 다중 디바이스 패스키입니다.
FalseFalse보안 키나 디바이스를 벗어나지 않는 TPM 키와 같이 디바이스에 종속된 패스키입니다. 사용자에게 두 번째 패스키를 등록하도록 권장하십시오.

function GetPasskeyKind(aRecord: TsgcWebAuthn_CredentialRecord): string;
begin
  if aRecord.BackupEligible and aRecord.BackupState then
    Result := 'synced passkey'
  else if aRecord.BackupEligible then
    Result := 'multi-device passkey, not backed up yet'
  else
    Result := 'device-bound passkey';
end;

로그인할 때마다 서버는 BS 플래그가 설정되고 BE 플래그가 설정되지 않은 응답, 그리고 BE 플래그가 저장된 BackupEligible과 다른 응답을 거부합니다. 자격 증명의 적격성은 결코 변경되지 않기 때문입니다. 새 BS 플래그는 BackupState에 복사되며, OnWebAuthnAuthenticationSuccessful에서 저장하십시오.

서명 카운터와 복제된 인증기

일부 인증기는 로그인할 때마다 서명 카운터를 증가시킵니다. 응답의 카운터나 저장된 SignCount이(가) 0이 아닌 경우, 응답의 카운터는 저장된 값보다 커야 합니다. 그렇지 않으면 인증기가 복제되었을 가능성이 있으므로 로그인이 거부됩니다. 저장된 값은 항상 앞으로만 이동하며, 탐지가 작동하려면 매 로그인 후 저장해야 합니다. 동기화된 패스키는 보통 항상 0을 보고하므로 이 경우 검사가 비활성화됩니다. 증가하지 않는 카운터를 허용하려면 WebAuthnOptions.AllowSignCountLessOrEqualStoredValue를 True로 설정하십시오(저장된 값은 여전히 절대 낮아지지 않습니다).

Demo

Demos\26.Authentication\01.Passkeys 데모는 사용자당 여러 패스키를 등록하고, 사용자 이름 없이 그리고 자동 완성으로 로그인하며, 각 패스키가 동기화되었는지 디바이스 종속인지 표시하고, 위 이벤트를 통해 자격 증명을 애플리케이션의 JSON 파일에 저장합니다. WebAuthn과(와) JavaScript 클라이언트도 참조하십시오.