iOS17からサードパーティ製のパスワードマネージャーにパスキーの保存ができるようになったので、どういう実装なのか調べてみました。
説明しないこと
- パスワード側の処理
- ECDSAなど秘密鍵の生成部分
- 秘密鍵の保存方法
- 秘密鍵の同期方法
プロジェクト作成
Xcodeで新規プロジェクトを作成
今回はUIをStoryboardで作成
まず、パスワードマネージャーとして認識させるために Signing & Capabilities に AutoFill Credential Provider を追加します。
まず、パスワードマネージャーとして認識させるために AutoFill Credential Provider を target に追加します。
次に、作成された Provider の Info.plist にパスキーを動かせるようにするために設定を追加します。
NSExtension > NSExtensionAttributes > ASCredentialProviderExtensionCapabilities > ProvidesPasskeys
設定が終わったら実装に入ります。
実装
ASCredentialProviderViewController を継承したコントローラが作成されるので、ここにパスキーを登録するための処理を記載していきます。
必要になるのは、下記2つのメソッドです
- override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest)
- override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest)
prepareInterface
パスキー作成時に表示される画面で「続ける」を押した時に呼び出されるメソッドを実装します。
override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) {
// パスキー作成時に必要なパラメータが入っている
let passkeyRequest = registrationRequest as! ASPasskeyCredentialRequest
let passkeyCredentialIdentity = passkeyRequest.credentialIdentity as! ASPasskeyCredentialIdentity
// このコードでは秘密鍵の保存名に利用、本来は別の登録と被らないようにしないといけない
let recordIdentifier = "sample"
let rpId = passkeyCredentialIdentity.relyingPartyIdentifier
let userName = passkeyCredentialIdentity.userName
let userHandle = passkeyCredentialIdentity.userHandle
var credentialId: Data?
var attestationObject: Data?
// サンプルなのでiOSのkeystoreを使って秘密鍵を作成
// アルゴリズムはECDSA P-256に固定、本来は送られてくる ASPasskeyCredentialRequest.supportedAlgorithms をみて判断する
let ecc = Ecc(alias: recordIdentifier)
do {
let key = try ecc.getPublicKey()
let attestation = Attestation(rpId: rpId)
try attestation.setECPublicKey(publicKey: key)
credentialId = attestation.getCredentialId()
attestationObject = try attestation.toCBOR()
} catch {
NSLog("error")
// cancelRequest だとモーダルが閉じないので空のクレデンシャルを返してる
self.extensionContext.completeRegistrationRequest(using: ASPasskeyRegistrationCredential())
return
}
// ブラウザ等に返すパスキーの情報
let passkeyCredential = ASPasskeyRegistrationCredential(
relyingParty: rpId,
clientDataHash: passkeyRequest.clientDataHash,
credentialID: credentialId!,
attestationObject: attestationObject!
)
// 処理が成功した場合に情報を返す
self.extensionContext.completeRegistrationRequest(using: passkeyCredential) { _ in
// パスキーを作成したので、呼び出せるようにデータを保存する
let store = ASCredentialIdentityStore.shared;
store.getState {state in
if state.isEnabled {
// navigator.credentials.get の項目に表示されるようにする
let credential = ASPasskeyCredentialIdentity(
relyingPartyIdentifier: rpId,
userName: userName,
credentialID: credentialId!,
userHandle: userHandle,
recordIdentifier: recordIdentifier // get でこの設定を呼び出した時に、秘密鍵を参照するのに使う値を入れる
)
store.saveCredentialIdentities([credential]) { bool, error in
if let error = error {
NSLog(error.localizedDescription)
} else {
NSLog("passkey save success")
}
}
}
}
}
}
パスキー情報の削除
ASCredentialIdentityStoreの中身を削除する処理をアプリ画面で実装する
AttestationObject
CBORを使うためにCBORCodingパッケージを追加
import Foundation
import CryptoKit
import CBORCoding
class Attestation : NSObject {
var encoder = CBOREncoder()
var rpIdHash: Data
var flags: Data
var signCount: Data = Data([0, 0, 0, 0])
// aaguidの割り当て方がわからなかったので適当な値
var aaguid: Data = Data([0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 2, 3, 4, 5, 6, 7])
var credentialId: Data
var credentialIdLength: Data
var credentialPublicKey: Data?
init(rpId: String) {
self.rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!))
// BE, BSフラグが立っていないとcompleteRegistrationRequestがフォーマットが違うと例外を吐く
self.flags = Data([ UInt8(UP | UV | BE | BS | AT) ])
// 固定値にしているが、本来はランダムな値を作りアプリ内で重複しないようにする
self.credentialId = Data([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])
self.credentialIdLength = Data([0, UInt8(self.credentialId.count)])
}
func setECPublicKey(publicKey: Data) throws -> Void {
credentialPublicKey = try encoder.encode(
CredentialPublicKeyEc(
typ: EC2,
alg: ES256,
crv: P256,
x: publicKey.subdata(in: 1..<33),
y: publicKey.subdata(in: 33..<65)
)
)
}
func generateAuthData() throws -> Data {
var authData: Data = Data()
if credentialPublicKey == nil {
throw NSError(domain: "credentialPublicKey is empty", code: 0, userInfo: nil)
}
authData.append(rpIdHash)
authData.append(flags)
authData.append(signCount)
authData.append(aaguid)
authData.append(credentialIdLength)
authData.append(credentialId)
authData.append(credentialPublicKey!)
return authData
}
func getCredentialId() -> Data {
return credentialId
}
func toCBOR() throws -> Data {
return try encoder.encode(
AttestationObject(
fmt: "none", // "packed"などの方式にするとcompleteRegistrationRequestがフォーマットが違うと例外を吐く
attStmt: [String:String](),
authData: try generateAuthData()
)
)
}
}
struct AttestationObject: Codable {
var fmt: String
var attStmt: [String: String]
var authData: Data
}
struct CredentialPublicKeyEc: Codable {
var typ: Int
var alg: Int
var crv: Int
var x: Data
var y: Data
private enum CodingKeys: Int, CodingKey {
case typ = 1
case alg = 3
case crv = -1
case x = -2
case y = -3
}
}
provideCredentialWithoutUserInteraction
AutoFill で保存した情報を選択するか、サインインの画面で「続ける」を押した時に呼び出されるメソッドを実装します。
override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest) {
NSLog("call: provideCredentialWithoutUserInteraction")
// ASCredentialRequest.type をみてハンドリング
if credentialRequest.type == .passkeyAssertion {
let passkeyRequest = credentialRequest as! ASPasskeyCredentialRequest
let passkeyCredentialIdentity = passkeyRequest.credentialIdentity as! ASPasskeyCredentialIdentity
let recordIdentifier = passkeyCredentialIdentity.recordIdentifier!
let rpId = passkeyCredentialIdentity.relyingPartyIdentifier
let userHandle = passkeyCredentialIdentity.userHandle
let credentialId = passkeyCredentialIdentity.credentialID
let assertion = Assertion(rpId: rpId)
let authenticatorData = assertion.toData()
var signature: Data?
var message = Data()
message.append(authenticatorData)
message.append(passkeyRequest.clientDataHash)
let ecc = Ecc(alias: recordIdentifier)
do {
signature = try ecc.signature(message)
} catch {
NSLog("error")
// cancelRequest だとモーダルが閉じないので空のクレデンシャルを返してる
self.extensionContext.completeAssertionRequest(using: ASPasskeyAssertionCredential())
return
}
// ブラウザ等に返すパスキーの情報
let passkeyCredential = ASPasskeyAssertionCredential(
userHandle: userHandle,
relyingParty: rpId,
signature: signature!,
clientDataHash: passkeyRequest.clientDataHash,
authenticatorData: authenticatorData,
credentialID: credentialId
)
// 処理が成功した場合に情報を返す
self.extensionContext.completeAssertionRequest(using: passkeyCredential)
} else {
// パスワードの場合の処理
}
}
Assertion
import Foundation
import CryptoKit
class Assertion : NSObject {
var rpIdHash: Data
var flags: Data
var signCount: Data = Data([0, 0, 0, 0])
init(rpId: String) {
self.rpIdHash = Data(SHA256.hash(data: rpId.data(using: .utf8)!))
// BE, BSフラグが立っていないとcompleteRegistrationRequestがフォーマットが違うと例外を吐く
self.flags = Data([ UInt8(UP | UV | BE | BS) ])
}
func toData() -> Data {
var authenticatorData: Data = Data()
authenticatorData.append(rpIdHash)
authenticatorData.append(flags)
authenticatorData.append(signCount)
return authenticatorData
}
}
ビルド
作成したアプリに問題がなければ target を Providerにして ビルドする
パスワードマネージャーとして認識されていれば 設定 > パスワード
に項目がふえている
動かしてみる
パスキー作成でJSに送られてくる中身
# CredentialID
AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE
# Attestation
{"fmt":"none","attStmt":{},"authData.rpIdHash":"d5ef9306a0e5f55d585b0b979287deaaf404ad25ba148cd29dc3da8afe985095","authData.flags":{"data":221,"UP":true,"UV":true,"BE":true,"BS":true,"AT":true,"ED":false},"authData.signCount":0,"authData.aaguid":"00000001-0001-0001-0001020304050607","authData.credentialIdLength":32,"authData.credentialId":{"0":1,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0,"30":0,"31":1},"authData.publicKeyCbor":{1:2,3:-7,-1:1,-2:{"0":115,"1":195,"2":13,"3":235,"4":73,"5":35,"6":25,"7":44,"8":232,"9":79,"10":167,"11":155,"12":101,"13":129,"14":63,"15":9,"16":6,"17":103,"18":149,"19":173,"20":109,"21":148,"22":63,"23":61,"24":1,"25":245,"26":19,"27":109,"28":221,"29":51,"30":121,"31":120},-3:{"0":66,"1":168,"2":202,"3":162,"4":171,"5":206,"6":154,"7":60,"8":152,"9":12,"10":19,"11":246,"12":4,"13":113,"14":143,"15":8,"16":149,"17":152,"18":121,"19":76,"20":4,"21":208,"22":113,"23":9,"24":191,"25":135,"26":148,"27":162,"28":229,"29":6,"30":136,"31":144}}}
# ClientDataJSON
{"type":"webauthn.create","challenge":"bXlfY2hhbGxlbmdlLm15X2NoYWxsZW5nZQ","origin":"https://tucur-prg.github.io"}
パスキー認証でJSに送られてくる中身
# CredentialID
AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE
# Authenticator
{"rpIdHash":"d5ef9306a0e5f55d585b0b979287deaaf404ad25ba148cd29dc3da8afe985095","flags":{"data":29,"UP":true,"UV":true,"BE":true,"BS":true,"AT":false,"ED":false},"signCount":0}
# ClientDataJSON
{"type":"webauthn.get","challenge":"bXlfY2hhbGxlbmdlLm15X2NoYWxsZW5nZQ","origin":"https://tucur-prg.github.io"}
おわりに
以上がパスワードマネージャーにパスキー部分の実装に関わる部分です。
必要最小限なコードなので例外処理やクレデンシャルの管理部分など、だいぶ削ってあります。