@@ -31,6 +31,14 @@ class EdgeAttestationModule(
3131 // to sign challenges (see signChallenge). Cleared only on clearKey or
3232 // when re-enrollment is needed.
3333 private const val KEY_ALIAS = " edge_attestation_key"
34+
35+ // Serializes all AndroidKeyStore access to KEY_ALIAS. getAttestation,
36+ // signChallenge and clearKey each mutate/read the single shared alias; the
37+ // JS engine's watchdog can release its in-flight lock and start a new
38+ // handshake while an older native Thread is still running, so without this
39+ // lock two overlapping getAttestation calls could delete/regenerate the key
40+ // out from under each other and return cross-wired certificate chains.
41+ private val keystoreLock = Any ()
3442 }
3543
3644 override fun getName (): String = " EdgeAttestation"
@@ -46,95 +54,101 @@ class EdgeAttestationModule(
4654 challenge : String ,
4755 promise : Promise
4856 ) {
49- // Key generation can be slow; run off the JS thread.
57+ // Key generation can be slow; run off the JS thread. Serialize all Keystore
58+ // access so an overlapping handshake cannot corrupt the shared alias.
5059 Thread {
51- val keyAlias = KEY_ALIAS
52- try {
53- if (Build .VERSION .SDK_INT < Build .VERSION_CODES .N ) {
54- promise.reject(
55- " unsupported" ,
56- " Key attestation requires Android 7.0 (API 24) or later"
57- )
58- return @Thread
59- }
60-
61- // getAttestation is only called when (re-)enrollment is required, so a
62- // leftover key under the stable alias is stale; delete it first.
60+ synchronized(keystoreLock) {
61+ val keyAlias = KEY_ALIAS
6362 try {
64- val existing = KeyStore .getInstance(" AndroidKeyStore" )
65- existing.load(null )
66- existing.deleteEntry(keyAlias)
67- } catch (ignored: Exception ) {
68- // Best effort.
69- }
70-
71- val generator =
72- KeyPairGenerator .getInstance(
73- KeyProperties .KEY_ALGORITHM_EC ,
74- " AndroidKeyStore"
75- )
76- // The challenge's UTF-8 bytes are bound into the attestation extension;
77- // the server compares them against the challenge it issued.
78- // Builds the spec, optionally requesting a StrongBox-backed key. A
79- // StrongBox (dedicated secure element, e.g. Pixel Titan M) key attests
80- // at `attestationSecurityLevel = strongBox`, which the info server maps
81- // to `secureElement`; a plain TEE key attests as `trustedEnvironment`
82- // -> `hardware`.
83- fun buildSpec (strongBox : Boolean ): KeyGenParameterSpec {
84- val builder =
85- KeyGenParameterSpec
86- .Builder (
87- keyAlias,
88- KeyProperties .PURPOSE_SIGN or KeyProperties .PURPOSE_VERIFY
89- ).setAlgorithmParameterSpec(ECGenParameterSpec (" secp256r1" ))
90- .setDigests(KeyProperties .DIGEST_SHA256 )
91- .setAttestationChallenge(challenge.toByteArray(Charsets .UTF_8 ))
92- if (strongBox && Build .VERSION .SDK_INT >= Build .VERSION_CODES .P ) {
93- builder.setIsStrongBoxBacked(true )
63+ if (Build .VERSION .SDK_INT < Build .VERSION_CODES .N ) {
64+ promise.reject(
65+ " unsupported" ,
66+ " Key attestation requires Android 7.0 (API 24) or later"
67+ )
68+ return @synchronized
9469 }
95- return builder.build()
96- }
9770
98- // Prefer the highest assurance (StrongBox / secure element) and fall
99- // back to the TEE only when this device has no StrongBox.
100- val wantStrongBox = Build .VERSION .SDK_INT >= Build .VERSION_CODES .P
101- try {
102- generator.initialize(buildSpec(wantStrongBox))
103- generator.generateKeyPair()
104- } catch (e: StrongBoxUnavailableException ) {
105- // No StrongBox on this device; fall back to the TEE (hardware).
106- generator.initialize(buildSpec(false ))
107- generator.generateKeyPair()
108- }
71+ // getAttestation is only called when (re-)enrollment is required, so
72+ // a leftover key under the stable alias is stale; delete it first.
73+ try {
74+ val existing = KeyStore .getInstance(" AndroidKeyStore" )
75+ existing.load(null )
76+ existing.deleteEntry(keyAlias)
77+ } catch (ignored: Exception ) {
78+ // Best effort.
79+ }
10980
110- val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
111- keyStore.load(null )
112- val chain = keyStore.getCertificateChain(keyAlias)
113- if (chain == null || chain.isEmpty()) {
114- promise.reject(" attestation_error" , " Empty attestation certificate chain" )
115- return @Thread
116- }
81+ val generator =
82+ KeyPairGenerator .getInstance(
83+ KeyProperties .KEY_ALGORITHM_EC ,
84+ " AndroidKeyStore"
85+ )
86+ // The challenge's UTF-8 bytes are bound into the attestation
87+ // extension; the server compares them against the challenge it
88+ // issued. Builds the spec, optionally requesting a StrongBox-backed
89+ // key. A StrongBox (dedicated secure element, e.g. Pixel Titan M) key
90+ // attests at `attestationSecurityLevel = strongBox`, which the info
91+ // server maps to `secureElement`; a plain TEE key attests as
92+ // `trustedEnvironment` -> `hardware`.
93+ fun buildSpec (strongBox : Boolean ): KeyGenParameterSpec {
94+ val builder =
95+ KeyGenParameterSpec
96+ .Builder (
97+ keyAlias,
98+ KeyProperties .PURPOSE_SIGN or KeyProperties .PURPOSE_VERIFY
99+ ).setAlgorithmParameterSpec(ECGenParameterSpec (" secp256r1" ))
100+ .setDigests(KeyProperties .DIGEST_SHA256 )
101+ .setAttestationChallenge(challenge.toByteArray(Charsets .UTF_8 ))
102+ if (strongBox && Build .VERSION .SDK_INT >= Build .VERSION_CODES .P ) {
103+ builder.setIsStrongBoxBacked(true )
104+ }
105+ return builder.build()
106+ }
117107
118- val certChain = Arguments .createArray()
119- for (cert in chain) {
120- certChain.pushString(Base64 .encodeToString(cert.encoded, Base64 .NO_WRAP ))
121- }
108+ // Prefer the highest assurance (StrongBox / secure element) and fall
109+ // back to the TEE only when this device has no StrongBox.
110+ val wantStrongBox = Build .VERSION .SDK_INT >= Build .VERSION_CODES .P
111+ try {
112+ generator.initialize(buildSpec(wantStrongBox))
113+ generator.generateKeyPair()
114+ } catch (e: StrongBoxUnavailableException ) {
115+ // No StrongBox on this device; fall back to the TEE (hardware).
116+ generator.initialize(buildSpec(false ))
117+ generator.generateKeyPair()
118+ }
122119
123- val result = Arguments .createMap()
124- result.putArray(" certChain" , certChain)
125- promise.resolve(result)
126- } catch (e: Exception ) {
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.
130- try {
131120 val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
132121 keyStore.load(null )
133- keyStore.deleteEntry(keyAlias)
134- } catch (ignored: Exception ) {
135- // Best effort cleanup.
122+ val chain = keyStore.getCertificateChain(keyAlias)
123+ if (chain == null || chain.isEmpty()) {
124+ // Throw so the catch below deletes the half-created key rather than
125+ // leaving it enrolled with an attestation never returned to JS.
126+ throw IllegalStateException (" Empty attestation certificate chain" )
127+ }
128+
129+ val certChain = Arguments .createArray()
130+ for (cert in chain) {
131+ certChain.pushString(
132+ Base64 .encodeToString(cert.encoded, Base64 .NO_WRAP )
133+ )
134+ }
135+
136+ val result = Arguments .createMap()
137+ result.putArray(" certChain" , certChain)
138+ promise.resolve(result)
139+ } catch (e: Exception ) {
140+ // A failed enrollment should not leave a half-created key behind. The
141+ // key is intentionally NOT deleted on success: it survives so
142+ // signChallenge can reuse it for token refreshes.
143+ try {
144+ val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
145+ keyStore.load(null )
146+ keyStore.deleteEntry(keyAlias)
147+ } catch (ignored: Exception ) {
148+ // Best effort cleanup.
149+ }
150+ promise.reject(" attestation_error" , e.message, e)
136151 }
137- promise.reject(" attestation_error" , e.message, e)
138152 }
139153 }.start()
140154 }
@@ -145,36 +159,38 @@ class EdgeAttestationModule(
145159 promise : Promise
146160 ) {
147161 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 )
162+ synchronized(keystoreLock) {
163+ try {
164+ val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
165+ keyStore.load(null )
166+ val entry =
167+ keyStore.getEntry(KEY_ALIAS , null ) as ? KeyStore .PrivateKeyEntry
168+ if (entry == null ) {
169+ promise.reject(" noKey" , " No attested key is stored" )
170+ return @synchronized
171+ }
172+ // keyId = base64url(SHA-256(leaf SPKI)), matching the server's
173+ // derivation.
174+ val spki = keyStore.getCertificate(KEY_ALIAS ).publicKey.encoded
175+ val keyId =
176+ Base64 .encodeToString(
177+ java.security.MessageDigest
178+ .getInstance(" SHA-256" )
179+ .digest(spki),
180+ Base64 .URL_SAFE or Base64 .NO_PADDING or Base64 .NO_WRAP
181+ )
182+ val signer = java.security.Signature .getInstance(" SHA256withECDSA" )
183+ signer.initSign(entry.privateKey)
184+ signer.update(challenge.toByteArray(Charsets .UTF_8 ))
185+ val signature = Base64 .encodeToString(signer.sign(), Base64 .NO_WRAP )
171186
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)
187+ val result = Arguments .createMap()
188+ result.putString(" keyId" , keyId)
189+ result.putString(" signature" , signature)
190+ promise.resolve(result)
191+ } catch (e: Exception ) {
192+ promise.reject(" signChallenge" , e.message, e)
193+ }
178194 }
179195 }.start()
180196 }
@@ -184,9 +200,11 @@ class EdgeAttestationModule(
184200 // Best-effort: force re-enrollment when the server rejects an assertion
185201 // (unknown key, revoked serial, disabled app). Resolve regardless.
186202 try {
187- val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
188- keyStore.load(null )
189- keyStore.deleteEntry(KEY_ALIAS )
203+ synchronized(keystoreLock) {
204+ val keyStore = KeyStore .getInstance(" AndroidKeyStore" )
205+ keyStore.load(null )
206+ keyStore.deleteEntry(KEY_ALIAS )
207+ }
190208 } catch (ignored: Exception ) {
191209 // Best effort.
192210 }
0 commit comments