Skip to content

A database whose salt is a password verifier is no longer opened without being asked - #206

Merged
dmitrat merged 1 commit into
mainfrom
refuse-legacy-encryption
Aug 15, 2026
Merged

A database whose salt is a password verifier is no longer opened without being asked#206
dmitrat merged 1 commit into
mainfrom
refuse-legacy-encryption

Conversation

@dmitrat

@dmitrat dmitrat commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Item 2 of Docs/FIX-PLAN-FROM-SITE-FINDINGS-2026-08-15.md. This is a breaking change and belongs in 14.0.0.

What was happening

A database written before the crypto preamble kept opening under the old encryptor, and the code knew: WrapEncrypted's own remarks said its salt is a function of its password and its nonce counter restarts on every open. Nothing told anybody.

What is wrong with such a file cannot be repaired by opening it — 13.0.0 measured all three:

E1 the salt is SHA256(password + "_WitDB_Salt"), so one password means one key across every database ever created with it
E2 that salt is the file's first eight bytes, in the clear — the head of the file is a password verifier costing one SHA-256 against 5.6 hours through PBKDF2
E4 the nonce counter is set to zero in a constructor that runs on open, so two sessions encrypt different plaintext under one nonce, which AES-GCM has no recovery from

What changes

Both sites refuse — the paged store's Shape.Other and the LSM store's directory of SSTables with no header — and the message names what happened and both ways forward, in the order somebody would try them:

…this version will not open it: its salt is derived from the password and stored in the clear, and its nonce counter restarts on every open. Both are properties of the bytes on disk and cannot be repaired by reading them. Either open it with the version of WitDatabase that wrote it, or convert it by changing its password — which rewrites it into the current format. To read it as it is, ask for the old scheme by name: WithLegacyEncryption() on the builder, or 'Legacy Encryption=true' in a connection string.

The opt-in is part of the fix rather than a concession. "Or convert it" is impossible if nothing can read the source. It selects the old scheme and nothing else — a wrong password is still refused.

This reverses a promise 13.0.0 made, and that is the part to read twice

13.0.0 committed fixtures written by 12.8.0 — a paged database, a Fast Encryption one, and an LSM directory generated from a worktree at the parent commit — and three tests asserting that they still open.

Those three now carry the opt-in and go on proving the half that matters to somebody holding such a file: the data is still reachable, which is what makes converting it possible at all. The compatibility claim became conditional; it was not withdrawn.

The guards

The fixtures are those 12.8.0 files rather than anything synthetic — a fixture rewritten by the new code is a fixture that tests nothing.

sabotage red
the paged refusal removed APagedDatabaseFromBeforeThePreambleIsRefusedTest
the LSM refusal removed AnLsmDirectoryFromBeforeTheHeaderIsRefusedTest
the opt-in never read six cases, including all four compatibility ones

Two controls decide whether the refusal is narrow enough, and both had to be invented rather than found:

  • a database written by this version opens with no opt-in — the refusal is about the old scheme, not about encryption;
  • an unencrypted database is untouched. This matters more than it looks: an unencrypted file looks exactly like a legacy one to CryptoPreamble.Inspect, both being "page 0 is neither a preamble nor zeros". The branch is only reached when encryption was asked for, and now a case says so.

What is not in this PR

Studio has no affordance for it yet. A user who opens a legacy database in Studio will see the refusal message; the way through is the Open dialog's Connection string… button, which takes Legacy Encryption=true, and then Password… to convert. A proper affordance — recognise the refusal, explain it, offer the conversion — is Studio work and is named in the plan rather than left to be discovered.

And the release note. Whoever cuts 14.0.0 has to write this for somebody whose application stopped working on upgrade, naming the opt-in and the conversion.

Core 2371, ADO.NET 1054.

🤖 Generated with Claude Code

…out being asked

Item 2 of Docs/FIX-PLAN-FROM-SITE-FINDINGS-2026-08-15, and it is a BREAKING change that
belongs in 14.0.0.

A database written before the crypto preamble kept opening under the old encryptor, and
the code knew: WrapEncrypted's own remarks said its salt is a function of its password and
its nonce counter restarts on every open, and nothing told anybody. What is wrong with such
a file cannot be repaired by opening it - 13.0.0 measured all three: the salt is
SHA256(password + "_WitDB_Salt") so one password means one key everywhere, that salt is the
file's first eight bytes in the clear which makes the head of the file a verifier costing
one SHA-256 against 5.6 hours through PBKDF2, and the nonce counter is set to zero in a
constructor that runs on OPEN, so two sessions encrypt different plaintext under one nonce.

Both sites refuse now - the paged store's Shape.Other and the LSM store's directory of
SSTables with no header - and the message names what happened and both ways forward, in the
order somebody would try them: open it with the version that wrote it, or convert it by
changing its password, which rewrites it in the current format.

THE OPT-IN IS PART OF THE FIX RATHER THAN A CONCESSION. "Or convert it" is impossible if
nothing can read the source, so WithLegacyEncryption() on the builder and
Legacy Encryption=true in a connection string open exactly as 13.1.1 did. It selects the
old scheme and nothing else: a wrong password is still refused.

THIS REVERSES A PROMISE 13.0.0 MADE, and that is the part to read twice. 13.0.0 committed
fixtures written by 12.8.0 - a paged database, a Fast Encryption one, and an LSM directory
generated from a worktree at the parent commit - and three tests asserting that they still
open. Those three now carry the opt-in and go on proving the half that matters to somebody
holding such a file: THE DATA IS STILL REACHABLE, which is what makes converting it
possible at all. The compatibility claim became conditional; it was not withdrawn.

The guards are those fixtures rather than anything synthetic - a fixture rewritten by the
new code is a fixture that tests nothing. Both refusal sites were measured red separately,
each naming its own place, and the opt-in was measured red on its own: six cases go red
when the flag is never read.

Two controls decide whether the refusal is NARROW enough, and both had to be invented
rather than found: a database written by this version opens with no opt-in, and an
UNENCRYPTED database is untouched - which matters because an unencrypted file looks exactly
like a legacy one to CryptoPreamble.Inspect, both being "page 0 is neither a preamble nor
zeros". The branch is only reached when encryption was asked for, and now a case says so.

Studio has no affordance for this yet and its Open dialog's "Connection string…" is the
way through until it does. That is named in the plan rather than left to be discovered.

Core 2371, ADO.NET 1054.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmitrat
dmitrat merged commit ac4140b into main Aug 15, 2026
1 check passed
@dmitrat
dmitrat deleted the refuse-legacy-encryption branch August 15, 2026 13:59
dmitrat added a commit that referenced this pull request Aug 15, 2026
…at it refuses (#208)

The audit of 2026-08-15 found the code ready and the release not, for one reason: #206's
message tells a user to convert their database by changing its password, and Studio - the
tool that does that - could not open such a database at all. The instruction was
unfollowable in the shipping tool. This closes it and prepares the release.

STUDIO. It recognises the refusal by its TYPE rather than by its wording -
LegacyEncryptionException exists for that, so nothing has to match a sentence that might be
reworded - explains what happened in words that name the version and the remedy, and offers
a checkbox that opens the database in the old format. Ticking it and pressing Connect gets
the data; a notification then says the conversion is one password change away.

Beside it, a smaller thing with a wider reach: a failed open now carries its reason back.
ConnectionManager.OpenAsync answered null and nothing else, so a database refused for one
nameable reason reached the dialog as the same sentence as a file that is not a database at
all. LastOpenError is what the dialog reads to tell them apart, and the control case asserts
that an ordinary refusal is still not offered the box.

Four parts, four separate red measurements, each naming its own place: the reason channel,
the recognition, the connection-string keyword, and the checkbox in the markup. The fixture
is the 12.8.0 file 13.0.0 committed - a fixture rewritten by the new code tests nothing.

THE RELEASE. Seven packages to 14.0.0, and the README install snippets with them; the lint
added in #204 compares those pins against each project's own version, so forgetting them
would have gone red rather than shipping a package that tells people to install the previous
one. Studio to 3.0.0-rc.2. Both CHANGELOGs carry their section, and the engine's is written
for somebody whose application stopped opening its database: what is wrong with such a file,
that it cannot be repaired by reading it, both ways forward, and the code for each.

Pre-flight pack-and-read, all seven into a throwaway folder: every package 14.0.0 and every
internal dependency 14.0.0. That check is what 3.0.0 did not have.

The version argument is #206 alone. Nothing else here can make a working application fail:
the version strings change what an answer SAYS, and everything else adds.

Core 2376, IndexedDb 153, Parser 815, Engine 2549, ADO.NET 1054, EF 593, Studio 903 - 8443
green, and the local sweep includes the Performance category that CI excludes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant