@parlor/capacitor-device-key 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cychien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Package.swift ADDED
@@ -0,0 +1,32 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "ParlorCapacitorDeviceKey",
6
+ platforms: [.iOS(.v15), .macOS(.v13)],
7
+ products: [
8
+ .library(
9
+ name: "ParlorCapacitorDeviceKey",
10
+ targets: ["ParlorDeviceKeyPlugin"])
11
+ ],
12
+ dependencies: [
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
14
+ ],
15
+ targets: [
16
+ .target(
17
+ name: "ParlorDeviceKeyCore",
18
+ path: "ios/Sources/ParlorDeviceKeyCore"),
19
+ .target(
20
+ name: "ParlorDeviceKeyPlugin",
21
+ dependencies: [
22
+ "ParlorDeviceKeyCore",
23
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
24
+ .product(name: "Cordova", package: "capacitor-swift-pm")
25
+ ],
26
+ path: "ios/Sources/ParlorDeviceKeyPlugin"),
27
+ .testTarget(
28
+ name: "ParlorDeviceKeyCoreTests",
29
+ dependencies: ["ParlorDeviceKeyCore"],
30
+ path: "ios/Tests/ParlorDeviceKeyCoreTests")
31
+ ]
32
+ )
package/README.md CHANGED
@@ -1,3 +1,11 @@
1
- # Temporary Holding Version
1
+ # @parlor/capacitor-device-key
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The native half of the device-key and session-store capabilities on iOS and Android. Installed by `@parlor/capabilities` in Capacitor builds; you do not depend on it directly.
4
+
5
+ ```ts
6
+ import { ParlorSessionStore } from "@parlor/capacitor-device-key";
7
+
8
+ console.log(typeof ParlorSessionStore);
9
+ ```
10
+
11
+ Docs: https://cychien.github.io/parlor/reference/capabilities/
@@ -0,0 +1,44 @@
1
+ export interface ParlorDeviceKeyPlugin {
2
+ publicKeys(options: {
3
+ presence: boolean;
4
+ challenge: string;
5
+ }): Promise<{
6
+ signing: {
7
+ keyId: string;
8
+ publicKey: JsonWebKey;
9
+ };
10
+ presence?: {
11
+ keyId: string;
12
+ publicKey: JsonWebKey;
13
+ };
14
+ proof: {
15
+ signature: string;
16
+ };
17
+ }>;
18
+ sign(options: {
19
+ keyId: string;
20
+ payload: string;
21
+ }): Promise<{
22
+ signature: string;
23
+ }>;
24
+ }
25
+ export interface ParlorSessionStorePlugin {
26
+ get(): Promise<{
27
+ value?: string;
28
+ }>;
29
+ set(options: {
30
+ value?: string;
31
+ }): Promise<void>;
32
+ }
33
+ export interface ParlorAuthBrowserPlugin {
34
+ open(options: {
35
+ url: string;
36
+ callbackScheme: string;
37
+ }): Promise<{
38
+ url?: string;
39
+ }>;
40
+ }
41
+ export declare const ParlorDeviceKey: ParlorDeviceKeyPlugin;
42
+ export declare const ParlorSessionStore: ParlorSessionStorePlugin;
43
+ export declare const ParlorAuthBrowser: ParlorAuthBrowserPlugin;
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QACrE,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,UAAU,CAAA;SAAE,CAAC;QAClD,QAAQ,CAAC,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,UAAU,CAAA;SAAE,CAAC;QACpD,KAAK,EAAE;YAAE,SAAS,EAAE,MAAM,CAAA;SAAE,CAAC;KAC9B,CAAC,CAAC;IACH,IAAI,CAAC,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnF;AAED,MAAM,WAAW,wBAAwB;IACvC,GAAG,IAAI,OAAO,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnC,GAAG,CAAC,OAAO,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnF;AAED,eAAO,MAAM,eAAe,uBAA2D,CAAC;AACxF,eAAO,MAAM,kBAAkB,0BACiC,CAAC;AACjE,eAAO,MAAM,iBAAiB,yBACgC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { registerPlugin } from "@capacitor/core";
2
+ export const ParlorDeviceKey = registerPlugin("ParlorDeviceKey");
3
+ export const ParlorSessionStore = registerPlugin("ParlorSessionStore");
4
+ export const ParlorAuthBrowser = registerPlugin("ParlorAuthBrowser");
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAoBjD,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAwB,iBAAiB,CAAC,CAAC;AACxF,MAAM,CAAC,MAAM,kBAAkB,GAC7B,cAAc,CAA2B,oBAAoB,CAAC,CAAC;AACjE,MAAM,CAAC,MAAM,iBAAiB,GAC5B,cAAc,CAA0B,mBAAmB,CAAC,CAAC","sourcesContent":["import { registerPlugin } from \"@capacitor/core\";\n\nexport interface ParlorDeviceKeyPlugin {\n publicKeys(options: { presence: boolean; challenge: string }): Promise<{\n signing: { keyId: string; publicKey: JsonWebKey };\n presence?: { keyId: string; publicKey: JsonWebKey };\n proof: { signature: string };\n }>;\n sign(options: { keyId: string; payload: string }): Promise<{ signature: string }>;\n}\n\nexport interface ParlorSessionStorePlugin {\n get(): Promise<{ value?: string }>;\n set(options: { value?: string }): Promise<void>;\n}\n\nexport interface ParlorAuthBrowserPlugin {\n open(options: { url: string; callbackScheme: string }): Promise<{ url?: string }>;\n}\n\nexport const ParlorDeviceKey = registerPlugin<ParlorDeviceKeyPlugin>(\"ParlorDeviceKey\");\nexport const ParlorSessionStore =\n registerPlugin<ParlorSessionStorePlugin>(\"ParlorSessionStore\");\nexport const ParlorAuthBrowser =\n registerPlugin<ParlorAuthBrowserPlugin>(\"ParlorAuthBrowser\");\n"]}
@@ -0,0 +1,122 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ public struct PublicPoint: Equatable {
5
+ public let x: String
6
+ public let y: String
7
+
8
+ public init(x: String, y: String) {
9
+ self.x = x
10
+ self.y = y
11
+ }
12
+
13
+ public var jwk: [String: String] {
14
+ ["kty": "EC", "crv": "P-256", "x": x, "y": y]
15
+ }
16
+ }
17
+
18
+ public enum KeyEncoding {
19
+ public static func base64url(_ data: Data) -> String {
20
+ data.base64EncodedString()
21
+ .replacingOccurrences(of: "+", with: "-")
22
+ .replacingOccurrences(of: "/", with: "_")
23
+ .replacingOccurrences(of: "=", with: "")
24
+ }
25
+
26
+ public static func data(fromBase64url text: String) -> Data? {
27
+ var base64 = text
28
+ .replacingOccurrences(of: "-", with: "+")
29
+ .replacingOccurrences(of: "_", with: "/")
30
+ base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
31
+ return Data(base64Encoded: base64)
32
+ }
33
+
34
+ public static func point(x963 representation: Data) -> PublicPoint? {
35
+ guard representation.count == 65, representation.first == 0x04 else { return nil }
36
+ let bytes = representation.dropFirst()
37
+ return PublicPoint(
38
+ x: base64url(Data(bytes.prefix(32))),
39
+ y: base64url(Data(bytes.suffix(32))))
40
+ }
41
+
42
+ public static func thumbprint(_ point: PublicPoint) -> String {
43
+ let text = "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"\(point.x)\",\"y\":\"\(point.y)\"}"
44
+ return base64url(Data(SHA256.hash(data: Data(text.utf8))))
45
+ }
46
+
47
+ public static func enrollmentBytes(
48
+ challenge: String,
49
+ signing: PublicPoint,
50
+ presence: (keyId: String, point: PublicPoint)?
51
+ ) -> Data {
52
+ let presenceText =
53
+ presence.map {
54
+ "{\"format\":\"es256-raw\",\"id\":\(quoted($0.keyId)),\"publicKey\":\(jwkText($0.point))}"
55
+ } ?? "null"
56
+ let text =
57
+ "{\"challenge\":\(quoted(challenge)),\"presence\":\(presenceText),\"signing\":\(jwkText(signing))}"
58
+ return Data(text.utf8)
59
+ }
60
+
61
+ public static func rawSignature(fromDER der: Data) -> Data? {
62
+ let bytes = [UInt8](der)
63
+ var at = 0
64
+ guard let sequence = readTag(bytes, &at, expecting: 0x30), sequence == bytes.count - at
65
+ else { return nil }
66
+ guard let r = readInteger(bytes, &at), let s = readInteger(bytes, &at), at == bytes.count
67
+ else { return nil }
68
+ return Data(r + s)
69
+ }
70
+
71
+ private static func jwkText(_ point: PublicPoint) -> String {
72
+ "{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\(quoted(point.x)),\"y\":\(quoted(point.y))}"
73
+ }
74
+
75
+ private static func quoted(_ text: String) -> String {
76
+ var out = "\""
77
+ for scalar in text.unicodeScalars {
78
+ switch scalar {
79
+ case "\"": out += "\\\""
80
+ case "\\": out += "\\\\"
81
+ case "\n": out += "\\n"
82
+ case "\r": out += "\\r"
83
+ case "\t": out += "\\t"
84
+ default:
85
+ if scalar.value < 0x20 {
86
+ out += String(format: "\\u%04x", scalar.value)
87
+ } else {
88
+ out.unicodeScalars.append(scalar)
89
+ }
90
+ }
91
+ }
92
+ return out + "\""
93
+ }
94
+
95
+ private static func readTag(_ bytes: [UInt8], _ at: inout Int, expecting tag: UInt8) -> Int? {
96
+ guard at < bytes.count, bytes[at] == tag else { return nil }
97
+ at += 1
98
+ guard at < bytes.count else { return nil }
99
+ let first = Int(bytes[at])
100
+ at += 1
101
+ if first < 0x80 { return first }
102
+ let count = first & 0x7f
103
+ guard count >= 1, count <= 2, at + count <= bytes.count else { return nil }
104
+ var length = 0
105
+ for _ in 0..<count {
106
+ length = (length << 8) | Int(bytes[at])
107
+ at += 1
108
+ }
109
+ return length
110
+ }
111
+
112
+ private static func readInteger(_ bytes: [UInt8], _ at: inout Int) -> [UInt8]? {
113
+ guard let length = readTag(bytes, &at, expecting: 0x02), length > 0,
114
+ at + length <= bytes.count
115
+ else { return nil }
116
+ var value = Array(bytes[at..<(at + length)])
117
+ at += length
118
+ while value.count > 1, value.first == 0 { value.removeFirst() }
119
+ guard value.count <= 32 else { return nil }
120
+ return [UInt8](repeating: 0, count: 32 - value.count) + value
121
+ }
122
+ }
@@ -0,0 +1,172 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import ParlorDeviceKeyCore
4
+ import Security
5
+
6
+ enum DeviceKeyError: LocalizedError {
7
+ case keystore(String)
8
+ case presenceRefused
9
+ case unknownKey
10
+ case badPayload
11
+
12
+ var errorDescription: String? {
13
+ switch self {
14
+ case .keystore(let reason): return reason
15
+ case .presenceRefused: return "The presence check did not complete."
16
+ case .unknownKey: return "This device has no such key."
17
+ case .badPayload: return "The payload is not base64url."
18
+ }
19
+ }
20
+ }
21
+
22
+ enum KeyRole: String {
23
+ case signing
24
+ case presence
25
+ }
26
+
27
+ struct StoredKey {
28
+ let ref: SecKey
29
+ let point: PublicPoint
30
+ let keyId: String
31
+ let inSecureEnclave: Bool
32
+ }
33
+
34
+ final class DeviceKeyStore {
35
+ private let tagPrefix: String
36
+
37
+ init(bundleId: String = Bundle.main.bundleIdentifier ?? "parlor") {
38
+ tagPrefix = "\(bundleId).parlor.device."
39
+ }
40
+
41
+ func existing(_ role: KeyRole, context: LAContext? = nil) -> StoredKey? {
42
+ var query = baseQuery(role)
43
+ query[kSecReturnRef as String] = true
44
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
45
+ if let context { query[kSecUseAuthenticationContext as String] = context }
46
+ var item: CFTypeRef?
47
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
48
+ let item
49
+ else { return nil }
50
+ return describe(item as! SecKey)
51
+ }
52
+
53
+ func ensure(_ role: KeyRole) throws -> StoredKey {
54
+ if let found = existing(role) { return found }
55
+ if let created = try createInSecureEnclave(role) { return created }
56
+ return try createInSoftware(role)
57
+ }
58
+
59
+ func canConfirmPresence() -> Bool {
60
+ var error: NSError?
61
+ return LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
62
+ }
63
+
64
+ func lookup(keyId: String, context: LAContext) -> (role: KeyRole, key: StoredKey)? {
65
+ for role in [KeyRole.signing, .presence] {
66
+ if let key = existing(role, context: context), key.keyId == keyId {
67
+ return (role, key)
68
+ }
69
+ }
70
+ return nil
71
+ }
72
+
73
+ func sign(_ key: StoredKey, role: KeyRole, payload: Data, context: LAContext) throws -> Data {
74
+ if role == .presence && !key.inSecureEnclave {
75
+ try confirmPresence(context)
76
+ }
77
+ var error: Unmanaged<CFError>?
78
+ guard
79
+ let der = SecKeyCreateSignature(
80
+ key.ref, .ecdsaSignatureMessageX962SHA256, payload as CFData, &error)
81
+ else {
82
+ throw DeviceKeyError.keystore(
83
+ error?.takeRetainedValue().localizedDescription ?? "The signature failed.")
84
+ }
85
+ guard let raw = KeyEncoding.rawSignature(fromDER: der as Data) else {
86
+ throw DeviceKeyError.keystore("The keystore returned a malformed signature.")
87
+ }
88
+ return raw
89
+ }
90
+
91
+ private func confirmPresence(_ context: LAContext) throws {
92
+ var denied: Error?
93
+ let done = DispatchSemaphore(value: 0)
94
+ context.evaluatePolicy(
95
+ .deviceOwnerAuthentication, localizedReason: "Confirm it is you"
96
+ ) { ok, error in
97
+ if !ok { denied = error ?? DeviceKeyError.presenceRefused }
98
+ done.signal()
99
+ }
100
+ done.wait()
101
+ if denied != nil { throw DeviceKeyError.presenceRefused }
102
+ }
103
+
104
+ private func tag(_ role: KeyRole) -> Data {
105
+ Data((tagPrefix + role.rawValue).utf8)
106
+ }
107
+
108
+ private func baseQuery(_ role: KeyRole) -> [String: Any] {
109
+ [
110
+ kSecClass as String: kSecClassKey,
111
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
112
+ kSecAttrApplicationTag as String: tag(role),
113
+ ]
114
+ }
115
+
116
+ private func describe(_ ref: SecKey) -> StoredKey? {
117
+ guard let publicKey = SecKeyCopyPublicKey(ref),
118
+ let representation = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?,
119
+ let point = KeyEncoding.point(x963: representation)
120
+ else { return nil }
121
+ let attributes = SecKeyCopyAttributes(ref) as? [String: Any]
122
+ let token = attributes?[kSecAttrTokenID as String] as? String
123
+ return StoredKey(
124
+ ref: ref,
125
+ point: point,
126
+ keyId: KeyEncoding.thumbprint(point),
127
+ inSecureEnclave: token == (kSecAttrTokenIDSecureEnclave as String))
128
+ }
129
+
130
+ private func create(_ role: KeyRole, secureEnclave: Bool) throws -> StoredKey? {
131
+ var privateAttributes: [String: Any] = [
132
+ kSecAttrIsPermanent as String: true,
133
+ kSecAttrApplicationTag as String: tag(role),
134
+ ]
135
+ var attributes: [String: Any] = [
136
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
137
+ kSecAttrKeySizeInBits as String: 256,
138
+ ]
139
+ if secureEnclave {
140
+ var flags: SecAccessControlCreateFlags = [.privateKeyUsage]
141
+ if role == .presence { flags.insert(.userPresence) }
142
+ guard
143
+ let access = SecAccessControlCreateWithFlags(
144
+ nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, nil)
145
+ else { return nil }
146
+ privateAttributes[kSecAttrAccessControl as String] = access
147
+ attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
148
+ } else {
149
+ privateAttributes[kSecAttrAccessible as String] =
150
+ kSecAttrAccessibleWhenUnlockedThisDeviceOnly
151
+ }
152
+ attributes[kSecPrivateKeyAttrs as String] = privateAttributes
153
+ var error: Unmanaged<CFError>?
154
+ guard let ref = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
155
+ if secureEnclave { return nil }
156
+ throw DeviceKeyError.keystore(
157
+ error?.takeRetainedValue().localizedDescription ?? "The key could not be created.")
158
+ }
159
+ return describe(ref)
160
+ }
161
+
162
+ private func createInSecureEnclave(_ role: KeyRole) throws -> StoredKey? {
163
+ try create(role, secureEnclave: true)
164
+ }
165
+
166
+ private func createInSoftware(_ role: KeyRole) throws -> StoredKey {
167
+ guard let key = try create(role, secureEnclave: false) else {
168
+ throw DeviceKeyError.keystore("The key could not be created.")
169
+ }
170
+ return key
171
+ }
172
+ }
@@ -0,0 +1,58 @@
1
+ import AuthenticationServices
2
+ import Capacitor
3
+ import Foundation
4
+
5
+ @objc(ParlorAuthBrowserPlugin)
6
+ public class ParlorAuthBrowserPlugin: CAPPlugin, CAPBridgedPlugin,
7
+ ASWebAuthenticationPresentationContextProviding
8
+ {
9
+ public let identifier = "ParlorAuthBrowserPlugin"
10
+ public let jsName = "ParlorAuthBrowser"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "open", returnType: CAPPluginReturnPromise)
13
+ ]
14
+
15
+ private var session: ASWebAuthenticationSession?
16
+
17
+ @objc func open(_ call: CAPPluginCall) {
18
+ guard let address = call.getString("url"), let url = URL(string: address),
19
+ url.scheme == "https" || url.scheme == "http",
20
+ let scheme = call.getString("callbackScheme"), !scheme.isEmpty
21
+ else {
22
+ call.reject("An http(s) url and a callbackScheme are required.")
23
+ return
24
+ }
25
+ DispatchQueue.main.async {
26
+ self.session?.cancel()
27
+ var running: ASWebAuthenticationSession?
28
+ let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) {
29
+ [weak self] callback, error in
30
+ if let self, self.session === running { self.session = nil }
31
+ running = nil
32
+ if let callback {
33
+ call.resolve(["url": callback.absoluteString])
34
+ } else if let error = error as? ASWebAuthenticationSessionError,
35
+ error.code == .canceledLogin
36
+ {
37
+ call.resolve([:])
38
+ } else {
39
+ call.reject(error?.localizedDescription ?? "Sign-in did not finish.")
40
+ }
41
+ }
42
+ session.presentationContextProvider = self
43
+ running = session
44
+ self.session = session
45
+ if !session.start() {
46
+ self.session = nil
47
+ running = nil
48
+ call.reject("The sign-in browser could not open.")
49
+ }
50
+ }
51
+ }
52
+
53
+ public func presentationAnchor(for session: ASWebAuthenticationSession)
54
+ -> ASPresentationAnchor
55
+ {
56
+ bridge?.webView?.window ?? ASPresentationAnchor()
57
+ }
58
+ }
@@ -0,0 +1,76 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import LocalAuthentication
4
+ import ParlorDeviceKeyCore
5
+
6
+ @objc(ParlorDeviceKeyPlugin)
7
+ public class ParlorDeviceKeyPlugin: CAPPlugin, CAPBridgedPlugin {
8
+ public let identifier = "ParlorDeviceKeyPlugin"
9
+ public let jsName = "ParlorDeviceKey"
10
+ public let pluginMethods: [CAPPluginMethod] = [
11
+ CAPPluginMethod(name: "publicKeys", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "sign", returnType: CAPPluginReturnPromise),
13
+ ]
14
+
15
+ private let store = DeviceKeyStore()
16
+ private let queue = DispatchQueue(label: "dev.parlor.device-key")
17
+
18
+ @objc func publicKeys(_ call: CAPPluginCall) {
19
+ guard let challenge = call.getString("challenge") else {
20
+ call.reject("challenge is required")
21
+ return
22
+ }
23
+ let wantsPresence = call.getBool("presence") ?? false
24
+ queue.async {
25
+ do {
26
+ let signing = try self.store.ensure(.signing)
27
+ var presence: StoredKey?
28
+ if wantsPresence && self.store.canConfirmPresence() {
29
+ presence = try? self.store.ensure(.presence)
30
+ }
31
+ let enrollment = KeyEncoding.enrollmentBytes(
32
+ challenge: challenge,
33
+ signing: signing.point,
34
+ presence: presence.map { ($0.keyId, $0.point) })
35
+ let proof = try self.store.sign(
36
+ signing, role: .signing, payload: enrollment, context: LAContext())
37
+ var reply: [String: Any] = [
38
+ "signing": ["keyId": signing.keyId, "publicKey": signing.point.jwk],
39
+ "proof": ["signature": KeyEncoding.base64url(proof)],
40
+ ]
41
+ if let presence {
42
+ reply["presence"] = ["keyId": presence.keyId, "publicKey": presence.point.jwk]
43
+ }
44
+ call.resolve(reply)
45
+ } catch {
46
+ call.reject(error.localizedDescription)
47
+ }
48
+ }
49
+ }
50
+
51
+ @objc func sign(_ call: CAPPluginCall) {
52
+ guard let keyId = call.getString("keyId"), let encoded = call.getString("payload") else {
53
+ call.reject("keyId and payload are required")
54
+ return
55
+ }
56
+ guard let payload = KeyEncoding.data(fromBase64url: encoded) else {
57
+ call.reject(DeviceKeyError.badPayload.localizedDescription)
58
+ return
59
+ }
60
+ queue.async {
61
+ let context = LAContext()
62
+ context.localizedReason = "Confirm it is you"
63
+ guard let found = self.store.lookup(keyId: keyId, context: context) else {
64
+ call.reject(DeviceKeyError.unknownKey.localizedDescription)
65
+ return
66
+ }
67
+ do {
68
+ let signature = try self.store.sign(
69
+ found.key, role: found.role, payload: payload, context: context)
70
+ call.resolve(["signature": KeyEncoding.base64url(signature)])
71
+ } catch {
72
+ call.reject(error.localizedDescription)
73
+ }
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,61 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import Security
4
+
5
+ @objc(ParlorSessionStorePlugin)
6
+ public class ParlorSessionStorePlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "ParlorSessionStorePlugin"
8
+ public let jsName = "ParlorSessionStore"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
12
+ ]
13
+
14
+ private let account = "parlor.session"
15
+
16
+ private var identity: [String: Any] {
17
+ [
18
+ kSecClass as String: kSecClassGenericPassword,
19
+ kSecAttrService as String: Bundle.main.bundleIdentifier ?? "parlor",
20
+ kSecAttrAccount as String: account,
21
+ ]
22
+ }
23
+
24
+ @objc func get(_ call: CAPPluginCall) {
25
+ var query = identity
26
+ query[kSecReturnData as String] = true
27
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
28
+ var item: CFTypeRef?
29
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
30
+ if status == errSecItemNotFound {
31
+ call.resolve([:])
32
+ } else if status == errSecSuccess, let data = item as? Data,
33
+ let value = String(data: data, encoding: .utf8)
34
+ {
35
+ call.resolve(["value": value])
36
+ } else {
37
+ call.reject("The Keychain could not be read (\(status)).")
38
+ }
39
+ }
40
+
41
+ @objc func set(_ call: CAPPluginCall) {
42
+ let deleted = SecItemDelete(identity as CFDictionary)
43
+ guard deleted == errSecSuccess || deleted == errSecItemNotFound else {
44
+ call.reject("The Keychain could not be updated (\(deleted)).")
45
+ return
46
+ }
47
+ guard let value = call.getString("value"), !value.isEmpty else {
48
+ call.resolve()
49
+ return
50
+ }
51
+ var attributes = identity
52
+ attributes[kSecValueData as String] = Data(value.utf8)
53
+ attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
54
+ let status = SecItemAdd(attributes as CFDictionary, nil)
55
+ if status == errSecSuccess {
56
+ call.resolve()
57
+ } else {
58
+ call.reject("The Keychain could not be updated (\(status)).")
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,63 @@
1
+ import CryptoKit
2
+ import XCTest
3
+
4
+ @testable import ParlorDeviceKeyCore
5
+
6
+ final class KeyEncodingTests: XCTestCase {
7
+ private let signing = PublicPoint(
8
+ x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
9
+ y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0")
10
+ private let presence = PublicPoint(
11
+ x: "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsDRN9lH8s6Ra6vc",
12
+ y: "KE0n8UyW-WMcIRb9_SkfD1BO2nO8w0z0K_Bm3b1o9uw")
13
+
14
+ func testThumbprintMatchesTheWebAdapter() {
15
+ XCTAssertEqual(
16
+ KeyEncoding.thumbprint(signing), "oKIywvGUpTVTyxMQ3bwIIeQUudfr_CkLMjCE19ECD-U")
17
+ }
18
+
19
+ func testEnrollmentBytesMatchTheCanonicalJson() {
20
+ let bytes = KeyEncoding.enrollmentBytes(challenge: "AQID-g", signing: signing, presence: nil)
21
+ XCTAssertEqual(
22
+ String(decoding: bytes, as: UTF8.self),
23
+ "{\"challenge\":\"AQID-g\",\"presence\":null,\"signing\":{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"\(signing.x)\",\"y\":\"\(signing.y)\"}}"
24
+ )
25
+ let both = KeyEncoding.enrollmentBytes(
26
+ challenge: "AQID-g", signing: signing,
27
+ presence: (KeyEncoding.thumbprint(presence), presence))
28
+ XCTAssertEqual(
29
+ String(decoding: both, as: UTF8.self),
30
+ "{\"challenge\":\"AQID-g\",\"presence\":{\"format\":\"es256-raw\",\"id\":\"HePMXFv0dmgSDC07-rfMd1T6M1tz4TSm2ovWfz3HKvc\",\"publicKey\":{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"\(presence.x)\",\"y\":\"\(presence.y)\"}},\"signing\":{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"\(signing.x)\",\"y\":\"\(signing.y)\"}}"
31
+ )
32
+ }
33
+
34
+ func testPointFromX963SplitsTheCoordinates() {
35
+ let key = P256.Signing.PrivateKey().publicKey
36
+ let raw = key.rawRepresentation
37
+ let point = KeyEncoding.point(x963: key.x963Representation)
38
+ XCTAssertEqual(point?.x, KeyEncoding.base64url(raw.prefix(32)))
39
+ XCTAssertEqual(point?.y, KeyEncoding.base64url(raw.suffix(32)))
40
+ XCTAssertNil(KeyEncoding.point(x963: raw))
41
+ }
42
+
43
+ func testDerSignaturesBecomeRawRAndS() throws {
44
+ let key = P256.Signing.PrivateKey()
45
+ for round in 0..<64 {
46
+ let signature = try key.signature(for: Data("payload \(round)".utf8))
47
+ XCTAssertEqual(
48
+ KeyEncoding.rawSignature(fromDER: signature.derRepresentation),
49
+ signature.rawRepresentation)
50
+ }
51
+ }
52
+
53
+ func testMalformedDerIsRefused() {
54
+ XCTAssertNil(KeyEncoding.rawSignature(fromDER: Data([0x30, 0x02, 0x02, 0x00])))
55
+ XCTAssertNil(KeyEncoding.rawSignature(fromDER: Data()))
56
+ }
57
+
58
+ func testBase64urlRoundTrips() {
59
+ let data = Data([1, 2, 3, 250])
60
+ XCTAssertEqual(KeyEncoding.base64url(data), "AQID-g")
61
+ XCTAssertEqual(KeyEncoding.data(fromBase64url: "AQID-g"), data)
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@parlor/capacitor-device-key",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Capacitor plugins for the device key and the session store, backed by the iOS keystore",
5
+ "homepage": "https://cychien.github.io/parlor",
6
+ "bugs": "https://github.com/cychien/parlor/issues",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cychien/parlor.git",
11
+ "directory": "packages/capacitor-device-key"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "ios",
16
+ "Package.swift"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "devDependencies": {
27
+ "@capacitor/core": "^8.5.2",
28
+ "@types/node": "^24.14.0",
29
+ "typescript": "^7.0.2"
30
+ },
31
+ "peerDependencies": {
32
+ "@capacitor/core": ">=8"
33
+ },
34
+ "capacitor": {
35
+ "ios": {
36
+ "src": "ios"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ }
6
43
  }