LoginSignup
6
2

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

Last updated at Posted at 2023-12-01

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

説明しないこと

  • パスワード側の処理
  • ECDSAなど秘密鍵の生成部分
  • 秘密鍵の保存方法
  • 秘密鍵の同期方法

プロジェクト作成

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

image.png

今回はUIをStoryboardで作成

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

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

image.png
image.png

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

image.png

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

実装

ASCredentialProviderViewController を継承したコントローラが作成されるので、ここにパスキーを登録するための処理を記載していきます。

必要になるのは、下記2つのメソッドです

  • override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest)
  • override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest)

prepareInterface

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

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

image.png image.png

    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にして ビルドする
image.png

ビルド時に立ち上がるアプリを safari にして実行する
image.png

パスワードマネージャーとして認識されていれば 設定 > パスワード に項目がふえている
image.png

動かしてみる

動作確認ページ

パスキー作成で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"}

おわりに

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

サンプルコード

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2