Passkeys

パスキーは、パスワードを、ユーザーの認証器(Windows Hello、iCloud Keychain、Google Password Manager、パスワードマネージャー、またはFIDO2セキュリティキー)が作成する鍵ペアで置き換えます。秘密鍵は決して認証器から出ることがなく、サーバーは公開鍵のみを保存します。また署名はサイトのドメインに紐付けられるため、パスキーはフィッシング、再利用、サーバーデータベースからの漏洩の対象になりません。

技術的には、パスキーは検出可能なWebAuthn資格情報(レジデントキー)です。認証器は秘密鍵の隣にユーザーハンドルを保持するため、ユーザーはユーザー名を入力せずにサインインでき、ブラウザはユーザー名フィールドのオートフィルリストにそのサイトのパスキーを提示できます。TsgcWSAPIServer_WebAuthnサーバーは、以下で説明する設定とイベントによってパスキーをサポートします。

Availability

WebAuthnサーバーはsgcWebSockets EnterpriseおよびsgcAuthパックに含まれています。WebAuthnにはセキュアコンテキストが必要です。ページはhttps(またはlocalhost)で提供し、WebAuthnOptions.RelyingPartyをブラウザに表示されるホスト名に設定してください。

検出可能な資格情報の登録

プロパティWebAuthnOptions.DefaultOptions.Registration.DiscoverableCredentialは、登録オプションで要求されるresidentKeyを設定します。

値residentKeyMeaning
waundcPreferred (default)preferred可能な場合、認証器は検出可能な資格情報を作成します。
waundcRequiredrequired検出可能な資格情報(discoverable credential)のみが受け入れられます。パスキーおよびユーザー名なしサインインに使用してください。
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;

ユーザーごとの複数のパスキー

ユーザーは通常、複数のパスキーを持ちます。例えばコンピュータに1つ、スマートフォンに1つです。それぞれを同じユーザー名で登録してください。ユーザー名にすでにサーバーが把握している資格情報がある場合、登録はそのユーザーハンドル(user.id)を再利用するため、アカウントのすべてのパスキーが1つのユーザーハンドルを共有します。サーバーは、実行中に登録された資格情報とAddCredentialで追加された資格情報を把握しているため、資格情報をデータベースに保存している場合は、アカウントごとに1つのユーザーハンドルを維持するために、起動時にそれらを追加してください。ユーザー名を指定すると、認証オプションにはallowCredentials内のそのユーザーのすべてのパスキーが含まれます。

ユーザー名なしサインイン

空のユーザー名で認証オプションを要求してください。その場合、オプションにはallowCredentialsが含まれず、ブラウザはリライングパーティ向けに保持しているパスキーを表示し、ユーザーがその中から1つを選びます。サーバーはレスポンスの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);

パスキーオートフィル

条件付きメディエーションでは、パスキーは保存済みパスワードと並んで、ユーザー名フィールドのオートフィルリストに表示されます。入力欄にautocomplete="username webauthn"を追加し、/sgcWebAuthn.js(コンポーネントが提供、エンドポイントEndpointsOptions.WebAuthn)を読み込み、ページ読み込み時にユーザー名なしオプションでstartAuthentication(options, true)を呼び出してください。ユーザーがパスキーを選択すると、このpromiseは解決します。


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

オートフィルには、条件付きメディエーション(conditional mediation)をサポートするブラウザ(現行のChrome、Edge、Safari)が必要です。browserSupportsWebAuthnAutofill()はそれ以外のブラウザではFalseを返すため、それらのブラウザ向けに「パスキーでサインイン」ボタンを残しておいてください。

パスキーを独自のデータベースに保存する

コンポーネントは資格情報を永続化しません。4つのイベントによって、コンポーネントを独自のストレージに接続します。

イベント対処方法
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鍵です。ユーザーに2つ目のパスキーの登録を推奨してください。

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フラグが設定されていないレスポンス、および保存されているBackupEligibleとBEフラグが異なるレスポンスを拒否します。資格情報の適格性は決して変化しないためです。新しいBSフラグはBackupStateにコピーされるので、OnWebAuthnAuthenticationSuccessfulで保存してください。

署名カウンタと複製された認証器

一部の認証器は、サインインのたびに署名カウンタを増加させます。レスポンスのカウンタ、または保存されているSignCountのいずれかがゼロでない場合、レスポンスのカウンタは保存されている値より大きくなければなりません。そうでない場合、認証器が複製された可能性があるため、サインインは拒否されます。保存される値は常に前進するのみで、検出を機能させるにはサインインのたびに保存する必要があります。同期型パスキーは通常常に0を報告するため、その場合このチェックは無効になります。増加しないカウンタを受け入れるにはWebAuthnOptions.AllowSignCountLessOrEqualStoredValueをTrueに設定してください(それでも保存されている値が下がることはありません)。

Demo

デモDemos\26.Authentication\01.Passkeysは、ユーザーごとに複数のパスキーを登録し、ユーザー名なしおよびオートフィルでサインインし、各パスキーが同期型かデバイス固定型かを表示し、上記のイベントを通じてアプリケーションのJSONファイルに資格情報を保存します。WebAuthnおよびJavaScriptクライアントも参照してください。