@parlor/capacitor-device-key 0.0.0-stage → 0.1.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 +21 -0
- package/Package.swift +32 -0
- package/README.md +10 -2
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/ios/Sources/ParlorDeviceKeyCore/KeyEncoding.swift +122 -0
- package/ios/Sources/ParlorDeviceKeyPlugin/DeviceKeyStore.swift +172 -0
- package/ios/Sources/ParlorDeviceKeyPlugin/ParlorDeviceKeyPlugin.swift +76 -0
- package/ios/Sources/ParlorDeviceKeyPlugin/ParlorSessionStorePlugin.swift +61 -0
- package/ios/Tests/ParlorDeviceKeyCoreTests/KeyEncodingChecks.swift +63 -0
- package/package.json +40 -3
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
|
-
#
|
|
1
|
+
# @parlor/capacitor-device-key
|
|
2
2
|
|
|
3
|
-
|
|
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/
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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 declare const ParlorDeviceKey: ParlorDeviceKeyPlugin;
|
|
34
|
+
export declare const ParlorSessionStore: ParlorSessionStorePlugin;
|
|
35
|
+
//# 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,eAAO,MAAM,eAAe,uBAA2D,CAAC;AACxF,eAAO,MAAM,kBAAkB,0BACiC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAgBjD,MAAM,CAAC,MAAM,eAAe,GAAG,cAAc,CAAwB,iBAAiB,CAAC,CAAC;AACxF,MAAM,CAAC,MAAM,kBAAkB,GAC7B,cAAc,CAA2B,oBAAoB,CAAC,CAAC"}
|
|
@@ -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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.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
|
}
|