commit e4860f3ba88ff8f95a87a0e73d120e3b349ffadf
parent 27fb4e797de6170ada27121c754af95d643e4186
Author: Bryan Montz <bryanmontz@me.com>
Date: Thu, 4 May 2023 06:40:04 -0500
Replace Vault dependency with @KeychainStorage property wrapper
Changelog-Changed: replace Vault dependency with @KeychainStorage property wrapper
Closes: #1076
Diffstat:
7 files changed, 162 insertions(+), 203 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -258,6 +258,8 @@
4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; };
4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE60CDC295E1C5E00105A1F /* Wallet.swift */; };
50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; };
+ 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; };
+ 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */; };
50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; };
50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B5685229F97CB400A23243 /* CredentialHandler.swift */; };
5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; };
@@ -268,7 +270,6 @@
643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643EA5C7296B764E005081BB /* RelayFilterView.swift */; };
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647D9A8C2968520300A295DE /* SideMenuView.swift */; };
64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FBD06E296255C400D9D3B2 /* Theme.swift */; };
- 6C7DE41F2955169800E66263 /* Vault in Frameworks */ = {isa = PBXBuildFile; productRef = 6C7DE41E2955169800E66263 /* Vault */; };
7C0F392F29B57CAF0039859C /* Binding+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0F392E29B57CAF0039859C /* Binding+.swift */; };
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; };
7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; };
@@ -681,6 +682,8 @@
4CFF8F6C29CD022E008DB934 /* WideEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideEventView.swift; sourceTree = "<group>"; };
4FE60CDC295E1C5E00105A1F /* Wallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wallet.swift; sourceTree = "<group>"; };
50088DA029E8271A008A1FDF /* WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocket.swift; sourceTree = "<group>"; };
+ 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorage.swift; sourceTree = "<group>"; };
+ 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorageTests.swift; sourceTree = "<group>"; };
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; };
50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = "<group>"; };
5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; };
@@ -720,7 +723,6 @@
buildActionMask = 2147483647;
files = (
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
- 6C7DE41F2955169800E66263 /* Vault in Frameworks */,
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1035,6 +1037,7 @@
4C363AA728297703006E126D /* InsertSort.swift */,
4C477C9D282C3A4800033AA3 /* TipCounter.swift */,
4C285C8B28398BC6008A31F1 /* Keys.swift */,
+ 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */,
4C90BD19283AA67F008EE7EF /* Bech32.swift */,
4C216F352870A9A700040376 /* InputDismissKeyboard.swift */,
3169CAEC294FCCFC00EE4006 /* Constants.swift */,
@@ -1271,6 +1274,7 @@
3A3040F229A91366008A0F29 /* ProfileViewTests.swift */,
3A30410029AB12AA008A0F29 /* EventGroupViewTests.swift */,
4C8D00D329E3C5D40036AF10 /* NIP19Tests.swift */,
+ 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */,
);
path = damusTests;
sourceTree = "<group>";
@@ -1393,7 +1397,6 @@
packageProductDependencies = (
4C649880286E0EE300EAE2B3 /* secp256k1 */,
4C06670328FC7EC500038D2A /* Kingfisher */,
- 6C7DE41E2955169800E66263 /* Vault */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -1498,7 +1501,6 @@
packageReferences = (
4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */,
4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */,
- 6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -1789,6 +1791,7 @@
4C75EFA427FA577B0006080F /* PostView.swift in Sources */,
4C30AC7229A5677A00E2BD5A /* NotificationsView.swift in Sources */,
4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */,
+ 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */,
4C1A9A1D29DDCF9B00516EAC /* NotificationSettingsView.swift in Sources */,
4C75EFB528049D790006080F /* Relay.swift in Sources */,
4CEE2AF1280B216B00AB5EEF /* EventDetailView.swift in Sources */,
@@ -1811,6 +1814,7 @@
3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */,
4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */,
3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */,
+ 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */,
3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */,
DD597CBD2963D85A00C64D32 /* MarkdownTests.swift in Sources */,
3A3040EF29A8FEE9008A0F29 /* EventDetailBarTests.swift in Sources */,
@@ -2307,14 +2311,6 @@
revision = 40b4b38b3b1c83f7088c76189a742870e0ca06a9;
};
};
- 6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */ = {
- isa = XCRemoteSwiftPackageReference;
- repositoryURL = "https://github.com/SparrowTek/Vault";
- requirement = {
- kind = upToNextMajorVersion;
- minimumVersion = 1.0.0;
- };
- };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -2328,11 +2324,6 @@
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
productName = secp256k1;
};
- 6C7DE41E2955169800E66263 /* Vault */ = {
- isa = XCSwiftPackageProductDependency;
- package = 6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */;
- productName = Vault;
- };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 4CE6DEDB27F7A08100C66700 /* Project object */;
diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -16,15 +16,6 @@
"state" : {
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
}
- },
- {
- "identity" : "vault",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/SparrowTek/Vault",
- "state" : {
- "revision" : "87db56c3c8b6421c65b0745f73e08b0dc56f79d4",
- "version" : "1.0.3"
- }
}
],
"version" : 2
diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift
@@ -6,7 +6,6 @@
//
import Foundation
-import Vault
import UIKit
let fallback_zap_amount = 1000
@@ -160,16 +159,11 @@ class UserSettingsStore: ObservableObject {
var deepl_plan: DeepLPlan
var deepl_api_key: String {
- didSet {
- do {
- if deepl_api_key == "" {
- try clearDeepLApiKey()
- } else {
- try saveDeepLApiKey(deepl_api_key)
- }
- } catch {
- // No-op.
- }
+ get {
+ return internal_deepl_api_key ?? ""
+ }
+ set {
+ internal_deepl_api_key = newValue == "" ? nil : newValue
}
}
@@ -179,73 +173,34 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "libretranslate_url", default_value: "")
var libretranslate_url: String
- @Setting(key: "libretranslate_api_key", default_value: "")
var libretranslate_api_key: String {
- didSet {
- do {
- if libretranslate_api_key == "" {
- try clearLibreTranslateApiKey()
- } else {
- try saveLibreTranslateApiKey(libretranslate_api_key)
- }
- } catch {
- // No-op.
- }
+ get {
+ return internal_libretranslate_api_key ?? ""
}
- }
-
- @Published var nokyctranslate_api_key: String {
- didSet {
- do {
- if nokyctranslate_api_key == "" {
- try clearNoKYCTranslateApiKey()
- } else {
- try saveNoKYCTranslateApiKey(nokyctranslate_api_key)
- }
- } catch {
- // No-op.
- }
+ set {
+ internal_libretranslate_api_key = newValue == "" ? nil : newValue
}
}
-
- init() {
- do {
- deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
- } catch {
- deepl_api_key = ""
+
+ var nokyctranslate_api_key: String {
+ get {
+ return internal_nokyctranslate_api_key ?? ""
}
-
- do {
- nokyctranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
- } catch {
- nokyctranslate_api_key = ""
+ set {
+ internal_nokyctranslate_api_key = newValue == "" ? nil : newValue
}
-
- }
-
- private func saveLibreTranslateApiKey(_ apiKey: String) throws {
- try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
- }
-
- private func clearLibreTranslateApiKey() throws {
- try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
- }
-
- private func saveNoKYCTranslateApiKey(_ apiKey: String) throws {
- try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
}
- private func clearNoKYCTranslateApiKey() throws {
- try Vault.deletePrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
- }
+ // These internal keys are necessary because entries in the keychain need to be Optional,
+ // but the translation view needs non-Optional String in order to use them as Bindings.
+ @KeychainStorage(account: "deepl_apikey")
+ var internal_deepl_api_key: String?
- private func saveDeepLApiKey(_ apiKey: String) throws {
- try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration())
- }
-
- private func clearDeepLApiKey() throws {
- try Vault.deletePrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
- }
+ @KeychainStorage(account: "nokyctranslate_apikey")
+ var internal_nokyctranslate_api_key: String?
+
+ @KeychainStorage(account: "libretranslate_apikey")
+ var internal_libretranslate_api_key: String?
var can_translate: Bool {
switch translation_service {
@@ -254,31 +209,13 @@ class UserSettingsStore: ObservableObject {
case .libretranslate:
return URLComponents(string: libretranslate_url) != nil
case .deepl:
- return deepl_api_key != ""
+ return internal_deepl_api_key != nil
case .nokyctranslate:
- return nokyctranslate_api_key != ""
+ return internal_nokyctranslate_api_key != nil
}
}
}
-struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration {
- var serviceName = "damus"
- var accessGroup: String? = nil
- var accountName = "libretranslate_apikey"
-}
-
-struct DamusDeepLKeychainConfiguration: KeychainConfiguration {
- var serviceName = "damus"
- var accessGroup: String? = nil
- var accountName = "deepl_apikey"
-}
-
-struct DamusNoKYCTranslateKeychainConfiguration: KeychainConfiguration {
- var serviceName = "damus"
- var accessGroup: String? = nil
- var accountName = "nokyctranslate_apikey"
-}
-
func pk_setting_key(_ pubkey: String, key: String) -> String {
return "\(pubkey)_\(key)"
}
diff --git a/damus/Util/KeychainStorage.swift b/damus/Util/KeychainStorage.swift
@@ -0,0 +1,73 @@
+//
+// KeychainStorage.swift
+// damus
+//
+// Created by Bryan Montz on 5/2/23.
+//
+
+import Foundation
+import Security
+
+@propertyWrapper struct KeychainStorage {
+ let account: String
+ private let service = "damus"
+
+ var wrappedValue: String? {
+ get {
+ let query = [
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecClass: kSecClassGenericPassword,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne
+ ] as [CFString: Any] as CFDictionary
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query, &result)
+
+ if status == errSecSuccess, let data = result as? Data {
+ return String(data: data, encoding: .utf8)
+ } else {
+ return nil
+ }
+ }
+ set {
+ if let newValue {
+ let query = [
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecClass: kSecClassGenericPassword,
+ kSecValueData: newValue.data(using: .utf8) as Any
+ ] as [CFString: Any] as CFDictionary
+
+ var status = SecItemAdd(query, nil)
+
+ if status == errSecDuplicateItem {
+ let query = [
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecClass: kSecClassGenericPassword
+ ] as [CFString: Any] as CFDictionary
+
+ let updates = [
+ kSecValueData: newValue.data(using: .utf8) as Any
+ ] as CFDictionary
+
+ status = SecItemUpdate(query, updates)
+ }
+ } else {
+ let query = [
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ kSecClass: kSecClassGenericPassword
+ ] as [CFString: Any] as CFDictionary
+
+ _ = SecItemDelete(query)
+ }
+ }
+ }
+
+ init(account: String) {
+ self.account = account
+ }
+}
diff --git a/damus/Util/Keys.swift b/damus/Util/Keys.swift
@@ -7,7 +7,6 @@
import Foundation
import secp256k1
-import Vault
let PUBKEY_HRP = "npub"
let PRIVKEY_HRP = "nsec"
@@ -44,12 +43,6 @@ enum Bech32Key {
case sec(String)
}
-struct DamusKeychainConfiguration: KeychainConfiguration {
- var serviceName = "damus"
- var accessGroup: String? = nil
- var accountName = "privkey"
-}
-
func decode_bech32_key(_ key: String) -> Bech32Key? {
guard let decoded = try? bech32_decode(key) else {
return nil
@@ -114,12 +107,17 @@ func save_pubkey(pubkey: String) {
UserDefaults.standard.set(pubkey, forKey: "pubkey")
}
+enum Keys {
+ @KeychainStorage(account: "privkey")
+ static var privkey: String?
+}
+
func save_privkey(privkey: String) throws {
- try Vault.savePrivateKey(privkey, keychainConfiguration: DamusKeychainConfiguration())
+ Keys.privkey = privkey
}
func clear_saved_privkey() throws {
- try Vault.deletePrivateKey(keychainConfiguration: DamusKeychainConfiguration())
+ Keys.privkey = nil
}
func clear_saved_pubkey() {
@@ -154,7 +152,7 @@ func get_saved_pubkey() -> String? {
}
func get_saved_privkey() -> String? {
- let mkey = try? Vault.getPrivateKey(keychainConfiguration: DamusKeychainConfiguration());
+ let mkey = Keys.privkey
return mkey.map { $0.trimmingCharacters(in: .whitespaces) }
}
diff --git a/damus/Views/Settings/TranslationSettingsView.swift b/damus/Views/Settings/TranslationSettingsView.swift
@@ -79,9 +79,7 @@ struct TranslationSettingsView: View {
if settings.translation_service != .none {
Toggle(NSLocalizedString("Automatically translate notes", comment: "Toggle to automatically translate notes."), isOn: $settings.auto_translate)
.toggleStyle(.switch)
- }
-
- if settings.translation_service != .none {
+
Toggle(NSLocalizedString("Translate DMs", comment: "Toggle to translate direct messages."), isOn: $settings.translate_dms)
.toggleStyle(.switch)
}
@@ -92,81 +90,6 @@ struct TranslationSettingsView: View {
dismiss()
}
}
-
- var libretranslate_view: some View {
- VStack {
- Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $settings.libretranslate_server) {
- ForEach(LibreTranslateServer.allCases, id: \.self) { server in
- Text(server.model.displayName)
- .tag(server.model.tag)
- }
- }
-
- TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url)
- .disableAutocorrection(true)
- .disabled(settings.libretranslate_server != .custom)
- .autocapitalization(UITextAutocapitalizationType.none)
- HStack {
- let libretranslate_api_key_placeholder = NSLocalizedString("API Key (optional)", comment: "Prompt for optional entry of API Key to use translation server.")
- if show_api_key {
- TextField(libretranslate_api_key_placeholder, text: $settings.libretranslate_api_key)
- .disableAutocorrection(true)
- .autocapitalization(UITextAutocapitalizationType.none)
- if settings.libretranslate_api_key != "" {
- Button(NSLocalizedString("Hide API Key", comment: "Button to hide the LibreTranslate server API key.")) {
- show_api_key = false
- }
- }
- } else {
- SecureField(libretranslate_api_key_placeholder, text: $settings.libretranslate_api_key)
- .disableAutocorrection(true)
- .autocapitalization(UITextAutocapitalizationType.none)
- if settings.libretranslate_api_key != "" {
- Button(NSLocalizedString("Show API Key", comment: "Button to show the LibreTranslate server API key.")) {
- show_api_key = true
- }
- }
- }
- }
- }
- }
-
- var deepl_view: some View {
- VStack {
- Picker(NSLocalizedString("Plan", comment: "Prompt selection of DeepL subscription plan to perform machine translations on notes"), selection: $settings.deepl_plan) {
- ForEach(DeepLPlan.allCases, id: \.self) { server in
- Text(server.model.displayName)
- .tag(server.model.tag)
- }
- }
-
- HStack {
- let deepl_api_key_placeholder = NSLocalizedString("API Key (required)", comment: "Prompt for required entry of API Key to use translation server.")
- if show_api_key {
- TextField(deepl_api_key_placeholder, text: $settings.deepl_api_key)
- .disableAutocorrection(true)
- .autocapitalization(UITextAutocapitalizationType.none)
- if settings.deepl_api_key != "" {
- Button(NSLocalizedString("Hide API Key", comment: "Button to hide the DeepL translation API key.")) {
- show_api_key = false
- }
- }
- } else {
- SecureField(deepl_api_key_placeholder, text: $settings.deepl_api_key)
- .disableAutocorrection(true)
- .autocapitalization(UITextAutocapitalizationType.none)
- if settings.deepl_api_key != "" {
- Button(NSLocalizedString("Show API Key", comment: "Button to show the DeepL translation API key.")) {
- show_api_key = true
- }
- }
- }
- if settings.deepl_api_key == "" {
- Link(NSLocalizedString("Get API Key", comment: "Button to navigate to DeepL website to get a translation API key."), destination: URL(string: "https://www.deepl.com/pro-api")!)
- }
- }
- }
- }
}
struct TranslationSettingsView_Previews: PreviewProvider {
diff --git a/damusTests/KeychainStorageTests.swift b/damusTests/KeychainStorageTests.swift
@@ -0,0 +1,46 @@
+//
+// KeychainStorageTests.swift
+// damusTests
+//
+// Created by Bryan Montz on 5/3/23.
+//
+
+import XCTest
+@testable import damus
+import Security
+
+final class KeychainStorageTests: XCTestCase {
+ @KeychainStorage(account: "test-keyname")
+ var secret: String?
+
+ override func tearDownWithError() throws {
+ secret = nil
+ }
+
+ func testWriteToKeychain() throws {
+ // write a secret to the keychain using the property wrapper's setter
+ secret = "super-secure-key"
+
+ // verify it exists in the keychain using the property wrapper's getter
+ XCTAssertEqual(secret, "super-secure-key")
+
+ // verify it exists in the keychain directly
+ let query = [
+ kSecAttrService: "damus",
+ kSecAttrAccount: "test-keyname",
+ kSecClass: kSecClassGenericPassword,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne
+ ] as [CFString: Any] as CFDictionary
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query, &result)
+ XCTAssertEqual(status, errSecSuccess)
+
+ let data = try XCTUnwrap(result as? Data)
+ let the_secret = String(data: data, encoding: .utf8)
+
+ XCTAssertEqual(the_secret, "super-secure-key")
+ }
+
+}