Skip to content

Commit b37d2de

Browse files
fixup! Add app/device attestation for gated info-server requests
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4ae023b commit b37d2de

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

ios/edge/EdgeAttestation.m

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,13 @@ @interface RCT_EXTERN_MODULE (EdgeAttestation, NSObject)
1313
resolver:(RCTPromiseResolveBlock)resolve
1414
rejecter:(RCTPromiseRejectBlock)reject)
1515

16+
RCT_EXTERN_METHOD(generateAssertion
17+
: (NSString *)challenge
18+
resolver:(RCTPromiseResolveBlock)resolve
19+
rejecter:(RCTPromiseRejectBlock)reject)
20+
21+
RCT_EXTERN_METHOD(clearKey
22+
: (RCTPromiseResolveBlock)resolve
23+
rejecter:(RCTPromiseRejectBlock)reject)
24+
1625
@end

ios/edge/EdgeAttestation.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import CryptoKit
22
import DeviceCheck
33
import Foundation
44
import React
5+
import Security
56

67
/// Native bridge for iOS App Attest (app-level attestation).
78
///
@@ -16,6 +17,48 @@ class EdgeAttestation: NSObject {
1617
return false
1718
}
1819

20+
// Keychain persistence for the attested App Attest key id. App Attest private
21+
// keys live in the Secure Enclave keyed by this id; Apple recommends storing
22+
// the id in the Keychain so it survives across launches and is reused for
23+
// assertions (no re-attestation).
24+
private static let keychainService = "co.edgesecure.app.appattest"
25+
private static let keychainAccount = "keyId"
26+
27+
private func storeKeyId(_ keyId: String) {
28+
clearKeyId()
29+
guard let data = keyId.data(using: .utf8) else { return }
30+
let query: [String: Any] = [
31+
kSecClass as String: kSecClassGenericPassword,
32+
kSecAttrService as String: EdgeAttestation.keychainService,
33+
kSecAttrAccount as String: EdgeAttestation.keychainAccount,
34+
kSecValueData as String: data
35+
]
36+
SecItemAdd(query as CFDictionary, nil)
37+
}
38+
39+
private func loadKeyId() -> String? {
40+
let query: [String: Any] = [
41+
kSecClass as String: kSecClassGenericPassword,
42+
kSecAttrService as String: EdgeAttestation.keychainService,
43+
kSecAttrAccount as String: EdgeAttestation.keychainAccount,
44+
kSecReturnData as String: true,
45+
kSecMatchLimit as String: kSecMatchLimitOne
46+
]
47+
var item: CFTypeRef?
48+
let status = SecItemCopyMatching(query as CFDictionary, &item)
49+
guard status == errSecSuccess, let data = item as? Data else { return nil }
50+
return String(data: data, encoding: .utf8)
51+
}
52+
53+
private func clearKeyId() {
54+
let query: [String: Any] = [
55+
kSecClass as String: kSecClassGenericPassword,
56+
kSecAttrService as String: EdgeAttestation.keychainService,
57+
kSecAttrAccount as String: EdgeAttestation.keychainAccount
58+
]
59+
SecItemDelete(query as CFDictionary)
60+
}
61+
1962
@objc(isSupported:rejecter:)
2063
func isSupported(
2164
_ resolve: @escaping RCTPromiseResolveBlock,
@@ -69,6 +112,9 @@ class EdgeAttestation: NSObject {
69112
reject("attestKey", "Failed to produce an attestation object", nil)
70113
return
71114
}
115+
// Persist the key id so subsequent handshakes refresh via assertions
116+
// instead of a full (rate-limited) attestation.
117+
self.storeKeyId(keyId)
72118
resolve([
73119
"keyId": keyId,
74120
"attestation": attestation.base64EncodedString(),
@@ -77,4 +123,51 @@ class EdgeAttestation: NSObject {
77123
}
78124
}
79125
}
126+
127+
@objc(generateAssertion:resolver:rejecter:)
128+
func generateAssertion(
129+
_ challenge: String,
130+
resolver resolve: @escaping RCTPromiseResolveBlock,
131+
rejecter reject: @escaping RCTPromiseRejectBlock
132+
) {
133+
guard #available(iOS 14.0, *), DCAppAttestService.shared.isSupported else {
134+
reject("unsupported", "App Attest is not supported on this device", nil)
135+
return
136+
}
137+
guard let keyId = loadKeyId() else {
138+
reject("noKey", "No attested App Attest key is stored", nil)
139+
return
140+
}
141+
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
142+
DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
143+
if let error = error as? DCError, error.code == .invalidKey {
144+
// The key no longer exists (reinstall/restore); force re-attestation.
145+
self.clearKeyId()
146+
reject("invalidKey", "Stored App Attest key is invalid", error)
147+
return
148+
}
149+
if let error = error {
150+
reject("generateAssertion", error.localizedDescription, error)
151+
return
152+
}
153+
guard let assertion = assertion else {
154+
reject("generateAssertion", "Failed to produce an assertion", nil)
155+
return
156+
}
157+
resolve([
158+
"keyId": keyId,
159+
"assertion": assertion.base64EncodedString(),
160+
"bundleId": Bundle.main.bundleIdentifier ?? ""
161+
])
162+
}
163+
}
164+
165+
@objc(clearKey:rejecter:)
166+
func clearKey(
167+
_ resolve: @escaping RCTPromiseResolveBlock,
168+
rejecter reject: @escaping RCTPromiseRejectBlock
169+
) {
170+
clearKeyId()
171+
resolve(nil)
172+
}
80173
}

0 commit comments

Comments
 (0)