Skip to content

Commit 03eb1ba

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

1 file changed

Lines changed: 76 additions & 4 deletions

File tree

android/app/src/main/java/co/edgesecure/app/EdgeAttestationModule.kt

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import java.security.spec.ECGenParameterSpec
2626
class EdgeAttestationModule(
2727
reactContext: ReactApplicationContext
2828
) : ReactContextBaseJavaModule(reactContext) {
29+
companion object {
30+
// Stable alias: the key is enrolled once via attestation and then reused
31+
// to sign challenges (see signChallenge). Cleared only on clearKey or
32+
// when re-enrollment is needed.
33+
private const val KEY_ALIAS = "edge_attestation_key"
34+
}
35+
2936
override fun getName(): String = "EdgeAttestation"
3037

3138
@ReactMethod
@@ -41,7 +48,7 @@ class EdgeAttestationModule(
4148
) {
4249
// Key generation can be slow; run off the JS thread.
4350
Thread {
44-
val keyAlias = "edge_attestation_" + System.currentTimeMillis()
51+
val keyAlias = KEY_ALIAS
4552
try {
4653
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
4754
promise.reject(
@@ -51,6 +58,16 @@ class EdgeAttestationModule(
5158
return@Thread
5259
}
5360

61+
// getAttestation is only called when (re-)enrollment is required, so a
62+
// leftover key under the stable alias is stale; delete it first.
63+
try {
64+
val existing = KeyStore.getInstance("AndroidKeyStore")
65+
existing.load(null)
66+
existing.deleteEntry(keyAlias)
67+
} catch (ignored: Exception) {
68+
// Best effort.
69+
}
70+
5471
val generator =
5572
KeyPairGenerator.getInstance(
5673
KeyProperties.KEY_ALGORITHM_EC,
@@ -107,17 +124,72 @@ class EdgeAttestationModule(
107124
result.putArray("certChain", certChain)
108125
promise.resolve(result)
109126
} catch (e: Exception) {
110-
promise.reject("attestation_error", e.message, e)
111-
} finally {
112-
// Don't accumulate one-shot attestation keys in the keystore.
127+
// A failed enrollment should not leave a half-created key behind. The
128+
// key is intentionally NOT deleted on success — it survives so
129+
// signChallenge can reuse it for token refreshes.
113130
try {
114131
val keyStore = KeyStore.getInstance("AndroidKeyStore")
115132
keyStore.load(null)
116133
keyStore.deleteEntry(keyAlias)
117134
} catch (ignored: Exception) {
118135
// Best effort cleanup.
119136
}
137+
promise.reject("attestation_error", e.message, e)
138+
}
139+
}.start()
140+
}
141+
142+
@ReactMethod
143+
fun signChallenge(
144+
challenge: String,
145+
promise: Promise
146+
) {
147+
Thread {
148+
try {
149+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
150+
keyStore.load(null)
151+
val entry =
152+
keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry
153+
if (entry == null) {
154+
promise.reject("noKey", "No attested key is stored")
155+
return@Thread
156+
}
157+
// keyId = base64url(SHA-256(leaf SPKI)), matching the server's
158+
// derivation.
159+
val spki = keyStore.getCertificate(KEY_ALIAS).publicKey.encoded
160+
val keyId =
161+
Base64.encodeToString(
162+
java.security.MessageDigest
163+
.getInstance("SHA-256")
164+
.digest(spki),
165+
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
166+
)
167+
val signer = java.security.Signature.getInstance("SHA256withECDSA")
168+
signer.initSign(entry.privateKey)
169+
signer.update(challenge.toByteArray(Charsets.UTF_8))
170+
val signature = Base64.encodeToString(signer.sign(), Base64.NO_WRAP)
171+
172+
val result = Arguments.createMap()
173+
result.putString("keyId", keyId)
174+
result.putString("signature", signature)
175+
promise.resolve(result)
176+
} catch (e: Exception) {
177+
promise.reject("signChallenge", e.message, e)
120178
}
121179
}.start()
122180
}
181+
182+
@ReactMethod
183+
fun clearKey(promise: Promise) {
184+
// Best-effort: force re-enrollment when the server rejects an assertion
185+
// (unknown key, revoked serial, disabled app). Resolve regardless.
186+
try {
187+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
188+
keyStore.load(null)
189+
keyStore.deleteEntry(KEY_ALIAS)
190+
} catch (ignored: Exception) {
191+
// Best effort.
192+
}
193+
promise.resolve(null)
194+
}
123195
}

0 commit comments

Comments
 (0)