Skip to content

Commit 40713eb

Browse files
committed
fixup! Add app/device attestation for gated info-server requests
1 parent 552fef1 commit 40713eb

6 files changed

Lines changed: 271 additions & 181 deletions

File tree

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

Lines changed: 129 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)