Skip to content

Fix local backup crash on classic-encrypted attachments - #14912

Open
patjackson52 wants to merge 1 commit into
signalapp:mainfrom
patjackson52:local-archive-classic-attachments
Open

Fix local backup crash on classic-encrypted attachments#14912
patjackson52 wants to merge 1 commit into
signalapp:mainfrom
patjackson52:local-archive-classic-attachments

Conversation

@patjackson52

@patjackson52 patjackson52 commented Aug 3, 2026

Copy link
Copy Markdown

Contributor checklist

  • Pixel 10 Pro, Android 17 (playStagingSpinner) — reproduced the reported crash and verified the fix, details below
  • Robolectric: new AttachmentTableTest_getLocalArchivableAttachments plus existing AttachmentTable* and backup.v2.local suites, 42/42
  • My contribution is fully baked and ready to be merged as is
  • I ensure that all the open issues my contribution fixes are mentioned in the commit message of my first commit using the Fixes #1234 syntax

Description

Fixes #14875.

Attachments from the classic encryption have a NULL data_random, but getLocalArchivableAttachments() reads it with requireNonNullBlob(). One such attachment aborts the entire local backup with an NPE, which is what the reporter is hitting. They can no longer create backups at all, though v1 .backup files worked on the same data. The same read exists in getLocalArchivableAttachmentsForPlaintextExport().

Two changes:

1. Let the null random through (AttachmentTable). LocalArchivableAttachment.random becomes nullable, both queries use requireBlob, and getDataStream() accepts a null random. It already picks ClassicDecryptingPartInputStream whenever a random isn't a modern 32-byte value, so the decryption side needed no changes — this restores the nullable handling FullBackupExporter.openAttachmentStream() had for v1 backups. random has exactly one consumer (getAttachmentStreamgetDataStream), so the nullable widening doesn't propagate further.

I chose this over adding AND data_random IS NOT NULL to the queries: the guard is a smaller diff, but it would silently exclude these attachments from local backups that v1 included, which seems worse than a crash you can see.

2. Widen per-attachment failure handling (LocalArchiver). Device testing turned up a second problem in the same path. source() — which opens and MAC-verifies the attachment — sits outside the try that records readWriteFailures, so any failure while opening an attachment escapes to the job rather than being recorded. And a failure to decrypt surfaces as IllegalArgumentException (from SecretKeySpec when an install has no classic keys), not IOException, so the existing catch wouldn't have caught it even if it were correctly scoped. The loop already maintains createFailures/readWriteFailures accumulators, so per-attachment resilience is clearly the intent; this makes the scope match. There's precedent for the broader catch in ArchiveFileSystem.deleteUnusedFiles().

Testing

Device. Built onto a Pixel 10 Pro against a staging account with five attachments, then set data_random = NULL on one row to simulate a classic-era attachment. Same database state and same action each time, only the binary differing:

On main the reported crash, reproduced exactly:

W/LocalArchiveJob: java.lang.NullPointerException
    at CursorExtensionsKt.requireNonNullBlob(CursorExtensions.kt:62)
    at AttachmentTable.getLocalArchivableAttachments(AttachmentTable.kt:628)
    at BackupRepository.exportForLocalBackup(BackupRepository.kt:779)
    at LocalArchiver.export(LocalArchiver.kt:85)
    at LocalArchiveJob.run(LocalArchiveJob.kt:104)
W/LocalArchiveJob: Archive failed. Snapshot temp folder needed to be deleted
W/JobRunner:       Job failed.

With change 1 only, forcing the attachment to be re-read by clearing the archived copies — the NPE is gone, but the failure moves downstream and takes the process with it:

FATAL EXCEPTION: signal-io-bounded-2
java.lang.IllegalArgumentException: Missing argument
    at javax.crypto.spec.SecretKeySpec.<init>
    at ClassicDecryptingPartInputStream.verifyMac(:61)
    at AttachmentTable.getDataStream(:2724)
    at LocalArchiver.export$lambda$3(LocalArchiver.kt:99)

With both changes, same scenario:

W/LocalArchiver:   Unable to save AttachmentId::5
                   java.lang.IllegalArgumentException: Missing argument
I/LocalArchiveJob: Archive finished with result:
                   Success(PartialSuccess(createFailures=[], readWriteFailures=[AttachmentId::5]))

To be straight about what that second trace does and doesn't show: my test row is synthetic. A null random on a fresh install (which has no classic keys) pointing at a file that is really modern-encrypted. That combination can't occur naturally, and a genuinely affected install has real classic keys and real classic-encrypted files, so change 1 alone is enough for the reporter. But it did expose that the surrounding failure handling doesn't cover opening an attachment, which is worth fixing regardless.

Unit. New AttachmentTableTest_getLocalArchivableAttachments covers a classic (null) row, a modern row, and a mixed set; the classic cases reproduce the NPE without the fix. Existing AttachmentTable* and backup.v2.local suites pass. 42/42 overall.

Two things I noticed but did not touch

Both are pre-existing and independent of this change; happy to open separate issues if useful.

  1. LocalArchiver deletes the existing archived file before re-archiving when cipherLength doesn't match. Since files/ is shared across all snapshots, a failed re-archive removes that attachment from previously-good snapshots too. Write-to-temp-then-swap would avoid it.
  2. PartialSuccess is treated as success by LocalArchiveJob, so a backup that dropped attachments finalizes and reports success with only a log line recording it.

Attachments from the classic encryption era have a NULL data_random, but
getLocalArchivableAttachments() read it with requireNonNullBlob(), so a
single such attachment aborted the entire local backup with an NPE. The
same read exists in getLocalArchivableAttachmentsForPlaintextExport().

Allow a null random through to getDataStream(), which already selects
ClassicDecryptingPartInputStream when a random isn't a modern 32-byte
value -- the same handling FullBackupExporter.openAttachmentStream() used
for v1 backups. These attachments are therefore included in local backups
as they were in v1, rather than filtered out of the query.

Also widen the per-attachment failure handling in LocalArchiver. Opening
an attachment happened outside the try that records readWriteFailures, so
any failure there escaped to the job -- and a failure to decrypt arrives
as an IllegalArgumentException rather than an IOException, which the
existing catch would have missed anyway. One unreadable attachment now
gets recorded and skipped instead of ending the backup.

Fixes signalapp#14875
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.

Cannot create v2 backups: NullPointerException in requireNonNullBlob()

1 participant