# KeyHub

*I could not say which services a given site depended on, and my API keys kept going missing.*

**Category:** WEB PLATFORM / INTERNAL TOOLS
**Status:** internal
**Period:** September 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**Stack:** React 19, TypeScript, Vite 8, Web Crypto API, AES-256-GCM, PBKDF2-SHA256, localStorage, Hand-built SVG

## The challenge

My third-party services were spread across several projects with no record of which one belonged to what. I could not see which services a given website or app depended on, and the keys themselves went missing. Answering either question meant asking someone.

## What I built

I built KeyHub in a day. Four linked record types carry the whole model: a team owns a project, a project uses a service, a service holds a credential, so "what does this site depend on" is answerable from either end. I seal secrets with AES-256-GCM under a key stretched from a master password by PBKDF2-SHA256 at 600,000 iterations. Everything lives in the browser's localStorage behind a single Repository interface, with JSON export and import for backup and the secret values still sealed inside the file.

## The detail

### The key is a ref

I keep the derived AES key outside the reducer that owns the database. In React state it could be serialised into a devtools snapshot, or written to localStorage alongside everything else. I mark it non-extractable at derivation, so JavaScript cannot read the bytes back out either, and locking the vault is one assignment: drop the reference.

### Cleared mid-render

When the vault locks while a secret is on screen, the field clears it with a render-phase state adjustment rather than an effect. React throws that render away and re-runs it, so the plaintext is gone before anything commits. I discard a decrypt still in flight when the lock fires rather than let it land in state.

### Cross-tab sync

Two tabs stay in step through the storage event, but saving the incoming state fires that event back and the tabs bounce the same value forever. A ref marks state that arrived from another tab so the persistence effect skips exactly one write. I re-run the payload through the same migration the initial load uses, because another script on the origin could have written anything into that key.

### Locked, not hidden

A locked vault still renders the whole app. Which service powers which project, what it costs and who owns it are not secret, and I store them unencrypted regardless, so hiding them behind the password would buy no real protection while blocking the main use case. I withhold only the secret values, and I wrote the reasoning into the code at the gate.

### Two dependencies

The app ships on React and React DOM and nothing else. No router, no component library, no CSS framework, no chart library: I drew the sixty icons on a shared 24x24 grid and built the spend donut from stroke-dasharray circles. A day of work, and the build compiles clean at 408 kB, 118 kB gzipped.

## The outcome

I built it and had it working in a single day: six screens, an encrypted vault, cross-tab sync, command-palette search, and export and import. There is no server, so it is a per-browser tool today rather than something the whole team shares. I put persistence behind one interface, which means I can add a backend later without touching a screen.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/keyhub.html)
