iOS17でパスワードマネージャにパスキーが登録できるようになったのでコード書いてみた


iOS17からサードパーティ製のパスワードマネージャーにパスキーの保存ができるようになったので、どういう実装なのか調べてみました。


## 説明しないこと
* パスワード側の処理
* ECDSAなど秘密鍵の生成部分
* 秘密鍵の保存方法
* 秘密鍵の同期方法


## プロジェクト作成

Xcodeで新規プロジェクトを作成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/14ff6a71-c285-6980-ebbd-2a5b83245413.png)

今回はUIをStoryboardで作成

まず、パスワードマネージャーとして認識させるために Signing & Capabilities に AutoFill Credential Provider を追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/b5b748dc-b71a-88f7-12bd-b71523754079.png)


まず、パスワードマネージャーとして認識させるために AutoFill Credential Provider を target に追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/60594272-fd7e-e7f1-6c90-0163edd4cf5b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/f8d173af-daeb-7960-018a-e17b8e459702.png)

次に、作成された Provider の Info.plist にパスキーを動かせるようにするために設定を追加します。
NSExtension > NSExtensionAttributes > ASCredentialProviderExtensionCapabilities > ProvidesPasskeys

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/c1c5ea15-f0da-dde3-7f7e-bc71204f1454.png)


設定が終わったら実装に入ります。

## 実装
[ASCredentialProviderViewController](https://developer.apple.com/documentation/authenticationservices/ascredentialproviderviewcontroller) を継承したコントローラが作成されるので、ここにパスキーを登録するための処理を記載していきます。

必要になるのは、下記２つのメソッドです
* override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest)
* override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest)

### prepareInterface

パスキー作成時に表示される画面で「続ける」を押した時に呼び出されるメソッドを実装します。

<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/d30b0a07-2dfb-1e2f-e657-647899096713.png">


```swift
    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")
                        }
                    }
                }
            }
        }
    }
```

:::note info
パスキー情報の削除
[ASCredentialIdentityStore](https://developer.apple.com/documentation/authenticationservices/ascredentialidentitystore)の中身を削除する処理をアプリ画面で実装する
:::

#### AttestationObject
CBORを使うために[CBORCoding](https://github.com/SomeRandomiOSDev/CBORCoding)パッケージを追加

```swift
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 で保存した情報を選択するか、サインインの画面で「続ける」を押した時に呼び出されるメソッドを実装します。

<img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/76404a75-a3d7-7983-9727-74fa1d828330.png"> <img width="300" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/2f1cd147-dd34-5d02-8eb4-cc7d4c53a1d4.png">


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

```swift
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にして ビルドする
<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/e934b73e-04bf-2e7f-7852-a3e225ed5b39.png">

ビルド時に立ち上がるアプリを safari にして実行する
<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/38f6347b-9c91-03f7-2e96-85b7c1216ea4.png">

パスワードマネージャーとして認識されていれば `設定 > パスワード` に項目がふえている
<img width="400" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110741/144021cd-d163-8fa4-233a-d6c17a883f3f.png">

## 動かしてみる

[動作確認ページ](https://tucur-prg.github.io/fido2-example/client/webauthn5.html)

<details><summary>パスキー作成でJSに送られてくる中身</summary>

```
# 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"}
```
</details>

<details><summary>パスキー認証でJSに送られてくる中身</summary>

```
# 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"}
```
</details>



## おわりに
以上がパスワードマネージャーにパスキー部分の実装に関わる部分です。
必要最小限なコードなので例外処理やクレデンシャルの管理部分など、だいぶ削ってあります。

[サンプルコード](https://github.com/tucur-prg/password-manager)