Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Encrypted at rest unless it cannot be, and the file itself says which

Notifio's auto-reply feature works by watching you reply to one listing by hand, and then replaying that on future listings. The recording it keeps is not abstract. It contains the fields that site asked for and what you

Notifio's auto-reply feature works by watching you reply to one listing by hand, and then replaying that on future listings. The recording it keeps is not abstract. It contains the fields that site asked for and what you put in them: your message, your move-in date, whatever else the form wanted.

None of that is uploaded anywhere. There is no account, no sync, no server-side copy. Which is a good privacy position right up until you notice what it implies: the file on the user's disk is the entire security boundary. There is no other layer to hide behind.

Electron gives you a reasonable tool for this in safeStorage, which encrypts a string with a key held by the OS keychain: Keychain on macOS, DPAPI on Windows, libsecret or kwallet on Linux. The problem is the last one. On a Linux box with no keyring running, and in any context where the module is loaded outside Electron at all, safeStorage is simply not available.

So the real question is not "do we encrypt this file". It is "what does the reader do when it does not know how the file was written".

The file answers that itself

export interface Envelope {
  v: number;
  enc: 'safeStorage' | 'none';
  data: string;
}

Three fields, and the middle one is the point. Every file written by this app states how it was written. Nothing downstream has to infer it.

export function writeEnvelope(filePath: string, value: unknown): void {
  const json = JSON.stringify(value);
  const envelope: Envelope = canEncrypt()
    ? { v: 1, enc: 'safeStorage', data: safeStorage.encryptString(json).toString('base64') }
    : { v: 1, enc: 'none', data: json };

  // Atomic write so a crash mid-write can't corrupt the file.
  const tmp = `${filePath}.tmp`;
  fs.writeFileSync(tmp, JSON.stringify(envelope), 'utf8');
  fs.renameSync(tmp, filePath);
}

The alternative, which plenty of code in the world does, is to leave enc out and work it out on read: try to parse data as JSON, and if that fails assume it must be ciphertext. That heuristic works in testing and fails in the one case you care about, because "this is encrypted" and "this is corrupt" both present as "this does not parse". A field that records the writer's decision turns a guess into a fact.

Note also the two separate version numbers. v: 1 versions the envelope. The payload inside carries its own DATA_VERSION, currently 4, because the settings shape has changed three times while the container has not changed at all. Container format and content shape change for different reasons and at different rates, so they get different counters. Collapsing them into one number means every content migration lies about the format.

The fallback is plaintext, and that is a decision, not an accident

export function canEncrypt(): boolean {
  try {
    return safeStorage.isEncryptionAvailable();
  } catch {
    // Not running under Electron, or the OS keychain is unavailable. Fall back to
    // plaintext rather than losing the data.
    return false;
  }
}

Two things worth saying plainly.

The try is there because this does not merely return false outside Electron, it throws. Any module that can be loaded by a test, a script, or a dev harness has to treat "the platform API is not here" as a value, not an exception.

And the fallback writes plaintext rather than refusing to save. That is a choice of availability over confidentiality, made for one specific situation: a user on a machine with no keyring, who has just spent two minutes demonstrating a reply and expects it to still be there tomorrow. Refusing the write means the feature does not work at all on that machine. Writing plaintext means it works, in a file inside the user's own home directory, on hardware they control.

I am comfortable defending that, and I would not be comfortable defending it silently, which is the actual reason enc is in the file. The honest framing is the same one the entitlement cache in this app uses about itself:

// Note this is a speed bump, not DRM. A local Electron app cannot keep a secret
// from its owner, and this file is plain JSON. Anything that must not be forged
// has to be enforced server-side.

Local encryption protects your data from other users and other processes on the machine. It does not protect it from you, and a design that pretends otherwise is just a longer way of being wrong.

The read path refuses to guess

export function readEnvelope<T>(filePath: string, fallback: T): T {
  if (!fs.existsSync(filePath)) return fallback;
  try {
    const envelope = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Envelope;
    let json: string;
    if (envelope.enc === 'safeStorage') {
      if (!canEncrypt()) {
        throw new Error('data is encrypted but safeStorage is unavailable');
      }
      json = safeStorage.decryptString(Buffer.from(envelope.data, 'base64'));
    } else {
      json = envelope.data;
    }
    return JSON.parse(json) as T;
  } catch (err) {
    console.error(`[store] Failed to read ${filePath}:`, err);
    return fallback;
  }
}

The branch I want to point at is the explicit throw. A file that says safeStorage on a machine that can no longer decrypt is an error, loudly, with a sentence that names the situation. The thing it must never do is fall through to the else and hand base64 ciphertext to JSON.parse as though it were content.

That failure would be far worse than it looks. JSON.parse on base64 throws, which lands in the same catch, which returns the same fallback, so the behaviour appears identical. What differs is the log line, and therefore whether anybody can tell what went wrong from a support email.

The sharp edge of returning fallback here is real and worth naming: the app will then be running on defaults over a file it could not read, and a later save overwrites it. What bounds that in practice is that the payload in question is one boolean, nothing rewrites it on a timer, and the recordings file is only written when the user records something new. If this pattern held something irreplaceable, the honest implementation would refuse to write over a file it failed to read.

The per-file version of that question, answered six different ways for six different files in this app, is in Six files, one write pattern, and six different answers to "what if this is garbage?". What this post adds is the layer above it: before you can decide what to do with a bad file, you have to know what kind of file you were handed.

One envelope, two files

/** Shared with recipes.ts so both files use one envelope format. */
export function readEnvelope<T>(filePath: string, fallback: T): T {

The settings file and the recordings file both go through this. Not because the data resembles each other, it does not, but because "local user data belonging to this person" is one category, and I would rather have one encryption path with one set of mistakes in it than two.

That also means the day a Linux user reports that nothing persists, there is exactly one function to read.

One function has to go around the migration

A small consequence worth including, because it is the kind of thing that surprises you.

The settings migration deletes. Older versions stored a profile, then named templates, then a shared message, and the current shape keeps none of them:

function migrate(raw: unknown): UserData {
  const data = (raw ?? {}) as { autoReply?: Partial<AutoReplySettings> };
  return {
    version: DATA_VERSION,
    autoReply: { enabled: data.autoReply?.enabled === true },
  };
}

I wrote about that shrinking in A settings file with one field left, and a migration that only deletes. What it leaves behind is this:

/**
 * The shared message as it was before it moved into each recording.
 *
 * Read straight off disk, because the current shape has no message field for it
 * to survive a migrate() through. recipes.ts calls this once to fold the text
 * into recordings that still point at the old setting; nothing else should.
 */
export function legacyMessage(): string {
  const raw = readEnvelope<{ message?: unknown }>(PROFILE_PATH, {});
  return typeof raw.message === 'string' ? raw.message : '';
}

Once your migration drops a field, any remaining need to read that field has to bypass the migration entirely, with a different generic parameter and a doc comment telling the next person not to copy it. Deleting is still the right call. It is just not free, and the invoice arrives as a function like this one.

The related trap, where the dropped field is absent rather than wrong, is in Storage written by an older build of your app is untrusted input, and 'absent' is the trap.

The rule worth taking away

A file should state how it was written. Not what it contains, which is what schema versions are for, but which of your writers produced it: encrypted or not, compressed or not, which codec, which key. The cost is one short string. What you buy is that every reader can fail with a sentence instead of a heuristic, and that the next person to open the file can tell the difference between ciphertext and corruption without running your code.

See it working

The app is on notifio.app/download for Mac and Windows, and the feature that produces the encrypted file is the auto-reply upgrade described on notifio.app/pricing.

What is stored locally and what reaches our servers is set out in full on notifio.app/privacy, the data questions people actually ask are on notifio.app/help, and the per-portal pages under notifio.app/alerts cover which sites a recording is even worth making for.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.