histbak

August 12, 2026 · View on GitHub

Scheduled backups of your browsing history, plus a viewer that makes the history readable. Everything runs locally. Nothing is uploaded anywhere, and the extension makes no network requests at all.

Chrome and Firefox, Manifest V3, no runtime dependencies.

The viewer

Why

Browser history is the one part of a browser profile with no export worth using and no backup at all. A profile corruption, a full disk, or a reinstall takes years of it with no warning. Meanwhile the history that survives is only browsable as a flat reverse-chronological list, which answers almost no question you would actually ask of it.

histbak writes the history to disk on a schedule, and reads it back as something you can look at.

Disclaimer

This is personal software, published in case it is useful to someone else. It carries no warranty of any kind. Read this section before trusting it with anything.

It is not a backup strategy on its own

Files land in your download folder, on the same disk as the profile they came from. A disk failure takes both. If the history matters, copy the output somewhere else.

Verify your backups

Open one in the viewer, or with the standalone script below, and confirm it contains what you expect. Do that before you need it, not after. An untested backup is a guess.

A lost passphrase is unrecoverable

There is no reset, no hint, and no back door. Files encrypted with a forgotten passphrase are permanently unreadable. This is a property of the design rather than an oversight.

Backups contain your history in full

Unencrypted output is plain JSON that anyone with the file can read. Consider where the download folder is, whether it syncs to a cloud service, and who else uses the machine.

Pruning deletes files

With keepLastN set, old backups are removed from disk. It matches on the extension's own filenames, but it is still a delete operation running unattended. Set it to 0 to disable.

Not audited

The cryptography uses standard WebCrypto primitives in a conventional arrangement, and the format is documented below and covered by tests. That is not the same as review by a cryptographer.

The extension is unsigned and unlisted. It runs as an unpacked developer extension, which Chrome will warn about on every start.

Install

No store listing. Build it and load it unpacked.

git clone https://github.com/didvc/histbak
cd histbak
npm install
npm run build

Chrome, Edge, Brave:

  1. Open chrome://extensions
  2. Turn on Developer mode
  3. Load unpacked, and select build/chrome

Firefox:

  1. Open about:debugging#/runtime/this-firefox
  2. Load Temporary Add-on
  3. Select build/firefox/manifest.json

A temporary add-on is removed when Firefox restarts. A permanent install needs a build signed through AMO.

Backups

A run collects history, applies the privacy filters, wraps the result in an envelope carrying its date range and counts, compresses it, optionally encrypts it, and writes it to your download folder.

Runs are incremental by default: each one exports only what changed since the last. A full snapshot lands every 30 days regardless, because a long chain of deltas with one damaged link is not a backup.

Scheduling uses one-shot alarms re-armed after each run rather than a repeating interval. A fixed 24-hour period drifts off the wall clock at every DST transition, so 03:00 daily would slowly become 02:00. If the browser was closed at the scheduled time, the run happens shortly after the next launch.

Settings

Encryption

Off by default, because turning it on has a consequence worth understanding first: a forgotten passphrase means a dead archive.

When on, files are AES-256-GCM with a key derived by PBKDF2-SHA-256 at 600,000 rounds, which is the current OWASP floor. Salt and IV are random per file. The header carrying the algorithm and round count is authenticated as GCM additional data, so a file altered to claim one KDF round fails to decrypt rather than becoming cheap to attack.

The passphrase is held in session storage and never written to disk. It is lost when the browser closes. A scheduled run with encryption on and no passphrase entered is skipped and reported, never quietly written in the clear.

Compression happens before encryption. Ciphertext does not compress, so the other order would produce files larger than the input.

Popup

Privacy filters

Applied before anything reaches disk, and to the viewer as well.

Tracking parameters such as utm_*, fbclid and gclid are stripped by default, since they identify campaigns and referrers and lose nothing useful. Optionally the whole query string can go, which also removes search terms. Titles can be dropped entirely; they often reveal more than the URL. URLs matching your exclude globs never leave the browser, and local schemes such as chrome://, file:// and data: are never recorded.

Exclude patterns are globs with *, one per line, matched against the whole URL:

*://*.bank*.*/*
*://mail.google.com/*
*://*.onion/*
*://localhost:*/*

File naming

Backups are written under your download folder using a template:

histbak/{YYYY}-{MM}/history-{YYYY}{MM}{DD}-{HH}{mm}-{count}items{ext}
TokenMeaning
{YYYY} {YY}year
{MM} {DD}month, day
{HH} {mm} {ss}hour, minute, second
{MON} {DAY}short month and weekday names
{TZ}UTC offset, for files shared across timezones
{count}number of items in this backup
{ext}extension matching the pipeline used

Everything resolves in local time. Slashes create subfolders. Templates are sanitised: a pattern containing .. cannot escape the download directory, Windows-illegal characters are replaced, and reserved device names such as con are prefixed. Unknown tokens are left visible rather than silently dropped, so a typo is obvious in the preview.

Reading a backup

The viewer opens backup files directly. Pick one or several from Open backup… and it decrypts, decompresses and renders them with the same charts as live history. Opening a full snapshot together with the increments that followed merges them, newest record per URL winning.

Layers are detected by content rather than by file extension, so a renamed file still opens.

Opening a backup without the extension

A backup you can only read with the tool that wrote it is a hostage. The format is plain gzip and standard AES-GCM, so this script recovers one with nothing but Node:

// node open-backup.mjs <file> [passphrase]
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
import { webcrypto as crypto } from "node:crypto";

const [file, passphrase] = process.argv.slice(2);
let bytes = new Uint8Array(readFileSync(file));

const MAGIC = "HISTBAK1";
if (Buffer.from(bytes.slice(0, 8)).toString() === MAGIC) {
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const iterations = view.getUint32(10, false);
  const salt = bytes.slice(14, 30);
  const iv = bytes.slice(30, 42);
  const header = bytes.slice(0, 42);          // authenticated, must be passed in
  const material = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(passphrase.normalize("NFKC")),
    "PBKDF2", false, ["deriveKey"]);
  const key = await crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
    material, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
  bytes = new Uint8Array(await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, additionalData: header }, key, bytes.slice(42)));
}

if (bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = gunzipSync(bytes);
const envelope = JSON.parse(Buffer.from(bytes).toString("utf8"));
console.log(envelope.kind, envelope.items.length, "items");
console.log(envelope.items.slice(0, 5));

An unencrypted backup needs even less:

gunzip -c history-20260812-0300-915items.json.gz | jq '.items | length'

Backup file format

Unencrypted output is gzipped JSON. Encrypted output wraps those bytes in a 42-byte header followed by AES-GCM ciphertext with its tag appended:

OffsetBytesField
08magic, HISTBAK1
81KDF id, 1 = PBKDF2-SHA-256
91cipher id, 1 = AES-256-GCM
104iteration count, uint32 big-endian
1416salt
3012IV
42nciphertext, GCM tag appended

The whole header is passed as GCM additional data, so none of it can be modified without decryption failing.

The JSON envelope inside:

{
  "format": "histbak",
  "version": 1,
  "kind": "full",
  "createdAt": 1786000000000,
  "range": { "from": 0, "to": 1786000000000 },
  "counts": { "collected": 920, "kept": 915 },
  "filters": { "stripTracking": true, "stripQuery": false, "excluded": 5 },
  "items": [
    {
      "url": "https://example.com/page",
      "title": "Example",
      "lastVisitTime": 1785999000000,
      "visitCount": 3,
      "typedCount": 0
    }
  ]
}

The iteration count lives in the file rather than in the code, so raising the default later does not lock you out of older backups.

What the numbers mean

The browser records one timestamp per page, its most recent visit, and a lifetime visit count. It does not expose the time of every visit through the search API. So the time-based charts count pages by last visit. A page opened 200 times contributes one point, at its most recent open. The lifetime visit counter is exact; anything bucketed by hour or day is described as pages rather than visits.

Table view

Every chart has a table view behind the same data, searchable and readable by a screen reader.

Settings reference

SettingDefaultMeaning
enabledtruerun backups automatically
frequencydailyhourly, daily or weekly
timeOfDay03:00local wall-clock time
dayOfWeek0Sunday, used by weekly
runMissedOnStartuptruecatch up after the browser was closed
incrementaltrueexport only what changed
fullBackupEveryDays30periodic full snapshot, 0 disables
maxItemsPerRun100000ceiling so one run cannot exhaust memory
compresstruegzip
encryptfalseAES-256-GCM
kdfIterations600000PBKDF2 rounds
filenameTemplatesee aboveoutput path pattern
keepLastN30prune older backups, 0 keeps everything
stripTrackingtrueremove utm_* and similar
stripQueryfalseremove all query strings
stripFragmentfalseremove #fragments
dropTitlesfalsestore URLs without page titles

Permissions

PermissionWhy
historyreading the history to back up and display
downloadswriting backup files, and pruning old ones
storagesettings, and the session-only passphrase
alarmsthe schedule
unlimitedStoragelarge histories exceed the default quota

No host permissions, no content scripts, no network access.

Project layout

src/
  lib/
    crypto.js      AES-GCM + PBKDF2, file header, tamper detection
    compress.js    gzip via CompressionStream
    naming.js      filename templating and path sanitising
    privacy.js     exclude globs, tracking-parameter stripping
    history.js     windowed reads around the search-result cap
    backup.js      the run: collect, filter, compress, encrypt, download
    schedule.js    DST-safe next-run computation
    insights.js    aggregation for the viewer
    restore.js     reading backups back in
    settings.js    defaults and storage
  background/      MV3 service worker: alarms, messages
  popup/           status and manual run
  options/         settings
  viewer/          dashboard
  ui/              shared DOM helpers, charts, stylesheet

Build

npm install
npm run build     # build/chrome and build/firefox, unpacked
npm run dist      # minified, into dist/
npm run build:watch

Tests

npm test          # 27 unit tests, no browser
npm run test:e2e  # 41 assertions against real Chromium

The unit tests cover the crypto format, including tamper detection and the authenticated header, gzip round-trips, filename templating and path-escape attempts, and the restore paths.

The end-to-end run loads the built extension, seeds history, and drives real backups. It checks the filename template against what the downloads API is actually asked for, decrypts the produced file back to the history that went in, confirms an unchanged second run writes nothing, and confirms that encryption with no passphrase writes nothing rather than falling back to plaintext.

npm run screenshots regenerates the images above. The viewer ones use synthetic history, because history.addUrl always stamps the current time and a test profile can therefore never contain the multi-day spread the charts are for.

Known limitations

Time-bucketed charts count pages by last visit, as described above. Per-visit timestamps are available through history.getVisits at one call per URL; insights.js has an opt-in implementation that the UI does not yet expose.

Deleting history in the browser does not delete it from past backups. That is the point of a backup, and also a hazard worth knowing.

Incognito browsing is never recorded by the history API, so it never appears here.

Firefox needs AMO signing for a permanent install, so the practical Firefox path is a temporary add-on that disappears on restart.

The viewer holds the queried range in memory. Very large ranges on a long history are slow, which is why the range filter defaults to 30 days.

Troubleshooting

No backups are appearing

Check the popup. It shows the last outcome, including runs skipped for a missing passphrase or because nothing changed. A scheduled run needs the browser to be open at the scheduled time, or runMissedOnStartup on.

Every run says "nothing new"

That is correct when no new pages have been visited. Use Full in the popup to force a complete snapshot.

Chrome keeps warning about developer mode

Unavoidable for an unpacked extension. The alternative is packing and self-hosting a CRX, which Chrome also restricts.

A backup will not open

Confirm the passphrase, then try the standalone script above to rule out the viewer. GCM cannot distinguish a wrong passphrase from a damaged file, so both report the same failure.

Licence

MPL-2.0