---
title: 'Encrypted Storage and the Keystore'
source: 'https://academia.sh/en/courses/platform-services-and-integrations/encrypted-storage-and-keystore'
course: 'Platform Services and Integrations'
language: en
updated: '2026-10-21T05:02:41+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains how the alias of a key in the keystore relates to a record'
    - 'Shows that a key bound to user authentication leaves its record unreadable once it is invalidated'
    - 'Measures a record that cannot be read as lost, because it never reaches the next read'
    - 'Distinguishes that a secret rewritten with the same key makes the old record superseded, and that the rule does not see the cause'
---

# Encrypted Storage and the Keystore

A secret the app writes is encrypted with a key in the keystore; if the key is bound to user authentication, it is invalidated when the lock screen is removed and the record can no longer be read. The lesson measures how many copies of a written secret reach the next read.

In the previous lesson, events travelled inside the device as far as a screen. In this lesson the event does not go to a screen but to a store: the app produces a secret, saves it, and reads it back in the next session. The writer and the reader are the same app, but a span of time and a key sit between them. In the course's opening lesson, the distribution of eight events was measured as exactly 3 once, 1 duplicated, 2 superseded, and 2 lost. The question in this lesson is the same: **how many copies of this sent event reached the receiver?** The event is the secret that was written, and the receiver is the next read.

```kotlin
// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)

class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
    val copies: Int = received.count { it.seq == sent.seq }
    val verdict: String = when {
        copies > 1 -> "duplicated"
        copies == 1 -> "once"
        received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
        else -> "lost"
    }

    fun line() {
        println("$label: key=${sent.key} | copies=$copies -> $verdict")
    }
}

fun countArrivals(arrivals: List<Arrival>) {
    for (name in listOf("once", "duplicated", "superseded", "lost")) {
        println("$name: ${arrivals.count { it.verdict == name }}")
    }
}
```

The rule looks at the copy count. If one copy with the same `seq` arrived, the delivery is **once**, and if more than one arrived, it is **duplicated**. If no copy arrived and one with a greater `seq` arrived under the same key, the event is **superseded**. If neither is the case, it is **lost**. The rule does not see the content, the cause, or whether the record is encrypted.

## The Keystore and the Alias

The platform's keystore (`KeyStore`) keeps encryption keys apart from the app's memory and files. The app never sees the key itself; it refers to it by a **key alias** and asks for an operation without seeing the key. An alias is like the row header of a registry: one key is found under the same name, and every encrypted record knows which alias's key it was encrypted with.

A condition can be attached when the key is generated: the key can be used only after the user has been authenticated.

```kotlin
// Platform code: KeyGenParameterSpec
val spec = KeyGenParameterSpec.Builder(
    "lock",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    .build()

val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(spec)
val key = generator.generateKey()
```

`setUserAuthenticationRequired(true)` binds the key to user authentication. According to the platform's documentation, such a key is irreversibly invalidated when the secure lock screen is removed or forcibly reset. Code that starts an operation with an invalidated key receives an exception.

```kotlin
// Platform code: KeyPermanentlyInvalidatedException
try {
    cipher.init(Cipher.DECRYPT_MODE, key)
} catch (e: KeyPermanentlyInvalidatedException) {
    keyStore.deleteEntry("lock")
}
```

Encrypted preference storage wraps this key in a ready-made shell. The app writes the value, and the shell encrypts it using the key.

```kotlin
// Platform code: EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val prefs = EncryptedSharedPreferences.create(
    context,
    "secrets",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
prefs.edit().putString("secret.card", "secret-value").apply()
```

This encrypted preference library is deprecated and should not be copied into new code. The current path is to generate the key directly in the keystore and have the app encrypt the data itself, or to use the data store together with an encryption library.

These three pieces are platform code, and here we make no claim about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. In the model, the vault ties records to an alias, and when the key of an alias is invalidated, the records of that alias can no longer be read.

```kotlin
// Vault.kt — ties records to a key alias, hides the records of an invalidated key
class Vault {
    private val records = mutableListOf<Pair<String, Signal>>()
    private val invalid = mutableSetOf<String>()

    fun write(alias: String, secret: Signal) {
        records.add(alias to secret)
    }

    fun invalidateKey(alias: String) {
        invalid.add(alias)
    }

    fun migrate(from: String, to: String) {
        records.filter { it.first == from }.forEach { records.add(to to it.second) }
    }

    fun readAll(): List<Signal> = records.filter { it.first !in invalid }.map { it.second }
}
```

The receiver is the return value of `readAll`. In each measurement the app writes the secrets and then reads them back.

```kotlin
// Probe.kt — counts the list read from the vault as the receiver for the secrets that were sent
fun probe(vault: Vault, sent: List<Signal>): List<Arrival> {
    val received = vault.readAll()
    return sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
}
```

## While the Key Is Valid

Three secrets are written under the same alias, and the key is left untouched. As long as the key is valid, a read returns every record.

```kotlin
// Main.kt — as long as the key is valid, every record is read
fun main() {
    val vault = Vault()
    val sent = listOf(Signal("secret.session", 1), Signal("secret.card", 2), Signal("secret.note", 3))
    sent.forEach { vault.write("lock", it) }
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=1 -> once
event-3: key=secret.note | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three writes are read. The assurance that encrypted storage provides is not present here: the vault does not model whether a record is encrypted, only whether it can be read.

## When the Lock Screen Is Removed

Four secrets sit under two aliases. Some are encrypted with the `lock` key, which is bound to user authentication, and some with the `general` key, which is not. The user removes the lock screen and the `lock` key is invalidated.

```kotlin
// Main.kt — the records of an invalidated key cannot be read
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.setting", 4),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.write("general", sent[3])
    vault.invalidateKey("lock")
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> lost
event-3: key=secret.note | copies=0 -> lost
event-4: key=secret.setting | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The records stay on disk, but the read does not return them. The rule counts this as lost: the event was written, no copy reached the receiver, and no greater `seq` arrived under the same key either. This lesson's delivery: lost. The reason is that a record existing and a record being readable are not the same thing.

## When a Migration Leaves Copies Behind

The app wants to move the records that are not bound to user authentication to a new alias. The migration copies the records to the new name and does not delete the old ones. Because the old key stays valid, the records under both names can be read.

```kotlin
// Main.kt — if the migration does not delete the old record, a read returns two copies
fun main() {
    val vault = Vault()
    val sent = listOf(Signal("secret.session", 1), Signal("secret.card", 2), Signal("secret.note", 3))
    vault.write("general", sent[0])
    vault.write("general", sent[1])
    vault.write("lock", sent[2])
    vault.migrate("general", "general-2")
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=2 -> duplicated
event-2: key=secret.card | copies=2 -> duplicated
event-3: key=secret.note | copies=1 -> once
once: 1
duplicated: 2
superseded: 0
lost: 0
```

The two migrated secrets arrived as two copies. If the read code does not merge these copies, the app sees the same secret twice. A duplicated result is a sign that the migration was left half done; the rule only gives the count.

**SE1.** The model associates each record with the key of a single alias. On the platform, a record also depends on the version of the key, the encryption mode, and the initialization vector; these are not in the model.

**SE2.** The model builds the invalidation of a key as a single call. On the platform, invalidation is noticed when the key is about to be used after an event such as the lock screen being removed or forcibly reset.

## The Limits of Invalidation

To understand why invalidation produces a loss, three distinctions have to be kept apart. First, the key and the record are separate things: the record sits in the app's file, while the key sits in the keystore. When the key is gone, the record stays where it is, but nothing is left that can open it. Second, even though the app created the key, it cannot bring it back; it can only generate a new key under the same alias, and the new key does not open the old record. Third, invalidation is not an event under the app's control. What triggers it is something the user does with the lock screen, and the app notices only at the next read, when it tries to use the key.

The counterpart of these three distinctions in the model is simple. The vault marks each record with an alias. `invalidateKey` counts an alias as invalid. `readAll` does not return the records of invalid aliases. In the model the content of a record is never visible; the rule looks only at the returned list.

Here the difference between what the platform says and what the model says has to stay clear. The platform documents that a key bound to user authentication is invalidated when the lock screen is removed. Under which other conditions a key can be invalidated depends on the key's generation parameters, and this is not measured in this lesson. The model knows only the `invalidateKey` call.

## The Bounding Measurement

The vault does not carry the cause of a loss. When the lock screen is removed, the `lock` key is invalidated and two secrets become unreadable. The app notices this, generates a new key, and rewrites only the card: the same key name `secret.card`, this time with a new `seq` and a new alias.

```kotlin
// Main.kt — a secret rewritten under the same key supersedes the old record
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.card", 4),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.invalidateKey("lock")
    vault.write("lock-2", sent[3])
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> superseded
event-3: key=secret.note | copies=0 -> lost
event-4: key=secret.card | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 1
```

Both of the unreadable secrets are unreadable for the same reason, because of the same invalidated key. Even so, by the rule one is superseded and the other is lost. `secret.card` is superseded because a greater `seq` arrived under the same key; `secret.note` is lost because no such copy exists. The rule does not see the cause of the loss; it sees only whether a copy that took its place exists.

This distinction matters from the app's point of view. A superseded secret does not count as gone for the user; its new version can be read. A lost secret, on the other hand, is data that has to be entered again. The rule separates the two but does not say which of them costs the user more.

A loss can turn into superseded through the user's action. After the invalidation, the user enters the card and the note again; the app writes both under the new alias with new `seq` values.

```kotlin
// Main.kt — if the user re-enters the secrets, the losses turn into superseded
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.card", 4),
        Signal("secret.note", 5),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.invalidateKey("lock")
    vault.write("lock-2", sent[3])
    vault.write("lock-2", sent[4])
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> superseded
event-3: key=secret.note | copies=0 -> superseded
event-4: key=secret.card | copies=1 -> once
event-5: key=secret.note | copies=1 -> once
once: 3
duplicated: 0
superseded: 2
lost: 0
```

`secret.note`, which was lost in the previous measurement, is now superseded, because a greater `seq` arrived to take its place. The old records that cannot be read stay where they are; the count changed because the rule now sees a copy that took its place. Having the user enter the data again is a way to recover secrets that can be regenerated. For a secret that cannot be entered again, the same measurement stays lost.

**SE3.** The model does the rewrite under a new alias and does not lay the new secret over the old one. On the platform, it is also possible to generate a new key under the same alias; the model does not build this distinction.

## What the Reading Code Has to Do

What the receiving side has to do follows from the measurement. If the result of a read is shorter than expected, it is accepted that this is a delivery outcome and not an error, and three things are done.

- **Recognize what is missing.** The reading code must know which secrets it expects. A loss is not noticed without comparing the expected list with the list that was read. In the model, `probe` does this comparison.
- **Merge the copies.** If more than one record comes back for the same key, the one with the greatest `seq` is taken. Because the transport can leave copies behind, the reading code does not treat a copy as an error, but it does not process it twice either.
- **Separate what can be regenerated.** A session key that can be fetched again from the server and a password that only the user knows are not in the same loss class. The first is renewed silently, the second is asked from the user. This distinction has to be made at write time, before the secret enters the store.

An empty result from a read and an error from a read are also handled separately. An empty result can say that no record was ever written, while an error can say that the record exists but could not be opened; code that puts both in the same branch mistakes an invalidation for a first launch.

There is also something the reading code must not do: silently delete a record that cannot be read. If the record is deleted, the next measurement can no longer tell superseded from lost; no trace is left.

## Common Misconceptions

**An encrypted record can always be read.** Encryption protects the record, but it does not guarantee that it can be read. If the key is lost, the record is protected and unreachable. Two measurements showed this: while the key was valid, three records were once, and after it was invalidated, two records were lost.

**A record that sits on disk has been delivered.** Delivery is the copy that reaches the receiver. An unreadable record on disk does not reach the receiver. That is why the rule looks not at the bytes on disk but at what the read returns.

**Superseded is bad news.** Superseded is often good news, because its replacement has arrived. What is bad is when the superseding copy never arrives; in that case the same event is counted as lost. The last measurement shows the two side by side.

**A duplicated result means the data is corrupted.** Duplicated only means the same copy arrived more than once. A copy left behind during transport is not corruption; it is a cleanup that was not finished.

**If the record is backed up, the key is backed up too.** It is not. The record is only encrypted bytes; the key that opens it stays separate from the record. In a setup that carries the record but not the key, a read gives the same result as the invalidation in the measurement: the records exist, they are not returned, and the rule counts them as lost. The platform's backup behavior is not measured in this lesson.

**Regenerating the key brings the record back.** It does not. Generating a new key under the same alias does not open a record encrypted with the old key; it only leaves a working key for the records that will be written from now on.

## Summary

- This lesson's delivery: lost. When the lock screen is removed, the key bound to user authentication is invalidated, and a record encrypted with that key cannot be read.
- The key alias ties the record to the key; the records under the same alias become unreadable together.
- With a valid key, all three writes arrive as once. When the two secrets bound to user authentication are invalidated, the result is lost 2, once 2.
- If the migration does not delete the old record, two secrets are read as two copies: duplicated 2, once 1.
- A secret rewritten under the same key makes the old record superseded; a secret that is not rewritten is lost. If the user enters both secrets again, the result is superseded 2, once 3. The rule sees the copies, not the cause.

## Next Step

In this lesson the record was written to disk and could not be read once the key was invalidated. In the next lesson the event goes out to the network: the app sends a request, but if the request is blocked as cleartext, no copy reaches the server. Network security configuration writes the rule for this block.
