Skip to content
academia.sh

Lesson 11 / 20

Secure Storage

Secret values such as a session token, a password, and a key do not sit in a plain store; they are written to the operating system's protected store. The lesson measures that when the session closes, the account subtree is unmounted, while deleting the token from the store does not show in the tree.

Published Updated

In this lesson

  • Explains why secret values are not kept in a plain key-value store
  • Measures that when the session closes, the account subtree comes out unmounted
  • Shows that at launch the token is read from the secure store and builds the session
  • Measures that deleting the token from the store does not show in the view tree
Contents

The previous lesson covered data that remains even when the app closes. That the data was ordinary text such as a draft made the job easier. A session token, a password, or an encryption key is not ordinary: whoever obtains them takes the user’s place. This lesson covers where secret values are kept and what happens in the view tree when the session closes.

The question of the course does not change: what does this update do to the node in the view tree? The fate is kept, updated, mounted, or unmounted; the total of the updated, mounted, and unmounted nodes is the commands. We do not assign a fate by hand; the rule gives it:

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

The model builds this fate’s rule; the model is not the framework itself. The rule compares only trees; it does not see where a secret value is kept.

Where a Secret Value Lives

A plain key-value store sits in the app’s data directory, in an unprotected file. It can end up in a backup and can be read from a rooted phone. For a secret value, the operating system offers a separate place: a secure key store. The value is kept there encrypted, and access is tied to the app’s identity and often to the lock state of the phone. Each platform’s store comes with its own name and its own rules; the framework puts a single write and read surface on top of them.

// Platform code: SecretStore
import Security

func saveToken(_ token: Data) -> Bool {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "session-token",
        kSecValueData as String: token,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]
    SecItemDelete(query as CFDictionary)
    return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}

This fragment is not measured; the model does not run the platform’s store. The point to notice is that writing is a call entirely separate from the app’s view: the code that writes to the store draws nothing on the screen.

AL1. The model builds the secure store as an in-memory Map and returns reading, writing, and deleting with a Promise. It does not model encryption or the access conditions; what is measured is what writing to the store or deleting from it leaves in the tree.

// vault.mjs — secure key store: in-memory Map, write, read, and delete with a Promise
const secrets = new Map();

export function saveSecret(key, value) {
  return Promise.resolve().then(() => {
    secrets.set(key, value);
  });
}

export function readSecret(key) {
  return Promise.resolve().then(() => (secrets.has(key) ? secrets.get(key) : null));
}

export function deleteSecret(key) {
  return Promise.resolve().then(() => {
    secrets.delete(key);
  });
}

AL2. The model builds the app’s tree from the session state: Login if there is no token, Account with Profile and Orders under it if there is. The title is there in both states. The value of the token itself does not enter the tree; the tree looks only at whether a token exists.

// app.mjs — builds the app's view tree from the session state
import { el } from "./fate.mjs";

export function renderApp(session) {
  const body = session.token === null
    ? el("Login", {})
    : el("Account", {}, [el("Profile", { name: session.name }), el("Orders", {})]);
  return el("App", {}, [el("Header", {}, [el("Title", { text: "My Account" })]), body]);
}

In the framework this choice is a small screen gate that looks at the token state.

// Platform code: AccountGate
function AccountGate({ token }) {
  return token ? <Account /> : <Login />;
}

When the Session Closes

The user closes the session. The token in the state is deleted and the gate returns to the Login screen.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";

const signedIn = renderApp({ token: "t-1", name: "Alice" });
const signedOut = renderApp({ token: null, name: "" });
const ops = reconcile(signedIn, signedOut);
printOps(ops);
countFates(ops);
kept: /App
kept: /App/Header
kept: /App/Header/Title
unmounted: /App/Account
unmounted: /App/Account/Profile
unmounted: /App/Account/Orders
mounted: /App/Login
kept: 3
updated: 0
mounted: 1
unmounted: 3
commands: 4

/App/Account is unmounted, and with it the Profile and Orders under it are unmounted too: the subtree goes as a whole. Login is mounted in its place. The title and the app itself are kept. The screen state belonging to the user’s account, the scroll position, the field the user half typed, all disappear with these nodes. This result agrees with the distinction in M10/K02: the state of what is unmounted disappears; the fate classifies not the place of the lifetime but what the update does to the node.

Reading the Token at Launch

When the app is opened again, the state in memory is empty. Only the secure store knows whether the session continues. At launch the token is read from the store and the state is built accordingly.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { saveSecret, readSecret } from "./vault.mjs";

const opening = renderApp({ token: null, name: "" });
saveSecret("session", "t-1")
  .then(() => readSecret("session"))
  .then((token) => {
    const ops = reconcile(opening, renderApp({ token, name: "Alice" }));
    printOps(ops);
    countFates(ops);
  });
kept: /App
kept: /App/Header
kept: /App/Header/Title
unmounted: /App/Login
mounted: /App/Account
mounted: /App/Account/Profile
mounted: /App/Account/Orders
kept: 3
updated: 0
mounted: 3
unmounted: 1
commands: 4

The launch screen starts with Login. When the token is found, Login is unmounted, and Account and the two nodes under it are mounted. What is seen in the tree is not the read from the store itself but the read changing the state. If the token had not been found, the state would not change and the tree would stay the same as the tree at launch.

Deleting Does Not Show in the Tree

Closing a session is two separate jobs: clearing the state and deleting the token from the store. The first changes the tree. The second happens outside the tree. The measurement below does only the second: the token is deleted from the store while the state stays in place.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { saveSecret, readSecret, deleteSecret } from "./vault.mjs";

const session = { token: "t-1", name: "Alice" };
saveSecret("session", "t-1")
  .then(() => deleteSecret("session"))
  .then(() => readSecret("session"))
  .then((left) => {
    console.log("left in store: " + left);
    countFates(reconcile(renderApp(session), renderApp(session)));
  });
left in store: null
kept: 6
updated: 0
mounted: 0
unmounted: 0
commands: 0

The store was emptied but nothing happened in the tree: commands is 0, and Account still stands where it was. The screen looks at the token in the state; it does not look at what is left in the store. For this reason deleting alone is not enough, and alone it does not show.

Reading the Platform Fragment Line by Line

The saveToken function carries four decisions together. The first is the class of the query: the value is defined as a generic password record. The second is the name of the record: the session token is found under a single name, so the code that reads and the code that deletes use the same name. The third is the value itself. The fourth is the access condition: the value can be read only while the phone is unlocked and cannot be moved to another phone.

Before writing, a delete call is made with the same query. The reason is that these stores may refuse to add a second record under the same name; deleting first prevents the write from failing when the token is renewed. The function finally returns whether the add succeeded.

No line of this fragment touches the screen. Whether the write succeeds or fails, the view tree is the same. The only thing that reaches the screen is the app writing this result as state: the token was received, the session is open. This distance between the store and the tree is the common subject of this lesson’s measurements.

When the Token Is Renewed

Tokens have a limited lifetime; from time to time the app gets a new token and replaces the value in the store. The user should not notice this. For the tree too, it should not be noticed: the session continues, the screen stays the same. We measure this with two renewals. In the first only the token changes. In the second the user’s name changes along with the token.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";

const before = renderApp({ token: "t-1", name: "Alice" });
console.log("token only:");
countFates(reconcile(before, renderApp({ token: "t-2", name: "Alice" })));
console.log("token and name:");
printOps(
  reconcile(before, renderApp({ token: "t-2", name: "Zoe" }))
    .filter((op) => op.fate !== "kept"),
);
token only:
kept: 6
updated: 0
mounted: 0
unmounted: 0
commands: 0
token and name:
updated: /App/Account/Profile

In the first renewal the tree did not change: because the token is not written to the tree, commands is 0. The user sees nothing in terms of the tree, and the account screen stays where it was. In the second renewal only Profile is updated, because the prop that changed is the prop of the node that shows the name. Account and the other nodes under it are kept.

These two results are two faces of the same principle. A secret value does not enter the tree; for this reason its change leaves no trace in the tree. The information that enters the tree is the information visible on the screen, and a node is updated only when that changes. A secret value not entering the tree is not only a leak precaution; it also prevents needless commands.

Counterexample: Writing the Token into the Tree

It may occur to someone to make the token a screen prop for convenience: the token is carried as a prop of the Account node. This works, but it has two prices. The first is that with every renewal Account becomes updated; a command arises although nothing on the screen changed. The second, and the heavier one, is that the secret value is now a part of the view tree. The tree travels in error reports, in debugging output, and in screen recordings; the secret value travels with it. The store exists to protect the value, and copying it into the tree breaks this protection.

The Bounding Measurement

The fate does not measure the security of a secret value. The same session closing is done in two ways: with the token left in the store and with the token deleted from the store. The tree changes the same way in both cases.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderApp } from "./app.mjs";
import { saveSecret, readSecret, deleteSecret } from "./vault.mjs";

function signOut(withDelete) {
  const signedIn = renderApp({ token: "t-1", name: "Alice" });
  const signedOut = renderApp({ token: null, name: "" });
  const clear = withDelete ? deleteSecret("session") : Promise.resolve();
  return clear.then(() => readSecret("session")).then((left) => {
    console.log(withDelete ? "with delete:" : "without delete:");
    console.log("store: " + (left === null ? "empty" : "full"));
    countFates(reconcile(signedIn, signedOut));
  });
}

saveSecret("session", "t-1")
  .then(() => signOut(false))
  .then(() => signOut(true));
without delete:
store: full
kept: 3
updated: 0
mounted: 1
unmounted: 3
commands: 4
with delete:
store: empty
kept: 3
updated: 0
mounted: 1
unmounted: 3
commands: 4

In both runs the command count is the same: the account subtree is unmounted and Login is mounted. The difference is only in the store: in the first the token stays full, in the second it is empty. Someone looking at the tree cannot say that the session has really closed; if the token stays in the store, the app builds the session again at the next launch. The fate classifies what the update does to the node; it does not classify the fate of the token.

Common Misconceptions

  • “The session closed and the screen changed, so the token was deleted.” The screen is drawn from the state, not from the store. Deleting is a separate call and is verified separately.
  • “As long as a secret value is visible on the screen it has entered memory, so the store is meaningless.” The copy in memory is temporary; the store determines where the value remains when the app closes.
  • “A token can sit in a plain store too, in the end it is a small piece of text.” Size does not determine security; what protects access is the store itself.
  • “If the deletion succeeded, the user has left the session.” Deleting from the store and removing the session screen from the tree are separate jobs. Deleting produces no command in the tree; what changes the screen is the token leaving the state. If the two steps are not done together, the screen keeps showing the account while the store is empty, or the old token stays in the store while the screen has returned to login.
  • “When the token is renewed, the screen is renewed too.” A renewal produces no command in the tree; because the token is not visible on the screen, no node changes.
  • “The secure store is only for tokens.” Passwords, keys, and other secret values that belong to the user are written to the same place. What decides the choice is not the shape of the value but the harm that would arise if it were disclosed.
  • “When the session closes, only the token goes.” Everything belonging to the account goes from the tree, and the native view tied to it goes with it. The next user does not see the previous user’s screen state; this is a wanted result and shows in the measurement as the subtree being unmounted as a whole.

Summary

  • This lesson’s fate: unmounted. When the session closed, /App/Account and the Profile and Orders nodes under it were unmounted as a whole, and Login was mounted in its place.
  • When the token read from the secure store at launch changes the state, Login is unmounted and the account subtree is mounted.
  • Deleting the token from the store does not change the tree: as long as the state stays in place, the Account node is kept.
  • The same session closing can leave the store full or empty; the tree changes the same way in both cases, and the fate does not classify the fate of the secret value.
  • A secret value sits not in a plain store but in the platform’s secure key store.

Next Step

Until this lesson, the changes in the tree came from state: a screen opening, data loading, a session closing. There is also a change that comes from outside: a user who taps a notification opens the app at a specific screen. The next lesson, Notifications and Deep Linking, measures how a link enters the tree.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close