The last place still calling the operator app a console. The Xcode project uses a synchronized root group pointed at FTStoreAdmin itself, so it never named the subfolder and the rename is a git mv and nothing else. App/ rather than Screens/, because the folder holds the transport, the session and the wire types as well as the views, and because the distinction that actually matters here is against Support/: this app's own code, versus helpers inlined from elsewhere with a note saying where they came from. Both AGENTS.md files follow, including the note recording that the history runs back through manager/FTStore/Admin/ — it now says the middle stretch was Console/, since that is the part a git log --follow has to cross.
89 lines
2.9 KiB
Swift
89 lines
2.9 KiB
Swift
//
|
|
// AdminKeychain.swift
|
|
// FTStore Admin
|
|
//
|
|
// The only place a secret is ever persisted by this app. The FTStore Admin
|
|
// session token lives here — never in UserDefaults, never in the app bundle,
|
|
// never in a cookie jar on disk.
|
|
//
|
|
// `ThisDeviceOnly` keeps the item out of iCloud Keychain and out of encrypted
|
|
// backups, so a decrypted IPA (or a restored backup) on another device carries
|
|
// no credential with it. `AfterFirstUnlock` rather than `WhenUnlocked` so a
|
|
// relaunch straight from a notification, on a device that hasn't been unlocked
|
|
// since, still finds the session.
|
|
//
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
enum AdminKeychain {
|
|
/// The app's own bundle id. Until this app existed these screens lived inside
|
|
/// FTStore Manager and used `app.ftstore.manager.admin`; that item is not
|
|
/// readable from here (no shared keychain group), so the operator signs in
|
|
/// once more and the Manager wipes its leftover on next launch.
|
|
private static let _service = "app.ftstore.admin"
|
|
|
|
/// The FTStore Admin session cookie value.
|
|
static let sessionAccount = "session-token"
|
|
|
|
@discardableResult
|
|
static func set(_ value: String?, for account: String) -> Bool {
|
|
guard let value, !value.isEmpty else {
|
|
remove(account)
|
|
return true
|
|
}
|
|
guard let data = value.data(using: .utf8) else { return false }
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: _service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
|
|
let attributes: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
]
|
|
|
|
// Update in place when the item exists; SecItemAdd would fail with
|
|
// errSecDuplicateItem and silently leave the stale token behind.
|
|
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
if status == errSecSuccess { return true }
|
|
guard status == errSecItemNotFound else { return false }
|
|
|
|
var insert = query
|
|
insert.merge(attributes) { current, _ in current }
|
|
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
|
}
|
|
|
|
static func get(_ account: String) -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: _service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
|
|
var item: CFTypeRef?
|
|
guard
|
|
SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data,
|
|
let string = String(data: data, encoding: .utf8),
|
|
!string.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
return string
|
|
}
|
|
|
|
static func remove(_ account: String) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: _service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|