Skip to content

Commit defb2f7

Browse files
committed
feat(rotation): implement key rotation and re-encryption functionality
1 parent 68abcff commit defb2f7

13 files changed

Lines changed: 451 additions & 51 deletions

android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import com.sensitiveinfo.internal.storage.SecureStorage
1515
import com.sensitiveinfo.internal.util.AliasGenerator
1616
import com.sensitiveinfo.internal.util.ReactContextHolder
1717
import com.sensitiveinfo.internal.util.ServiceNameResolver
18+
import com.sensitiveinfo.internal.util.accessControlFromPersisted
19+
import com.sensitiveinfo.internal.util.securityLevelFromPersisted
1820
import com.sensitiveinfo.internal.validation.AndroidStorageValidator
1921
import com.sensitiveinfo.internal.validation.StorageValidator
22+
import com.sensitiveinfo.AndroidKeyRotationManager
2023
import kotlinx.coroutines.CoroutineScope
2124
import kotlinx.coroutines.Dispatchers
2225
import kotlinx.coroutines.SupervisorJob
@@ -45,7 +48,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
4548
val securityAvailabilityResolver: SecurityAvailabilityResolver,
4649
val serviceNameResolver: ServiceNameResolver,
4750
val validator: StorageValidator,
48-
val responseBuilder: ResponseBuilder
51+
val responseBuilder: ResponseBuilder,
52+
val keyRotationManager: AndroidKeyRotationManager
4953
)
5054

5155
@Volatile
@@ -72,7 +76,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
7276
securityAvailabilityResolver = securityAvailabilityResolver,
7377
serviceNameResolver = serviceNameResolver,
7478
validator = AndroidStorageValidator(),
75-
responseBuilder = StandardResponseBuilder()
79+
responseBuilder = StandardResponseBuilder(),
80+
keyRotationManager = AndroidKeyRotationManager(ctx)
7681
).also { built ->
7782
dependencies = built
7883
}
@@ -123,7 +128,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
123128
securityLevel = resolved.securityLevel,
124129
backend = StorageBackend.ANDROIDKEYSTORE,
125130
accessControl = resolved.accessControl,
126-
timestamp = System.currentTimeMillis() / 1000.0
131+
timestamp = System.currentTimeMillis() / 1000.0,
132+
alias = alias
127133
)
128134

129135
// Step 7: Persist entry
@@ -211,8 +217,10 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
211217
securityLevel = SecurityLevel.SOFTWARE,
212218
backend = StorageBackend.ANDROIDKEYSTORE,
213219
accessControl = AccessControl.NONE,
214-
timestamp = System.currentTimeMillis() / 1000.0
215-
)
220+
timestamp = System.currentTimeMillis() / 1000.0,
221+
alias = entry.alias
222+
),
223+
service = service
216224
)
217225
}
218226
}
@@ -320,7 +328,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
320328
securityLevel = SecurityLevel.SOFTWARE,
321329
backend = StorageBackend.ANDROIDKEYSTORE,
322330
accessControl = AccessControl.NONE,
323-
timestamp = System.currentTimeMillis() / 1000.0
331+
timestamp = System.currentTimeMillis() / 1000.0,
332+
alias = entry.alias
324333
)
325334

326335
val value = if (includeValues && entry.ciphertext != null && entry.iv != null) {
@@ -354,7 +363,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
354363
deps.responseBuilder.buildItem(
355364
key = key,
356365
value = value,
357-
metadata = metadata
366+
metadata = metadata,
367+
service = service
358368
)
359369
} catch (e: Throwable) {
360370
// Step 6: Skip items that fail to process
@@ -432,6 +442,103 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
432442
}
433443
}
434444

445+
/**
446+
* Re-encrypts all items with the current key.
447+
* Migrates items encrypted with old keys to the current key version.
448+
*/
449+
override fun reEncryptAllItems(request: ReEncryptAllItemsRequest): Promise<ReEncryptAllItemsResponse> {
450+
return Promise.async(coroutineScope) {
451+
val deps = ensureInitialized()
452+
453+
// Step 1: Resolve service
454+
val service = deps.serviceNameResolver.resolve(request.service ?: "")
455+
456+
// Step 2: Get current key version
457+
var currentKeyVersion = deps.keyRotationManager.getCurrentKeyVersion()
458+
if (currentKeyVersion == null) {
459+
// Generate a new key if none exists
460+
val newKeyId = System.currentTimeMillis().toString()
461+
val success = deps.keyRotationManager.generateNewKey(newKeyId, requiresBiometry = false)
462+
if (success) {
463+
deps.keyRotationManager.rotateToNewKey(newKeyId)
464+
currentKeyVersion = newKeyId
465+
} else {
466+
throw IllegalStateException("Failed to generate initial key for re-encryption")
467+
}
468+
}
469+
470+
// Step 3: Get all entries for the service
471+
val entries = deps.storage.readAll(service)
472+
473+
var reEncryptedCount = 0
474+
val errors = mutableListOf<ReEncryptError>()
475+
476+
// Step 4: Re-encrypt items that use old keys
477+
for ((key, entry) in entries) {
478+
try {
479+
if (entry.alias != currentKeyVersion && entry.ciphertext != null && entry.iv != null) {
480+
// Get access control from persisted
481+
val accessControl = accessControlFromPersisted(entry.metadata.accessControl) ?: AccessControl.NONE
482+
val securityLevel = securityLevelFromPersisted(entry.metadata.securityLevel) ?: SecurityLevel.SOFTWARE
483+
484+
// Decrypt with old key
485+
val resolution = deps.cryptoManager.buildResolutionForPersisted(
486+
accessControl = accessControl,
487+
securityLevel = securityLevel,
488+
authenticators = entry.authenticators,
489+
requiresAuth = entry.requiresAuthentication,
490+
invalidateOnEnrollment = entry.invalidateOnEnrollment,
491+
useStrongBox = entry.useStrongBox
492+
)
493+
494+
val plaintext = deps.cryptoManager.decrypt(
495+
entry.alias,
496+
entry.ciphertext,
497+
entry.iv,
498+
resolution,
499+
null // No auth prompt for background operation
500+
)
501+
502+
// Encrypt with new key
503+
val newResolution = deps.cryptoManager.buildResolutionForPersisted(
504+
accessControl = accessControl,
505+
securityLevel = securityLevel,
506+
authenticators = entry.authenticators,
507+
requiresAuth = entry.requiresAuthentication,
508+
invalidateOnEnrollment = entry.invalidateOnEnrollment,
509+
useStrongBox = entry.useStrongBox
510+
)
511+
512+
val encryption = deps.cryptoManager.encrypt(
513+
currentKeyVersion,
514+
plaintext,
515+
newResolution,
516+
null
517+
)
518+
519+
// Update storage
520+
val updatedEntry = entry.copy(
521+
ciphertext = encryption.ciphertext,
522+
iv = encryption.iv,
523+
alias = currentKeyVersion
524+
)
525+
deps.storage.save(service, key, updatedEntry)
526+
527+
reEncryptedCount++
528+
}
529+
} catch (e: Exception) {
530+
errors.add(ReEncryptError(key = key, error = e.message ?: "Unknown error"))
531+
}
532+
}
533+
534+
// Step 5: Return results
535+
ReEncryptAllItemsResponse(
536+
itemsReEncrypted = reEncryptedCount.toDouble(),
537+
errors = errors.toTypedArray()
538+
)
539+
}
540+
}
541+
435542
private fun ensureInitialized(): Dependencies {
436543
dependencies?.let { return it }
437544

android/src/main/java/com/sensitiveinfo/KeyRotation.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import android.security.keystore.KeyGenParameterSpec
2828
import android.security.keystore.KeyPermanentlyInvalidatedException
2929
import android.security.keystore.KeyProperties
3030
import androidx.biometric.BiometricManager
31-
import java.security.KeyGenerator
31+
import javax.crypto.KeyGenerator
3232
import java.security.KeyStore
3333
import java.util.Calendar
3434
import kotlin.math.min

android/src/main/java/com/sensitiveinfo/internal/response/ResponseBuilder.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ interface ResponseBuilder {
3333
* @param key The storage key
3434
* @param value The decrypted value (null if metadata only)
3535
* @param metadata The storage metadata
36+
* @param service The service name
3637
* @return SensitiveInfoItem with consistent structure
3738
*/
38-
fun buildItem(key: String, value: String?, metadata: StorageMetadata): SensitiveInfoItem
39+
fun buildItem(key: String, value: String?, metadata: StorageMetadata, service: String): SensitiveInfoItem
3940
}
4041

4142
/**
@@ -53,10 +54,12 @@ class StandardResponseBuilder : ResponseBuilder {
5354
override fun buildItem(
5455
key: String,
5556
value: String?,
56-
metadata: StorageMetadata
57+
metadata: StorageMetadata,
58+
service: String
5759
): SensitiveInfoItem {
5860
return SensitiveInfoItem(
5961
key = key,
62+
service = service,
6063
value = value,
6164
metadata = metadata
6265
)

android/src/main/java/com/sensitiveinfo/internal/storage/PersistedEntry.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ internal data class PersistedEntry(
3737
metadataJson.put(KEY_BACKEND, metadata.backend)
3838
metadataJson.put(KEY_ACCESS_CONTROL, metadata.accessControl)
3939
metadataJson.put(KEY_TIMESTAMP, metadata.timestamp)
40+
metadataJson.put(KEY_METADATA_ALIAS, metadata.alias)
4041
json.put(KEY_METADATA, metadataJson)
4142
return json
4243
}
@@ -55,6 +56,7 @@ internal data class PersistedEntry(
5556
private const val KEY_BACKEND = "backend"
5657
private const val KEY_ACCESS_CONTROL = "accessControl"
5758
private const val KEY_TIMESTAMP = "timestamp"
59+
private const val KEY_METADATA_ALIAS = "alias"
5860

5961
/**
6062
* Rehydrates a persisted entry from JSON, tolerating partially populated payloads generated by
@@ -72,7 +74,8 @@ internal data class PersistedEntry(
7274
securityLevel = metadataJson.optString(KEY_SECURITY_LEVEL),
7375
backend = metadataJson.optString(KEY_BACKEND),
7476
accessControl = metadataJson.optString(KEY_ACCESS_CONTROL),
75-
timestamp = metadataJson.optDouble(KEY_TIMESTAMP)
77+
timestamp = metadataJson.optDouble(KEY_TIMESTAMP),
78+
alias = metadataJson.optString(KEY_METADATA_ALIAS, "")
7679
)
7780

7881
val alias = json.optString(KEY_ALIAS)

android/src/main/java/com/sensitiveinfo/internal/storage/PersistedMetadata.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ internal data class PersistedMetadata(
1414
val securityLevel: String,
1515
val backend: String,
1616
val accessControl: String,
17-
val timestamp: Double
17+
val timestamp: Double,
18+
val alias: String
1819
) {
1920
fun toStorageMetadata(): StorageMetadata? {
2021
val level = securityLevelFromPersisted(securityLevel) ?: return null
@@ -24,7 +25,8 @@ internal data class PersistedMetadata(
2425
securityLevel = level,
2526
backend = backendValue,
2627
accessControl = control,
27-
timestamp = timestamp
28+
timestamp = timestamp,
29+
alias = alias
2830
)
2931
}
3032

@@ -34,20 +36,23 @@ internal data class PersistedMetadata(
3436
securityLevel = metadata.securityLevel.persistedName(),
3537
backend = metadata.backend.persistedName(),
3638
accessControl = metadata.accessControl.persistedName(),
37-
timestamp = metadata.timestamp
39+
timestamp = metadata.timestamp,
40+
alias = metadata.alias
3841
)
3942
}
4043

4144
fun fallback(
4245
securityLevel: SecurityLevel = SecurityLevel.SOFTWARE,
4346
backend: StorageBackend = StorageBackend.ANDROIDKEYSTORE,
44-
accessControl: AccessControl = AccessControl.NONE
47+
accessControl: AccessControl = AccessControl.NONE,
48+
alias: String = ""
4549
): PersistedMetadata {
4650
val metadata = StorageMetadata(
4751
securityLevel = securityLevel,
4852
backend = backend,
4953
accessControl = accessControl,
50-
timestamp = System.currentTimeMillis() / 1000.0
54+
timestamp = System.currentTimeMillis() / 1000.0,
55+
alias = alias
5156
)
5257
return from(metadata)
5358
}

0 commit comments

Comments
 (0)