200 lines
6.8 KiB
Swift
200 lines
6.8 KiB
Swift
//
|
|
// UMSerialization.swift
|
|
// modules
|
|
//
|
|
// Created by Lukáš Korba on 03.02.2025.
|
|
//
|
|
|
|
import Foundation
|
|
import ComposableArchitecture
|
|
import WalletStorage
|
|
import ZcashLightClientKit
|
|
import Models
|
|
import CryptoKit
|
|
import Utils
|
|
|
|
public struct UserMetadata: Codable {
|
|
public enum Constants {
|
|
public static let version = 1
|
|
}
|
|
|
|
public enum CodingKeys: CodingKey {
|
|
case version
|
|
case lastUpdated
|
|
case accountMetadata
|
|
}
|
|
|
|
let version: Int
|
|
let lastUpdated: Int64
|
|
let accountMetadata: UMAccount
|
|
|
|
public init(version: Int, lastUpdated: Int64, accountMetadata: UMAccount) {
|
|
self.version = version
|
|
self.lastUpdated = lastUpdated
|
|
self.accountMetadata = accountMetadata
|
|
}
|
|
}
|
|
|
|
public struct UMAccount: Codable {
|
|
public enum CodingKeys: CodingKey {
|
|
case bookmarked
|
|
case annotations
|
|
case read
|
|
}
|
|
|
|
let bookmarked: [UMBookmark]
|
|
let annotations: [UMAnnotation]
|
|
let read: [String]
|
|
}
|
|
|
|
public struct UMBookmark: Codable {
|
|
public enum CodingKeys: CodingKey {
|
|
case txId
|
|
case lastUpdated
|
|
case isBookmarked
|
|
}
|
|
|
|
let txId: String
|
|
let lastUpdated: Int64
|
|
var isBookmarked: Bool
|
|
}
|
|
|
|
public struct UMAnnotation: Codable {
|
|
public enum CodingKeys: CodingKey {
|
|
case txId
|
|
case content
|
|
case lastUpdated
|
|
}
|
|
|
|
let txId: String
|
|
let content: String?
|
|
let lastUpdated: Int64
|
|
}
|
|
|
|
public extension UserMetadata {
|
|
/// Encrypts user metadata. The structure:
|
|
/// [Unencrypted data] `encryption version`
|
|
/// [Unencrypted data] `salt`
|
|
/// [Unencrypted data] `metadata version`
|
|
/// [Encrypted data] `serialized metadata`
|
|
/// This method always produces the latest structure with the latest encryption version.
|
|
static func encryptUserMetadata(_ umData: UserMetadata, account: Account) throws -> Data {
|
|
@Dependency(\.walletStorage) var walletStorage
|
|
|
|
guard let encryptionKeys = try? walletStorage.exportUserMetadataEncryptionKeys(account),
|
|
let umKey = encryptionKeys.getCached(account: account) else {
|
|
throw UserMetadataStorage.UMError.missingEncryptionKey
|
|
}
|
|
|
|
var encryptionVersionData = Data()
|
|
encryptionVersionData.append(contentsOf: Serializer.intToBytes(UserMetadataEncryptionKeys.Constants.version))
|
|
|
|
var metadataVersionData = Data()
|
|
metadataVersionData.append(contentsOf: Serializer.intToBytes(UserMetadata.Constants.version))
|
|
|
|
// Generate a fresh one-time sub-key for encrypting the user metadata.
|
|
let salt = SymmetricKey(size: SymmetricKeySize.bits256)
|
|
|
|
guard let dataForEncryption = try? JSONEncoder().encode(umData) else {
|
|
throw UserMetadataStorage.UMError.serialization
|
|
}
|
|
|
|
return try salt.withUnsafeBytes { salt in
|
|
let salt = Data(salt)
|
|
let subKey = umKey.deriveEncryptionKey(salt: salt)
|
|
|
|
// Encrypt the serialized user metadata.
|
|
// CryptoKit encodes the SealedBox as `nonce || ciphertext || tag`.
|
|
let sealed = try ChaChaPoly.seal(dataForEncryption, using: subKey)
|
|
|
|
// Prepend the encryption version & salt to the SealedBox so we can re-derive the sub-key.
|
|
|
|
// unencrypted data
|
|
return encryptionVersionData + salt + metadataVersionData
|
|
// encrypted data
|
|
+ sealed.combined
|
|
}
|
|
}
|
|
|
|
/// Tries to decrypt the data with the structure:
|
|
/// [Unencrypted data] `encryption version`
|
|
/// [Unencrypted data] `salt`
|
|
/// [Unencrypted data] `metadata version`
|
|
static func userMetadataFrom(encryptedData: Data, account: Account) throws -> UserMetadata? {
|
|
@Dependency(\.walletStorage) var walletStorage
|
|
|
|
guard let encryptionKeys = try? walletStorage.exportUserMetadataEncryptionKeys(account),
|
|
let umKey = encryptionKeys.getCached(account: account) else {
|
|
throw UserMetadataStorage.UMError.missingEncryptionKey
|
|
}
|
|
|
|
var offset = 0
|
|
|
|
// Deserialize `encryption version`
|
|
let encryptionVersionBytes = try UserMetadata.subdata(of: encryptedData, in: offset..<(offset + UserMetadataStorage.Constants.int64Size))
|
|
offset += UserMetadataStorage.Constants.int64Size
|
|
|
|
guard let encryptionVersion = UserMetadata.bytesToInt(Array(encryptionVersionBytes)) else {
|
|
return nil
|
|
}
|
|
|
|
guard encryptionVersion == UserMetadataEncryptionKeys.Constants.version else {
|
|
throw UserMetadataStorage.UMError.encryptionVersionNotSupported
|
|
}
|
|
|
|
let encryptedSubData = try UserMetadata.subdata(of: encryptedData, in: offset..<encryptedData.count)
|
|
|
|
// Derive the sub-key for decrypting the user metadata.
|
|
let salt = encryptedSubData.prefix(upTo: 32)
|
|
|
|
let subKeys = umKey.deriveDecryptionKeys(salt: salt)
|
|
|
|
for subKey in subKeys {
|
|
offset = 32
|
|
|
|
do {
|
|
// Deserialize `metadata version`
|
|
let metadataVersionBytes = try UserMetadata.subdata(of: encryptedSubData, in: offset..<(offset + UserMetadataStorage.Constants.int64Size))
|
|
offset += UserMetadataStorage.Constants.int64Size
|
|
|
|
guard let metadataVersion = UserMetadata.bytesToInt(Array(metadataVersionBytes)) else {
|
|
return nil
|
|
}
|
|
|
|
guard metadataVersion == UserMetadata.Constants.version else {
|
|
throw UserMetadataStorage.UMError.metadataVersionNotSupported
|
|
}
|
|
|
|
// Unseal the encrypted user metadata.
|
|
let sealed = try ChaChaPoly.SealedBox.init(combined: encryptedSubData.suffix(from: 32 + UserMetadataStorage.Constants.int64Size))
|
|
let data = try ChaChaPoly.open(sealed, using: subKey)
|
|
|
|
// deserialize the json's data
|
|
return try JSONDecoder().decode(UserMetadata.self, from: data)
|
|
} catch {
|
|
// this key failed to decrypt, try another one
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
static func subdata(of data: Data, in range: Range<Data.Index>) throws -> Data {
|
|
guard data.count >= range.upperBound else {
|
|
throw UserMetadataStorage.UMError.subdataRange
|
|
}
|
|
|
|
return data.subdata(in: range)
|
|
}
|
|
|
|
static func bytesToInt(_ bytes: [UInt8]) -> Int? {
|
|
guard bytes.count == UserMetadataStorage.Constants.int64Size else {
|
|
return nil
|
|
}
|
|
|
|
return bytes.withUnsafeBytes { ptr -> Int? in
|
|
Int.init(exactly: ptr.loadUnaligned(as: Int64.self).bigEndian)
|
|
}
|
|
}
|
|
}
|