Quantumleap
2267 words
11 minutes
KeychainAccessの理解を深めよう

KeychainAccess#

KeychainAccess とは Keychain に簡単にアクセスすることができるライブラリのこと。

ユーザのパスワードのような気密性の高いデータは UserDefaults や DB ではなく Keychain に保存することが推奨されている。

その Keychain は使いにくいことで有名だったのだが、KeychainAccess を使うことで簡単に利用することができる。

前回の記事では KeychainAccess を使ってデータ書き込みや読み込みを簡単にするための Extension について解説しました。

Service と Server#

KeychainAccess ではインスタンス生成時に引数をつけることでServerServiceかのどちらかを選択することができます。

ちなみに何もつけなかった場合にはアプリのバンドル ID がそのまま使われるみたいです

今までは慣習的にServerを利用していたのですが、Serviceとの違いは何なのでしょうか?

また、場合によっては一つのサービスについて複数のアカウント情報を保持し、ログイン時などにどちらのアカウントを選択するかユーザに選ばせたいような場合もあります。そのような複数アカウント機能を KeychainAccess で実装するにはどうすればよいでしょうか。

Server#

公式ドキュメントにもあるようにウェブサイトでのパスコードの保存に使う。

例えば、以下のようなコードを書いたとしよう。

var keychain: Keychain
keychain = Keychain(server: "AAA", protocolType: .https)
keychain["value"] = "AAA"
print(keychain["value"])
// -> AAA

keychain = Keychain(server: "BBB", protocolType: .https)
keychain["value"] = "BBB"
print(keychain["value"])
// -> BBB

keychain = Keychain(server: "AAA", protocolType: .https)
print(keychain["value"])
// -> BBB

二つのインスタンスは異なるものなのでAAAの値は保存されそうなのだが、実は上書きされてしまう。

というのも、このserverの値には URL に変換可能な文字列を代入する必要があるからだ。KeychainAccessライブラリの内部でStringからURLに変換される際に、変換不可能な倍にはserverには空文字が割り当てられている。

AAABBBも URL に変換不可能なのでどちらもserver=""が割り当てられているのと同じ状態になり、そのため二つは同一のインスタンスになってしまっている。

そのため、データが上書きされてしまっているのだ。

var keychain: Keychain
keychain = Keychain(server: URL(string: "https://tkgstrator.work")!, protocolType: .https)
keychain["value"] = "AAA"
print(keychain["value"])
// -> AAA

keychain = Keychain(server: URL(string: "https://tkgstrator.works")!, protocolType: .https)
keychain["value"] = "BBB"
print(keychain["value"])
// -> BBB

keychain = Keychain(server: URL(string: "https://tkgstrator.work")!, protocolType: .https)
print(keychain["value"])
// -> AAA

このように URL に変換可能な文字列または直接 URL を指定した場合には正しくデータが保存される。

Service#

基本的には Server と同じなのですが、任意の文字列が利用できるという点が異なります。それ以外は全て同じです。

Keychain へのデータ保存#

ここを勘違いしてしまっていたのですが、Keychain へのデータの保存は辞書型ではないようです。

KeychainAccess のインスタンスの中身は以下のようになっており、これらが配列として保存されています。

ServerService
authenticationTypeEnumEnum
synchronizableBoolBool
accessGroupBundleIDBundleID
classEnumEnum
keyStringString
valueAnyAny
accessibilityEnumEnum
protocolEnum-
serverURL-
service-String

つまり、例えばkeychain["price"] = 100のようなコードを書いたとしてもどこにもkeychain["price"]のデータはないということです。

じゃあどうやって保存されているのかというと、以下のようにkeypricevalue100のデータ(正確には上のようにもっといろんなデータが入っているが)が配列に追加されているだけだということです。

[
    [
        "key": "price",
        "value": "100",
        "server": "tkgstrator.work"
    ],
    [
        "key": "name",
        "value": "apple"
        "server": "tkgstrator.work"
    ],
]

これがどう困るかというと、サブアカウントのようなものを利用するときに困ります。

何故なら、このままだとどのアカウントのデータかを区別することができないからです。

値をユニークに保つために、Keychain では同一のkeyをもつことは許されていません。そうすれば単にデータが上書きされてしまうだけです。

[
    [
        "key": "userId",
        "value": "XXXXXXXX",
        "server": "tkgstrator.work"
    ],
    [
        "key": "userId",
        "value": "YYYYYYYY",
        "server": "tkgstrator.work"
    ]
]

単なる配列ならこのようなデータも保存できますが、これは同一のキーなのでどちらか一方しか保存できません。最初に書き込んだ方のデータは失われます。

これの対策として考えられるのがServerないしはServiceの値を変更することです。こうすれば別のデータとして扱えます。

var keychain: Keychain
keychain = Keychain(server: URL(string: "https://tkgstrator.work/account01")!, protocolType: .https)
keychain["userId"] = "XXXXXXXX"
print(keychain["value"])

keychain = Keychain(server: URL(string: "https://tkgstrator.work/account02")!, protocolType: .https)
keychain["userId"] = "YYYYYYYY"
print(keychain["value"])

つまり、このようにしてしまえば良いわけです。

ただし、この方法は次の観点から実装を見送りました。

  • Keychain を切り替えるのがめんどくさい
    • 切り替えるのは良いとして、そのためのServerなどのリストはどうやって保存するのか
    • それも Keychain に入れれば仕様がややこしくなってしまう
  • アカウント数が増えたときに切り替えるのがめんどくさい   - アカウント数の分だけ Keychain のインスタンスを用意するのはめんどくさい

構造体を Keychain に保存する#

そこで考えたのが、userId = XXXXXXXXのようなデータを保存するのではなく、キーとしてユーザ固有の値を与え、データにユーザ情報を全部入れてしまえばよいのではないかという方法でした。

// Before
keychain["userId"] = "XXXXXXXX"
keychain["password"] = "YYYYYYYY"
keychain["balancee"] = "ZZZZZZZZ"

// After
keychain["XXXXXXXX"] = User(password: "YYYYYYYY", balance: "ZZZZZZZZ")

しかし、これはこのままではビルドが通りません。Keychain に保存できるのは Data 型が String 型だと決まっているからです。

Codable を利用する#

ですが、Swift には構造体を Data 型に変換するためのプロトコルがあります。

それが当ブログでも何度か取り上げたCodbaleというプロトコルで、これを使えば構造体を JSONEncoder で Data 型に変換できます。

いちいちデータをエンコードしたりデコードしたりはめんどくさいので、Extension を使ってそれらの部分をうまく処理してやりましょう。

// Keychianに保存する構造体をCodable準拠にする
struct Account: Codable {
    var userId: String = ""
    var password: String = ""
    var membership: Bool = false

    init() {}
}

構造体を Codable にするには単に適合させるだけで良いです。何か特別なことをする必要はありません。

// Extension
extension Keychain {
    func setValue(forKey: String, account: Account) throws -> () {
        let encoder: JSONEncoder = {
            let encoder = JSONEncoder()
            encoder.keyEncodingStrategy = .convertToSnakeCase
            return encoder
        }()

        let data = try encoder.encode(account)
        try set(data, key: forKey)
    }

    func getValue(forKey: String) throws -> Account {
        let decoder: JSONDecoder = {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            return decoder
        }()
        guard let data = try getData(forKey) {
            return try decoder.decode(Account.self, from: data)
        }
        throw fatalError()
    }
}

これはエラーを認めてそれを返すようなメソッドですが、ネットワークからレスポンスを受け取っているわけではないので実際にエラーが発生することは(おそらく殆どない)と思われます。

// エラーを握りつぶす場合
extension Keychain {
    func setValue(forKey: String, account: Account) -> () {
        let encoder: JSONEncoder = {
            let encoder = JSONEncoder()
            encoder.keyEncodingStrategy = .convertToSnakeCase
            return encoder
        }()

        guard let data = try? encoder.encode(account) else { return }
        try? set(data, key: forKey)
    }

    func getValue(forKey: String) -> Account? {
        let decoder: JSONDecoder = {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            return decoder
        }()

        guard let data = try? getData(forKey) else { return nil }
        return try? decoder.decode(Account.self, from: data)
    }
}

エラーが発生しないようなケースであればtry?でエラーを握りつぶしてしまうのもアリです。

自分の場合はエラーが発生しないケースでしたので、後者を選択しました。

extension Keychain {
    func removeValue(account: Account) {
        try? remove(account.userId)
    }
}

最後に、データを削除できるようにしておいても良いかもしれません。

使い方#

let keychain = Keychain(service: "work.tkgstrator")

// データ読み込み(ない場合はnilが返ってくる)
guard let account = keychain.getValue(userId: "tkgling") else { return }

// データ書き込み
let account: Account = Account()
keychain.setValue(account: account)

構造体が Nil を許容する場合#

構造体にオプショナル型のプロパティをつけても正しく動作しました。

struct Account: Codable {
    var userId: String?
    var password: String?
    var membership: Bool

    init() {}
}

ただし、キーだけはオプショナルではダメなので、今回のようにuserIdをキーにする場合は書き込む前にuserIdnilでないかだけはチェックする必要があります。

let keychain = Keychain(service: "work.tkgstrator")

var account: Account = Account()
account.userId = "tkgstrator"
keychain.setValue(account: account)

guard let account = keychain.getValue(userId: "tkgstrator") else { return }
print(account)
// Account(userId: Optional("tkgstrator"), password: nil, membership: nil)

こうすれば直感的にデータを取得できるので便利でした。

KeychainAccessの理解を深めよう
https://fuwari.vercel.app/posts/2021/06/keychainaccess/
Author
tkgling
Published at
2021-06-28