fix(backup): read nothing shorter than a ciphertext as needing a key

The shortest value either codec writes is 40 base64 characters. A
shorter string under an ...Encrypted member is not ciphertext, and
reading it as a legacy value would have made a restore demand the v1
key for a field that holds none.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 20:41:52 +02:00
1 parent 7da778760e
commit 94aa7c6bd4
2 files changed
+13 -4

No files matched your search

@@ -77,9 +77,12 @@ describe("innerCiphertextKeyId", () => {
expect(innerCiphertextKeyId(legacy)?.keyId).toBe("v1");
});
it("ignores an empty value and one that is not base64", () => {
it("ignores an empty value, a short one, and one that is not base64", () => {
expect(innerCiphertextKeyId("")).toBeNull();
expect(innerCiphertextKeyId("not ciphertext at all!")).toBeNull();
expect(innerCiphertextKeyId("yes")).toBeNull();
expect(
innerCiphertextKeyId("not ciphertext at all, and long enough to count!"),
).toBeNull();
});
});
@@ -122,7 +125,9 @@ describe("assessBackupKeys", () => {
useKeys({ old: K1, cur: K2 }, "cur");
const file = {
moodEntries: [{ noteEncrypted: bytesString("fine") }],
appSettings: { adminAiKeyEncrypted: "gone.QUJDRA==" },
appSettings: {
adminAiKeyEncrypted: `gone.${Buffer.alloc(40).toString("base64")}`,
},
};
const collector = new BackupKeyIdCollector();
collector.visit(file);
+5 -1
View File
@@ -40,6 +40,7 @@ const VERSIONED = /^([A-Za-z0-9_-]{1,32})\.[A-Za-z0-9+/=]+$/;
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
const BASE64_TAIL = /^[A-Za-z0-9+/=]*$/;
const BYTES_CODEC_VERSION = 0x02;
const MIN_CIPHERTEXT_CHARS = 40;
/** How an inner value is laid out, which decides how a sample is opened. */
export type InnerCiphertextForm = "string" | "bytes-string" | "binary";
@@ -55,7 +56,10 @@ export interface InnerCiphertext {
* multi-megabyte document costs the same as a note.
*/
export function innerCiphertextKeyId(value: string): InnerCiphertext | null {
if (value.length === 0) return null;
// The shortest ciphertext either codec writes is 28 bytes (nonce and tag),
// 40 characters as base64: anything shorter is not ciphertext, and reading
// it as a legacy value would demand a key for a field that holds none.
if (value.length < MIN_CIPHERTEXT_CHARS) return null;
const versioned = VERSIONED.exec(value.slice(0, 64));
if (versioned && value.indexOf(".") === versioned[1].length) {
return { keyId: versioned[1], form: "string" };