support inline signed manifests in the download verifier by reading hashes from the verified signature content
What changed, and why it matters
This commit changes how Sparrow Wallet's download verifier reads the list of trusted file hashes. Previously, the verifier read hashes from a separate manifest file on disk. Now, it reads the hashes from the cryptographically signed content itself. This is meant to support 'inline signed manifests' and reduce the chance that an attacker could swap the manifest file after the signature was verified. The change also adds a size limit on the manifest content to prevent memory exhaustion.
Review the drongo submodule bump (3a60bd6453f5428fc81ad3822c35bfae04c581cc) to confirm the new PGPUtils.verify overload correctly binds the signed output stream to the verification process and does not silently ignore it. Verify that MAX_VALID_MANIFEST_SIZE is a reasonable constant and that getManifest() cannot be tricked by malformed inline content. Consider whether the old detached-manifest code path still needs to read manifest.get() at all.
Security signals we found
previously trusted manifest was re-read from disk after signature verification, creating a TOCTOU window between signature check and hash lookup
new code parses manifest from the cryptographically covered bytes returned by PGP verification
manifest size bounded to prevent unbounded memory buffering during verification
submodule bump suggests supporting library change in drongo
Evidence from the diff
DownloadVerifierDialog’s PGPVerifyService now returns a SignedManifest record containing both the PGPVerificationResult and the raw signed manifest bytes. The verifyManifest() method now receives those bytes and parses the manifest from a ByteArrayInputStream rather than re-reading manifest.get() from disk. A MAX_VALID_MANIFEST_SIZE guard is added: if the signed content is null or exceeds the limit, an InvalidManifestException is thrown. The PGPUtils.verify call is extended with an output stream that captures at most MAX_VALID_MANIFEST_SIZE+1 bytes during signature verification. The drongo submodule is bumped, likely to provide the matching PGPUtils.verify overload.
Changed components
src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.javadrongo submoduleInspect captured patch +31 / −10
### drongo
@@ -1 +1 @@
-Subproject commit 00aa6ec07e95860fb118adc5cb399d8b3d670fae
+Subproject commit 3a60bd6453f5428fc81ad3822c35bfae04c581cc
### src/main/java/com/sparrowwallet/sparrow/control/DownloadVerifierDialog.java
@@ -312,7 +312,8 @@ private void verify() {
return;
}
- PGPVerificationResult result = pgpVerifyService.getValue();
+ SignedManifest signedManifest = pgpVerifyService.getValue();
+ PGPVerificationResult result = signedManifest.result();
String message = result.userId() + " on " + signatureDateFormat.format(result.signatureTimestamp()) + (result.expired() ? " (key expired)" : "");
signedBy.setText(message);
@@ -332,7 +333,7 @@ private void verify() {
releaseVerified.setGraphic(GlyphUtils.getSuccessGlyph());
releaseLink.setText(release.get().getName());
} else {
- verifyManifest(verification);
+ verifyManifest(verification, signedManifest.content());
}
});
pgpVerifyService.setOnFailed(event -> {
@@ -374,10 +375,9 @@ private void clearReleaseFields() {
releaseLink.setText("");
}
- private void verifyManifest(long verification) {
+ private void verifyManifest(long verification, byte[] manifestContent) {
File releaseFile = release.get();
if(releaseFile != null && releaseFile.exists()) {
- File manifestFile = manifest.get();
hashService = new FileSha256Service(releaseFile);
hashService.setOnRunning(event -> {
if(verification != verificationCount) {
@@ -398,7 +398,11 @@ private void verifyManifest(long verification) {
String calculatedHash = hashService.getValue();
try {
- Map<File, String> manifestMap = getManifest(manifestFile);
+ if(manifestContent == null || manifestContent.length > MAX_VALID_MANIFEST_SIZE) {
+ throw new InvalidManifestException("Manifest file is larger than " + (MAX_VALID_MANIFEST_SIZE / 1024) + "KB");
+ }
+
+ Map<File, String> manifestMap = getManifest(new ByteArrayInputStream(manifestContent));
String manifestHash = getManifestHash(releaseFile.getName(), manifestMap);
if(calculatedHash.equalsIgnoreCase(manifestHash)) {
releaseHash.setText("Matched manifest hash");
@@ -775,7 +779,9 @@ public String formatExtensionsList(List<String> items) {
}
}
- private static class PGPVerifyService extends Service<PGPVerificationResult> {
+ private record SignedManifest(PGPVerificationResult result, byte[] content) { }
+
+ private static class PGPVerifyService extends Service<SignedManifest> {
private final File signature;
private final File manifest;
private final File publicKey;
@@ -787,15 +793,30 @@ public PGPVerifyService(File signature, File manifest, File publicKey) {
}
@Override
- protected Task<PGPVerificationResult> createTask() {
+ protected Task<SignedManifest> createTask() {
return new Task<>() {
- protected PGPVerificationResult call() throws IOException, PGPVerificationException {
+ protected SignedManifest call() throws IOException, PGPVerificationException {
boolean detachedSignature = !manifest.equals(signature);
+ //Retain at most one byte more than a valid manifest, so the content of a manifest that is too large can be rejected without holding all of it
+ ByteArrayOutputStream signedContent = manifest.length() > MAX_VALID_MANIFEST_SIZE ? null : new ByteArrayOutputStream();
+ OutputStream signedContentStream = signedContent == null ? null : new OutputStream() {
+ @Override
+ public void write(int b) {
+ write(new byte[] { (byte)b }, 0, 1);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ signedContent.write(b, off, (int)Math.max(0, Math.min(len, MAX_VALID_MANIFEST_SIZE + 1 - signedContent.size())));
+ }
+ };
+
try(InputStream publicKeyStream = publicKey == null ? null : new FileInputStream(publicKey);
InputStream contentStream = new BufferedInputStream(new FileInputStream(manifest));
InputStream detachedSignatureStream = detachedSignature ? new FileInputStream(signature) : null) {
- return PGPUtils.verify(publicKeyStream, contentStream, detachedSignatureStream);
+ PGPVerificationResult result = PGPUtils.verify(publicKeyStream, contentStream, detachedSignatureStream, signedContentStream);
+ return new SignedManifest(result, signedContent == null ? null : signedContent.toByteArray());
}
}
};Why this scored 40/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.