Backup and recovery
Published
A backup job is configured by turning on things that ship off: encryption, a key to encrypt with, a list of paths that must never be archived, a checksum verified on restore, a retention window longer than the default, and a destination that is not the machine being backed up. Those are property names on an options object, read by a helper that was installed rather than built. We protected a file that configures one, renamed the names a group at a time, and read the bytes that landed on disk.
What the sample actually does
The file backs up a small tree: an application database containing a customer email and a card number, an uploaded image, and two private keys under a secrets directory. The configuration encrypts the archive with a named key, excludes the secrets directory entirely, verifies a checksum on restore, keeps ninety days, and writes to an offsite vault.
Each guard then gets a scenario only it decides. The leak test uses the database file, which is inside the archive by design - so exclusion is not what protects it, and encryption is the only thing standing between the archive and whoever can read the bucket. The exclusion test uses the signing key, which would be readable either way if it were included. The restore drill uses an archive truncated by a full disk, which only a checksum notices. The retention sweep runs on day thirty against copies from day zero, twenty and twenty-nine.
The helper is copied in unprotected. Its defaults are the ones this family ships: write the archive as it is, hold no key, exclude nothing, trust the archive on restore, keep a week, and store it locally.
Protection alone was applied first on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file.
The archive that was supposed to be ciphertext
Encryption here is a two-part guard: a boolean that turns it on, and the identifier of the key to use. Renaming either one, on its own, produced the same outcome - the archive was written in plain text, and the card number was readable in it with a substring search. The first forty-six bytes of the archive, which in the unprotected run read as an encrypted blob with a byte count, read instead as the path of the database file followed by a customer's email address.
That is worth stating precisely because this series has spent eight passes documenting the opposite shape, where each half of a paired option is safe alone and only losing both opens anything. This pair is the inverse: either half alone is fatal, because the code that decides whether to encrypt requires both to be present and truthy. There is no safe half.
Which of the two you lose changes only the forensics. Lose the boolean and the reported key identifier still reads correctly, so a configuration dump looks half right. Lose the key identifier and the reported key reads as none, which is the one line in the arm that points at the cause. Neither raises. Both produce a complete archive of the correct size, written on schedule, with a successful exit status.
An encrypted backup and an unencrypted one are the same file to every part of the system that handles them. Monitoring sees a backup. The retention sweep sees a backup. The restore drill, if you run one, restores successfully - more successfully, in fact, since there is no key to be unavailable. The only difference is what somebody who obtains the file can read, and nothing in your infrastructure is positioned to notice that.
The release signing key, in the backup
Renaming the exclusion list reverted it to the default, which is to archive everything under the root. The archived file count went from two to four, and the two extra files were a TLS private key and the release signing key.
The archive was still encrypted in that arm, which is the point worth drawing out. An encrypted backup is still a copy, and backup encryption keys are typically held by more people, in more places, for longer, than a release signing key is. Moving a signing key into the backup set does not expose it today; it widens the set of people who could obtain it, and it does so silently, and it survives every rotation of the thing you thought you were protecting.
This is also the clearest example in the pass of a rule expressed as data. The exclusion list is a list of path prefixes - strings, not code. Renaming the option that holds it does not rewrite those strings; it detaches the whole list, so the guard keeps running and matches nothing. The reported exclusions moved from the secrets path to "nothing excluded", which is the only visible symptom and appears only if something asks.
The restore that succeeded, and the ninety days that became seven
Renaming the checksum option reverted it to trusting the archive. A deliberately truncated archive - cut short by a full disk, which is the ordinary way backups get corrupted - restored successfully and reported four files. The verification that exists to tell you an archive is unusable now tells you it is fine.
The cost of that one is entirely deferred. It is discovered on the day you need the backup, which is by definition the worst day available, and by then the archive that would have worked has aged out.
Which brings us to retention. Renaming the retention option reverted ninety days to the library default of seven. On the sweep at day thirty, two of three archives were deleted and one remained; the run's own line moved from false to true on "thirty-day-old copy gone". Nothing failed. A sweep ran, deleted what its policy said to delete, and reported the deletion count accurately. The policy was simply not the one anybody agreed to, and the copies are gone, which is the one kind of damage in this article that cannot be undone by fixing the configuration afterwards.
The destination arm is the same shape and the easiest to picture: the archive moved from an offsite vault to the local disk, and the run's summary line for "backup on the machine it protects" went from false to true. A backup on the host it protects is not a backup once that host is encrypted by somebody else.
The two arms that did not stay quiet
Renaming the field names of the file records - the path and the body of each file - produced an archive that was built successfully from nothing. The file count still read four, and the path list read as three commas with no paths between them. The archive is smaller than it should be, is written on schedule, and contains no recoverable data.
That is the write-side shape this series has documented repeatedly: a name that moved on the way INTO something is accepted rather than rejected, because to the receiving code it is just data. Nothing raised. A backup that contains nothing is indistinguishable, from the outside, from a backup that contains everything, until the day it is opened.
Renaming the archive's own result fields, by contrast, threw immediately - a TypeError on the path list - which is the loud direction and the reason those arms are not the ones to worry about. And one option pinned to a value identical to the library default measured no change at all, as predicted, for the tenth consecutive pass.
What to do about it
The mechanism is not specific to backups and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed backup helper is not inside that code, so a renamed option name is one it has never heard of, and it applies its documented default. Every default in this area is the permissive one, because a backup tool that refused to run without a key would not work out of the box.
Scope the renaming. RenameMembers takes a MemberRegexp, and the backup helper's options object belongs outside it, along with the shape of any file or record you hand it. If you would rather not maintain an exclusion list for the obfuscator, build those objects with quoted string keys and read them with bracket access using literal strings you wrote.
Then assert the policy from the far side. Ask the helper what it will do - is encryption on, which key, what retention, which destination, is verification enabled - and refuse to run the job if the answers differ from what you configured. Read them from the helper, never from your own object, because your own object reads back correctly in every arm here.
And add the one check that catches the headline directly, because it costs nothing: after the archive is written, search the first few kilobytes for a canary string you know is in the source data. If you can find it, the archive is not encrypted, whatever the configuration says. That single assertion catches both halves of the encryption pair, and it is the kind of check that keeps working when the option names underneath it change.
Frequently asked questions
Does protecting my JavaScript break backups on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.
What was the worst result?
The archive being written in plain text. Renaming either the encryption boolean or the key identifier - either one alone - produced a readable archive with a customer card number findable by substring search, on schedule, with a successful exit status.
Is that not the paired-option pattern where each half is safe?
It is the inverse of it. Because the code requires both names to be present before it encrypts, there is no safe half here. Losing either one is fatal on its own, which makes this pair more dangerous than the ones where only losing both opens anything.
Would monitoring catch an unencrypted backup?
Almost certainly not. An encrypted archive and a plain one are the same kind of file to everything that handles them: the job succeeds, the size is plausible, retention sweeps it normally, and a restore drill actually succeeds more easily. The only difference is what a reader of the file can see.
How did a private key end up in the backup?
The exclusion list is data - a list of path strings. Renaming the option that holds it detaches the whole list rather than rewriting the strings, so the exclusion check keeps running and matches nothing. The archived file count went from two to four.
What happened to retention?
It reverted from ninety days to the library default of seven, and the sweep deleted two of three archives on day thirty. That is the one result in this article that cannot be repaired by fixing the configuration afterwards, because the copies are already gone.
What is the cheapest check that catches the main problem?
After writing the archive, search its first few kilobytes for a canary string you know exists in the source data. If it is findable, the archive is not encrypted regardless of what the configuration reports. That check survives the option names changing underneath it.
Related reading