@@ -26,6 +26,13 @@ import java.security.spec.ECGenParameterSpec
2626class 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