Skip to content

Commit ca6d0c1

Browse files
committed
Serialize iOS App Attest key operations to match Android
1 parent 7cc4997 commit ca6d0c1

1 file changed

Lines changed: 81 additions & 51 deletions

File tree

ios/edge/EdgeAttestation.swift

Lines changed: 81 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ class EdgeAttestation: NSObject {
1717
return false
1818
}
1919

20+
// Serializes all key operations. The JS engine normally single-flights
21+
// handshakes, but its 90s watchdog can release the lock and start a second
22+
// handshake while an older native call is still running. Without this queue,
23+
// overlapping getAttestation / generateAssertion / clearKey calls could race
24+
// on the stored key id and leave assertions out of sync with the cached JWT.
25+
// Each async App Attest operation holds the queue (via a semaphore) until it
26+
// completes, so the operations never interleave.
27+
private static let serialQueue = DispatchQueue(
28+
label: "co.edgesecure.app.appattest.serial"
29+
)
30+
2031
// Keychain persistence for the attested App Attest key id. App Attest private
2132
// keys live in the Secure Enclave keyed by this id; Apple recommends storing
2233
// the id in the Keychain so it survives across launches and is reused for
@@ -87,40 +98,51 @@ class EdgeAttestation: NSObject {
8798
return
8899
}
89100

90-
// A fresh key is generated per handshake: an App Attest key can only be
91-
// attested once, so reuse would require the assertion flow instead.
92-
service.generateKey { keyId, error in
93-
if let error = error {
94-
reject("generateKey", error.localizedDescription, error)
95-
return
96-
}
97-
guard let keyId = keyId else {
98-
reject("generateKey", "Failed to generate an App Attest key", nil)
99-
return
100-
}
101+
// Serialize against other key operations; hold the queue until the async
102+
// attest completes.
103+
EdgeAttestation.serialQueue.async {
104+
let done = DispatchSemaphore(value: 0)
101105

102-
// The client data is the challenge's UTF-8 bytes; the server recomputes
103-
// SHA256(challenge) to validate the attestation nonce.
104-
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
105-
106-
service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in
106+
// A fresh key is generated per handshake: an App Attest key can only be
107+
// attested once, so reuse would require the assertion flow instead.
108+
service.generateKey { keyId, error in
107109
if let error = error {
108-
reject("attestKey", error.localizedDescription, error)
110+
reject("generateKey", error.localizedDescription, error)
111+
done.signal()
109112
return
110113
}
111-
guard let attestation = attestation else {
112-
reject("attestKey", "Failed to produce an attestation object", nil)
114+
guard let keyId = keyId else {
115+
reject("generateKey", "Failed to generate an App Attest key", nil)
116+
done.signal()
113117
return
114118
}
115-
// Persist the key id so subsequent handshakes refresh via assertions
116-
// instead of a full (rate-limited) attestation.
117-
self.storeKeyId(keyId)
118-
resolve([
119-
"keyId": keyId,
120-
"attestation": attestation.base64EncodedString(),
121-
"bundleId": Bundle.main.bundleIdentifier ?? ""
122-
])
119+
120+
// The client data is the challenge's UTF-8 bytes; the server recomputes
121+
// SHA256(challenge) to validate the attestation nonce.
122+
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
123+
124+
service.attestKey(keyId, clientDataHash: clientDataHash) { attestation, error in
125+
defer { done.signal() }
126+
if let error = error {
127+
reject("attestKey", error.localizedDescription, error)
128+
return
129+
}
130+
guard let attestation = attestation else {
131+
reject("attestKey", "Failed to produce an attestation object", nil)
132+
return
133+
}
134+
// Persist the key id so subsequent handshakes refresh via assertions
135+
// instead of a full (rate-limited) attestation.
136+
self.storeKeyId(keyId)
137+
resolve([
138+
"keyId": keyId,
139+
"attestation": attestation.base64EncodedString(),
140+
"bundleId": Bundle.main.bundleIdentifier ?? ""
141+
])
142+
}
123143
}
144+
145+
done.wait()
124146
}
125147
}
126148

@@ -134,31 +156,37 @@ class EdgeAttestation: NSObject {
134156
reject("unsupported", "App Attest is not supported on this device", nil)
135157
return
136158
}
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)
159+
160+
EdgeAttestation.serialQueue.async {
161+
guard let keyId = self.loadKeyId() else {
162+
reject("noKey", "No attested App Attest key is stored", nil)
151163
return
152164
}
153-
guard let assertion = assertion else {
154-
reject("generateAssertion", "Failed to produce an assertion", nil)
155-
return
165+
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
166+
let done = DispatchSemaphore(value: 0)
167+
DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
168+
defer { done.signal() }
169+
if let error = error as? DCError, error.code == .invalidKey {
170+
// The key no longer exists (reinstall/restore); force re-attestation.
171+
self.clearKeyId()
172+
reject("invalidKey", "Stored App Attest key is invalid", error)
173+
return
174+
}
175+
if let error = error {
176+
reject("generateAssertion", error.localizedDescription, error)
177+
return
178+
}
179+
guard let assertion = assertion else {
180+
reject("generateAssertion", "Failed to produce an assertion", nil)
181+
return
182+
}
183+
resolve([
184+
"keyId": keyId,
185+
"assertion": assertion.base64EncodedString(),
186+
"bundleId": Bundle.main.bundleIdentifier ?? ""
187+
])
156188
}
157-
resolve([
158-
"keyId": keyId,
159-
"assertion": assertion.base64EncodedString(),
160-
"bundleId": Bundle.main.bundleIdentifier ?? ""
161-
])
189+
done.wait()
162190
}
163191
}
164192

@@ -167,7 +195,9 @@ class EdgeAttestation: NSObject {
167195
_ resolve: @escaping RCTPromiseResolveBlock,
168196
rejecter reject: @escaping RCTPromiseRejectBlock
169197
) {
170-
clearKeyId()
171-
resolve(nil)
198+
EdgeAttestation.serialQueue.async {
199+
self.clearKeyId()
200+
resolve(nil)
201+
}
172202
}
173203
}

0 commit comments

Comments
 (0)