Skip to content

Commit

Permalink
feat: ios: Banana Spit backup flow (#2355)
Browse files Browse the repository at this point in the history
* feat: ios: Assets, localizable, models, rename and moving seeds related classes

* feat: ios: UI elements fixes and cleanup

* feat: ios: Banana split main modal

* feat: ios: Banana split action modal

* feat: ios: banana split / passphrase keychain classes

* feat: ios: QR code modal, passphrase modal

* feat: ios: initial key details integration

* feat: ios: Unit tests for BS keychain

* feat: ios: banana split unit tests

---------

Co-authored-by: Pavel Rybalko <[email protected]>
  • Loading branch information
krodak and prybalko authored Mar 7, 2024
1 parent d57c9ea commit 4533d9e
Show file tree
Hide file tree
Showing 45 changed files with 1,956 additions and 153 deletions.
164 changes: 131 additions & 33 deletions ios/PolkadotVault.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions ios/PolkadotVault/Backend/Services/BananaSplitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

import Foundation

struct BananaSplitBackup: Equatable, Codable {
let qrCodes: [[UInt8]]
}

// sourcery: AutoMockable
protocol BananaSplitServicing: AnyObject {
func encrypt(
Expand All @@ -15,7 +19,7 @@ protocol BananaSplitServicing: AnyObject {
passphrase: String,
totalShards: UInt32,
requiredShards: UInt32,
_ completion: @escaping (Result<[QrData], ServiceError>) -> Void
_ completion: @escaping (Result<BananaSplitBackup, ServiceError>) -> Void
)
func generatePassphrase(
with words: UInt32,
Expand All @@ -40,16 +44,17 @@ final class BananaSplitService {
passphrase: String,
totalShards: UInt32,
requiredShards: UInt32,
_ completion: @escaping (Result<[QrData], ServiceError>) -> Void
_ completion: @escaping (Result<BananaSplitBackup, ServiceError>) -> Void
) {
backendService.performCall({
try bsEncrypt(
let qrCodes = try bsEncrypt(
secret: secret,
title: title,
passphrase: passphrase,
totalShards: totalShards,
requiredShards: requiredShards
)
return BananaSplitBackup(qrCodes: qrCodes.map(\.payload))
}, completion: completion)
}

Expand Down
2 changes: 1 addition & 1 deletion ios/PolkadotVault/Components/Buttons/CircleButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct CloseModalButton: View {
ZStack {
Circle()
.frame(width: Sizes.xmarkButtonDiameter, height: Sizes.xmarkButtonDiameter, alignment: .center)
.foregroundColor(.fill6)
.foregroundColor(.fill18)
Image(.xmarkButton)
.foregroundColor(.textAndIconsPrimary)
}
Expand Down
58 changes: 58 additions & 0 deletions ios/PolkadotVault/Components/Buttons/IconButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// IconButton.swift
// PolkadotVault
//
// Created by Krzysztof Rodak on 15/02/2024.
//

import SwiftUI

struct IconButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(Spacing.medium)
.foregroundColor(.textAndIconsSecondary)
.frame(
height: Heights.iconButton,
alignment: .center
)
}
}

struct IconButton: View {
private let action: () -> Void
private let icon: ImageResource

init(
action: @escaping () -> Void,
icon: ImageResource
) {
self.action = action
self.icon = icon
}

var body: some View {
Button(action: action) {
HStack {
Image(icon)
}
}
.buttonStyle(IconButtonStyle())
}
}

#if DEBUG
struct IconButton_Previews: PreviewProvider {
static var previews: some View {
VStack(alignment: .leading, spacing: 10) {
IconButton(
action: {},
icon: .refreshPassphrase
)
}
.padding()
.preferredColorScheme(.dark)
.previewLayout(.sizeThatFits)
}
}
#endif
7 changes: 4 additions & 3 deletions ios/PolkadotVault/Components/Text/ActionableInfoBoxView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ struct ActionableInfoBoxView: View {
VStack(alignment: .leading, spacing: Spacing.medium) {
HStack {
Text(renderable.text)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.textAndIconsPrimary)
.font(PrimaryFont.bodyM.font)
.fixedSize(horizontal: false, vertical: true)
Spacer().frame(maxWidth: Spacing.medium)
Image(.infoIconBold)
.foregroundColor(.accentPink300)
Expand All @@ -44,8 +45,8 @@ struct ActionableInfoBoxView: View {
.onTapGesture { action.action() }
}
}

.padding(Spacing.medium)
.frame(maxWidth: .infinity)
.containerBackground(CornerRadius.small, state: .actionableInfo)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ struct AttributedInfoBoxView: View {
var body: some View {
HStack {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
Spacer().frame(maxWidth: Spacing.medium)
Image(.helpOutline)
.foregroundColor(.accentPink300)
}
.padding()
.frame(maxWidth: .infinity)
.font(PrimaryFont.bodyM.font)
.background(
RoundedRectangle(cornerRadius: CornerRadius.small)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ struct AttributedTintInfoBox: View {
var body: some View {
HStack {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
Spacer().frame(width: Spacing.large)
Image(.helpOutline)
.foregroundColor(.accentPink300)
}
.padding(Spacing.medium)
.frame(maxWidth: .infinity)
.font(PrimaryFont.bodyM.font)
.background(
RoundedRectangle(cornerRadius: CornerRadius.medium)
Expand Down
4 changes: 3 additions & 1 deletion ios/PolkadotVault/Components/Text/InfoBoxView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ struct InfoBoxView: View {
var body: some View {
HStack {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.textAndIconsTertiary)
Spacer().frame(maxWidth: Spacing.medium)
Image(.infoIconBold)
.foregroundColor(.accentPink300)
}
.padding()
.frame(maxWidth: .infinity)
.font(PrimaryFont.bodyM.font)
.strokeContainerBackground(CornerRadius.small)
}
Expand Down
11 changes: 9 additions & 2 deletions ios/PolkadotVault/Components/TextFields/PrimaryTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI

struct PrimaryTextFieldStyle: ViewModifier {
let placeholder: String
let keyboardType: UIKeyboardType
@Binding var text: String
@Binding var isValid: Bool

Expand All @@ -19,7 +20,7 @@ struct PrimaryTextFieldStyle: ViewModifier {
.font(PrimaryFont.bodyL.font)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.asciiCapable)
.keyboardType(keyboardType)
.submitLabel(.return)
.frame(height: Heights.textFieldHeight)
.padding(.horizontal, Spacing.medium)
Expand All @@ -35,9 +36,15 @@ struct PrimaryTextFieldStyle: ViewModifier {
extension View {
func primaryTextFieldStyle(
_ placeholder: String,
keyboardType: UIKeyboardType = .asciiCapable,
text: Binding<String>,
isValid: Binding<Bool> = Binding<Bool>.constant(true)
) -> some View {
modifier(PrimaryTextFieldStyle(placeholder: placeholder, text: text, isValid: isValid))
modifier(PrimaryTextFieldStyle(
placeholder: placeholder,
keyboardType: keyboardType,
text: text,
isValid: isValid
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//
// KeychainBananaSplitQueryProvider.swift
// Polkadot Vault
//
// Created by Krzysztof Rodak on 26/02/2024.
//

import Foundation

struct BananaSplitPassphrase: Codable, Equatable {
let passphrase: String
}

enum KeychainBananaSplitQuery {
case fetch(seedName: String)
case check(seedName: String)
case delete(seedName: String)
case save(seedName: String, bananaSplit: BananaSplitBackup)
}

enum KeychainBananaSplitPassphraseQuery {
case fetch(seedName: String)
case delete(seedName: String)
case save(seedName: String, passphrase: BananaSplitPassphrase, accessControl: SecAccessControl)
}

// sourcery: AutoMockable
protocol KeychainBananaSplitQueryProviding: AnyObject {
func query(for queryType: KeychainBananaSplitQuery) -> CFDictionary
func passhpraseQuery(for queryType: KeychainBananaSplitPassphraseQuery) -> CFDictionary
}

final class KeychainBananaSplitQueryProvider: KeychainBananaSplitQueryProviding {
enum Constants {
static let bananaSplitSuffix = "_bananaSplit"
static let passphraseSuffix = "_passphrase"
}

private let jsonEncoder: JSONEncoder

init(jsonEncoder: JSONEncoder = JSONEncoder()) {
self.jsonEncoder = jsonEncoder
}

func query(for queryType: KeychainBananaSplitQuery) -> CFDictionary {
var dictionary: [CFString: Any] = [
kSecClass: kSecClassGenericPassword
]
switch queryType {
case let .fetch(seedName):
dictionary[kSecMatchLimit] = kSecMatchLimitOne
dictionary[kSecAttrAccount] = backupName(seedName)
dictionary[kSecReturnAttributes] = false
dictionary[kSecReturnData] = true
case let .check(seedName):
dictionary[kSecMatchLimit] = kSecMatchLimitOne
dictionary[kSecAttrAccount] = backupName(seedName)
dictionary[kSecReturnData] = false
case let .delete(seedName):
dictionary[kSecAttrAccount] = backupName(seedName)
case let .save(seedName, bananaSplit):
dictionary[kSecAttrAccount] = backupName(seedName)
if let data = try? jsonEncoder.encode(bananaSplit) {
dictionary[kSecValueData] = data
}
dictionary[kSecReturnData] = false
}
return dictionary as CFDictionary
}

func passhpraseQuery(for queryType: KeychainBananaSplitPassphraseQuery) -> CFDictionary {
var dictionary: [CFString: Any] = [
kSecClass: kSecClassGenericPassword
]
switch queryType {
case let .fetch(seedName):
dictionary[kSecMatchLimit] = kSecMatchLimitOne
dictionary[kSecAttrAccount] = passphraseName(seedName)
dictionary[kSecReturnAttributes] = false
dictionary[kSecReturnData] = true
case let .delete(seedName):
dictionary[kSecAttrAccount] = passphraseName(seedName)
case let .save(seedName, passphrase, accessControl):
dictionary[kSecAttrAccessControl] = accessControl
dictionary[kSecAttrAccount] = passphraseName(seedName)
if let data = try? jsonEncoder.encode(passphrase) {
dictionary[kSecValueData] = data
}

dictionary[kSecReturnData] = false
}
return dictionary as CFDictionary
}

private func backupName(_ seedName: String) -> String {
seedName + Constants.bananaSplitSuffix
}

private func passphraseName(_ seedName: String) -> String {
seedName + Constants.passphraseSuffix
}
}
Loading

0 comments on commit 4533d9e

Please sign in to comment.