Keyshare vault
Keyshare vault
The vault abstraction maps stable keyshare ids to files. Signing flows reference keyshare ids; the vault is the only path to local key material.
Swift provides FileMobileNodeKeyshareVault.appSupport and SingleFileMobileNodeKeyshareVault. Android provides FileMobileNodeKeyshareVault and SingleFileMobileNodeKeyshareVault.
Production apps can replace these with a stricter implementation that adds file protection, encrypted storage, migration policy, backup policy, and remote wipe behavior.
Operational guidance
Use stable keyshare ids such as treasury-main-p2 and keep them free of account secrets. Persist public key and key id in product storage so future signing results can be bound to the expected key.
Vault roots must be app-private. Keyshare ids must survive app restarts and OS upgrades. Migration code must preserve the id-to-file mapping exactly — a broken mapping means the device can't sign and re-enrollment is required. Delete flows must call the SDK vault removal method and update product state together so there's no window where the app thinks a key exists but the vault disagrees.
Custom vault implementation
Implement MobileNodeKeyshareVault to replace the default file-based vault with encrypted storage, hardware-backed keys, or a custom migration policy. The protocol has three methods: write(keyshareId: String, data: Data) throws, read(keyshareId: String) throws -> Data, and delete(keyshareId: String) throws. The SDK calls these at keygen (write), sign (read), and device revoke or key rotation (delete).
On iOS, wrap a Keychain item with kSecAttrAccessibleWhenUnlockedThisDeviceOnly for hardware-backed storage. On Android, use an EncryptedFile from Jetpack Security backed by a hardware KeyStore key. Both approaches ensure the keyshare is bound to the device and inaccessible after a factory reset.
struct KeychainKeyshareVault: MobileNodeKeyshareVault {
private let service = "com.example.app.keyshare"
func write(keyshareId: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyshareId,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw VaultError.writeFailed(status)
}
}
func read(keyshareId: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyshareId,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw VaultError.notFound(keyshareId)
}
return data
}
func delete(keyshareId: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: keyshareId
]
SecItemDelete(query as CFDictionary)
}
}Migration and delete flows
Vault migration (e.g. moving from file-based to Keychain): read each keyshare id from the old vault, write it to the new vault, verify the round-trip by reading back and comparing bytes, then delete from the old vault. Do not delete the old entry until the new vault write and read-back both succeed — an incomplete migration leaves the device unable to sign, requiring re-enrollment.
Delete flow (device revoke, key rotation): call expert.devices.revoke() or the rotation operation first, wait for the receipt confirming the co-signer has processed the change, then delete the local keyshare file via the vault. Deleting the file before the co-signer confirms the revoke leaves a window where the app thinks the device is revoked but the co-signer still has the device in its roster. Always sequence: remote confirmation first, then local deletion.