Obtain the V1 signing certificate during stamp verification am: 7515f8919e am: 16950a704c am: 4e90b9ca76

Original change: https://googleplex-android-review.googlesource.com/c/platform/tools/apksig/+/12692889

Change-Id: I270f2f01b960aed2a7a25f1f77b8f2510c3f1542
diff --git a/src/main/java/com/android/apksig/ApkVerificationIssue.java b/src/main/java/com/android/apksig/ApkVerificationIssue.java
index 79c50d4..2aa9d0b 100644
--- a/src/main/java/com/android/apksig/ApkVerificationIssue.java
+++ b/src/main/java/com/android/apksig/ApkVerificationIssue.java
@@ -112,6 +112,10 @@
      * with signature(s) that did not verify.
      */
     public static final int SOURCE_STAMP_POR_DID_NOT_VERIFY = 35;
+    /** No V1 / jar signing signature blocks were found in the APK. */
+    public static final int JAR_SIG_NO_SIGNATURES = 36;
+    /** An exception was encountered when parsing the V1 / jar signer in the signature block. */
+    public static final int JAR_SIG_PARSE_EXCEPTION = 37;
 
     private final int mIssueId;
     private final String mFormat;
diff --git a/src/main/java/com/android/apksig/ApkVerifier.java b/src/main/java/com/android/apksig/ApkVerifier.java
index c186784..354dfbd 100644
--- a/src/main/java/com/android/apksig/ApkVerifier.java
+++ b/src/main/java/com/android/apksig/ApkVerifier.java
@@ -3033,7 +3033,8 @@
         private ApkVerificationIssueAdapter() {
         }
 
-        private static final Map<Integer, Issue> sVerificationIssueIdToIssue = new HashMap<>();
+        // This field is visible for testing
+        static final Map<Integer, Issue> sVerificationIssueIdToIssue = new HashMap<>();
 
         static {
             sVerificationIssueIdToIssue.put(ApkVerificationIssue.V2_SIG_MALFORMED_SIGNERS,
@@ -3112,6 +3113,10 @@
                     Issue.SOURCE_STAMP_POR_CERT_MISMATCH);
             sVerificationIssueIdToIssue.put(ApkVerificationIssue.SOURCE_STAMP_POR_DID_NOT_VERIFY,
                     Issue.SOURCE_STAMP_POR_DID_NOT_VERIFY);
+            sVerificationIssueIdToIssue.put(ApkVerificationIssue.JAR_SIG_NO_SIGNATURES,
+                    Issue.JAR_SIG_NO_SIGNATURES);
+            sVerificationIssueIdToIssue.put(ApkVerificationIssue.JAR_SIG_PARSE_EXCEPTION,
+                    Issue.JAR_SIG_PARSE_EXCEPTION);
         }
 
         /**
diff --git a/src/main/java/com/android/apksig/SourceStampVerifier.java b/src/main/java/com/android/apksig/SourceStampVerifier.java
index aba6c57..0c0e036 100644
--- a/src/main/java/com/android/apksig/SourceStampVerifier.java
+++ b/src/main/java/com/android/apksig/SourceStampVerifier.java
@@ -16,12 +16,12 @@
 
 package com.android.apksig;
 
-import static com.android.apksig.apk.ApkUtilsLite.computeSha256DigestBytes;
 import static com.android.apksig.Constants.VERSION_APK_SIGNATURE_SCHEME_V2;
 import static com.android.apksig.Constants.VERSION_APK_SIGNATURE_SCHEME_V3;
 import static com.android.apksig.Constants.VERSION_JAR_SIGNATURE_SCHEME;
-import static com.android.apksig.internal.apk.v1.V1SchemeConstants.MANIFEST_ENTRY_NAME;
+import static com.android.apksig.apk.ApkUtilsLite.computeSha256DigestBytes;
 import static com.android.apksig.internal.apk.stamp.SourceStampConstants.SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME;
+import static com.android.apksig.internal.apk.v1.V1SchemeConstants.MANIFEST_ENTRY_NAME;
 
 import com.android.apksig.apk.ApkFormatException;
 import com.android.apksig.apk.ApkUtilsLite;
@@ -54,6 +54,7 @@
 import java.nio.BufferUnderflowException;
 import java.nio.ByteBuffer;
 import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
 import java.security.cert.CertificateException;
 import java.security.cert.CertificateFactory;
 import java.security.cert.X509Certificate;
@@ -251,7 +252,7 @@
             if (mMinSdkVersion < AndroidSdkVersion.N
                     || signatureSchemeApkContentDigests.isEmpty()) {
                 Map<ContentDigestAlgorithm, byte[]> apkContentDigests =
-                        getApkContentDigestFromV1SigningScheme(cdRecords, apk, zipSections);
+                        getApkContentDigestFromV1SigningScheme(cdRecords, apk, zipSections, result);
                 signatureSchemeApkContentDigests.put(VERSION_JAR_SIGNATURE_SCHEME,
                         apkContentDigests);
             }
@@ -296,12 +297,12 @@
         try {
             signers = ApkSigningBlockUtilsLite.getLengthPrefixedSlice(apkSignatureSchemeBlock);
         } catch (ApkFormatException e) {
-            result.addVerificationError(isV2Block ? ApkVerificationIssue.V2_SIG_MALFORMED_SIGNERS
+            result.addVerificationWarning(isV2Block ? ApkVerificationIssue.V2_SIG_MALFORMED_SIGNERS
                     : ApkVerificationIssue.V3_SIG_MALFORMED_SIGNERS);
             return;
         }
         if (!signers.hasRemaining()) {
-            result.addVerificationError(isV2Block ? ApkVerificationIssue.V2_SIG_NO_SIGNERS
+            result.addVerificationWarning(isV2Block ? ApkVerificationIssue.V2_SIG_NO_SIGNERS
                     : ApkVerificationIssue.V3_SIG_NO_SIGNERS);
             return;
         }
@@ -328,7 +329,7 @@
                         apkContentDigests,
                         signerInfo);
             } catch (ApkFormatException | BufferUnderflowException e) {
-                signerInfo.addVerificationError(
+                signerInfo.addVerificationWarning(
                         isV2Block ? ApkVerificationIssue.V2_SIG_MALFORMED_SIGNER
                                 : ApkVerificationIssue.V3_SIG_MALFORMED_SIGNER);
                 return;
@@ -379,7 +380,7 @@
                 }
                 apkContentDigests.put(signatureAlgorithm.getContentDigestAlgorithm(), digestBytes);
             } catch (ApkFormatException | BufferUnderflowException e) {
-                signerInfo.addVerificationError(
+                signerInfo.addVerificationWarning(
                         isV2Signer ? ApkVerificationIssue.V2_SIG_MALFORMED_DIGEST
                                 : ApkVerificationIssue.V3_SIG_MALFORMED_DIGEST);
                 return;
@@ -394,7 +395,7 @@
                 certificate = (X509Certificate) certFactory.generateCertificate(
                         new ByteArrayInputStream(encodedCert));
             } catch (CertificateException e) {
-                signerInfo.addVerificationError(
+                signerInfo.addVerificationWarning(
                         isV2Signer ? ApkVerificationIssue.V2_SIG_MALFORMED_CERTIFICATE
                                 : ApkVerificationIssue.V3_SIG_MALFORMED_CERTIFICATE);
                 return;
@@ -408,24 +409,45 @@
         }
 
         if (signerInfo.getSigningCertificate() == null) {
-            signerInfo.addVerificationError(isV2Signer ? ApkVerificationIssue.V2_SIG_NO_CERTIFICATES
-                    : ApkVerificationIssue.V3_SIG_NO_CERTIFICATES);
+            signerInfo.addVerificationWarning(
+                    isV2Signer ? ApkVerificationIssue.V2_SIG_NO_CERTIFICATES
+                            : ApkVerificationIssue.V3_SIG_NO_CERTIFICATES);
             return;
         }
     }
 
+    /**
+     * Returns a mapping of the {@link ContentDigestAlgorithm} to the {@code byte[]} digest of the
+     * V1 / jar signing META-INF/MANIFEST.MF; if this file is not found then an empty {@code Map} is
+     * returned.
+     *
+     * <p>If any errors are encountered while parsing the V1 signers the provided {@code result}
+     * will be updated to include a warning, but the source stamp verification can still proceed.
+     */
     private static Map<ContentDigestAlgorithm, byte[]> getApkContentDigestFromV1SigningScheme(
             List<CentralDirectoryRecord> cdRecords,
             DataSource apk,
-            ZipSections zipSections)
+            ZipSections zipSections,
+            Result result)
             throws IOException, ApkFormatException {
         CentralDirectoryRecord manifestCdRecord = null;
+        List<CentralDirectoryRecord> signatureBlockRecords = new ArrayList<>(1);
         Map<ContentDigestAlgorithm, byte[]> v1ContentDigest = new EnumMap<>(
                 ContentDigestAlgorithm.class);
         for (CentralDirectoryRecord cdRecord : cdRecords) {
-            if (MANIFEST_ENTRY_NAME.equals(cdRecord.getName())) {
+            String cdRecordName = cdRecord.getName();
+            if (cdRecordName == null) {
+                continue;
+            }
+            if (manifestCdRecord == null && MANIFEST_ENTRY_NAME.equals(cdRecordName)) {
                 manifestCdRecord = cdRecord;
-                break;
+                continue;
+            }
+            if (cdRecordName.startsWith("META-INF/")
+                    && (cdRecordName.endsWith(".RSA")
+                        || cdRecordName.endsWith(".DSA")
+                        || cdRecordName.endsWith(".EC"))) {
+                signatureBlockRecords.add(cdRecord);
             }
         }
         if (manifestCdRecord == null) {
@@ -434,6 +456,36 @@
             // thus an empty digest will invalidate that signature.
             return v1ContentDigest;
         }
+        if (signatureBlockRecords.isEmpty()) {
+            result.addVerificationWarning(ApkVerificationIssue.JAR_SIG_NO_SIGNATURES);
+        } else {
+            for (CentralDirectoryRecord signatureBlockRecord : signatureBlockRecords) {
+                try {
+                    CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
+                    byte[] signatureBlockBytes = LocalFileRecord.getUncompressedData(apk,
+                            signatureBlockRecord, zipSections.getZipCentralDirectoryOffset());
+                    for (Certificate certificate : certFactory.generateCertificates(
+                            new ByteArrayInputStream(signatureBlockBytes))) {
+                        // If multiple certificates are found within the signature block only the
+                        // first is used as the signer of this block.
+                        if (certificate instanceof X509Certificate) {
+                            Result.SignerInfo signerInfo = new Result.SignerInfo();
+                            signerInfo.setSigningCertificate((X509Certificate) certificate);
+                            result.addV1Signer(signerInfo);
+                            break;
+                        }
+                    }
+                } catch (CertificateException e) {
+                    // Log a warning for the parsing exception but still proceed with the stamp
+                    // verification.
+                    result.addVerificationWarning(ApkVerificationIssue.JAR_SIG_PARSE_EXCEPTION,
+                            signatureBlockRecord.getName(), e);
+                    break;
+                } catch (ZipFormatException e) {
+                    throw new ApkFormatException("Failed to read APK", e);
+                }
+            }
+        }
         try {
             byte[] manifestBytes =
                     LocalFileRecord.getUncompressedData(
@@ -451,13 +503,15 @@
      * verified if {@link #isVerified()} returns true.
      */
     public static class Result {
+        private final List<SignerInfo> mV1SchemeSigners = new ArrayList<>();
         private final List<SignerInfo> mV2SchemeSigners = new ArrayList<>();
         private final List<SignerInfo> mV3SchemeSigners = new ArrayList<>();
-        private final List<List<SignerInfo>> mAllSchemeSigners = Arrays.asList(mV2SchemeSigners,
-                mV3SchemeSigners);
+        private final List<List<SignerInfo>> mAllSchemeSigners = Arrays.asList(mV1SchemeSigners,
+                mV2SchemeSigners, mV3SchemeSigners);
         private SourceStampInfo mSourceStampInfo;
 
         private final List<ApkVerificationIssue> mErrors = new ArrayList<>();
+        private final List<ApkVerificationIssue> mWarnings = new ArrayList<>();
 
         private boolean mVerified;
 
@@ -465,6 +519,14 @@
             mErrors.add(new ApkVerificationIssue(errorId, params));
         }
 
+        void addVerificationWarning(int warningId, Object... params) {
+            mWarnings.add(new ApkVerificationIssue(warningId, params));
+        }
+
+        private void addV1Signer(SignerInfo signerInfo) {
+            mV1SchemeSigners.add(signerInfo);
+        }
+
         private void addV2Signer(SignerInfo signerInfo) {
             mV2SchemeSigners.add(signerInfo);
         }
@@ -496,6 +558,14 @@
         }
 
         /**
+         * Returns a {@code List} of {@link SignerInfo} objects representing the V1 signers of the
+         * provided APK.
+         */
+        public List<SignerInfo> getV1SchemeSigners() {
+            return mV1SchemeSigners;
+        }
+
+        /**
          * Returns a {@code List} of {@link SignerInfo} objects representing the V2 signers of the
          * provided APK.
          */
@@ -552,6 +622,13 @@
         }
 
         /**
+         * Returns the warnings encountered while verifying the APK's source stamp.
+         */
+        public List<ApkVerificationIssue> getWarnings() {
+            return mWarnings;
+        }
+
+        /**
          * Returns all errors for this result, including any errors from signature scheme signers
          * and the source stamp.
          */
@@ -571,19 +648,43 @@
         }
 
         /**
+         * Returns all warnings for this result, including any warnings from signature scheme
+         * signers and the source stamp.
+         */
+        public List<ApkVerificationIssue> getAllWarnings() {
+            List<ApkVerificationIssue> warnings = new ArrayList<>();
+            warnings.addAll(mWarnings);
+
+            for (List<SignerInfo> signers : mAllSchemeSigners) {
+                for (SignerInfo signer : signers) {
+                    warnings.addAll(signer.getWarnings());
+                }
+            }
+            if (mSourceStampInfo != null) {
+                warnings.addAll(mSourceStampInfo.getWarnings());
+            }
+            return warnings;
+        }
+
+        /**
          * Contains information about an APK's signer and any errors encountered while parsing the
          * corresponding signature block.
          */
         public static class SignerInfo {
             private X509Certificate mSigningCertificate;
             private final List<ApkVerificationIssue> mErrors = new ArrayList<>();
+            private final List<ApkVerificationIssue> mWarnings = new ArrayList<>();
 
             void setSigningCertificate(X509Certificate signingCertificate) {
                 mSigningCertificate = signingCertificate;
             }
 
-            void addVerificationError(int error, Object... params) {
-                mErrors.add(new ApkVerificationIssue(error, params));
+            void addVerificationError(int errorId, Object... params) {
+                mErrors.add(new ApkVerificationIssue(errorId, params));
+            }
+
+            void addVerificationWarning(int warningId, Object... params) {
+                mWarnings.add(new ApkVerificationIssue(warningId, params));
             }
 
             /**
@@ -602,11 +703,19 @@
             }
 
             /**
+             * Returns a {@link List} of {@link ApkVerificationIssue} objects representing warnings
+             * encountered during processing of this signer's signature block.
+             */
+            public List<ApkVerificationIssue> getWarnings() {
+                return mWarnings;
+            }
+
+            /**
              * Returns {@code true} if any errors were encountered while parsing this signer's
              * signature block.
              */
             public boolean containsErrors() {
-                return mErrors.isEmpty();
+                return !mErrors.isEmpty();
             }
         }
 
diff --git a/src/test/java/com/android/apksig/ApkVerifierTest.java b/src/test/java/com/android/apksig/ApkVerifierTest.java
index 6bb5edf..9e1a75e 100644
--- a/src/test/java/com/android/apksig/ApkVerifierTest.java
+++ b/src/test/java/com/android/apksig/ApkVerifierTest.java
@@ -36,6 +36,8 @@
 import org.junit.runners.JUnit4;
 
 import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
 import java.nio.ByteBuffer;
 import java.security.InvalidKeyException;
 import java.security.MessageDigest;
@@ -1303,6 +1305,31 @@
                 Issue.SOURCE_STAMP_POR_CERT_MISMATCH);
     }
 
+    @Test
+    public void apkVerificationIssueAdapter_verifyAllBaseIssuesMapped() throws Exception {
+        Field[] fields = ApkVerificationIssue.class.getFields();
+        StringBuilder msg = new StringBuilder();
+        for (Field field : fields) {
+            // All public static int fields in the ApkVerificationIssue class should be issue IDs;
+            // if any are added that are not intended as IDs a filter set should be applied to this
+            // test.
+            if (Modifier.isStatic(field.getModifiers()) && field.getType() == int.class) {
+                if (!ApkVerifier.ApkVerificationIssueAdapter
+                        .sVerificationIssueIdToIssue.containsKey(field.get(null))) {
+                    if (msg.length() > 0) {
+                        msg.append('\n');
+                    }
+                    msg.append(
+                            "A mapping is required from ApkVerificationIssue." + field.getName()
+                                    + " to an ApkVerifier.Issue in ApkVerificationIssueAdapter");
+                }
+            }
+        }
+        if (msg.length() > 0) {
+            fail(msg.toString());
+        }
+    }
+
     private ApkVerifier.Result verify(String apkFilenameInResources)
             throws IOException, ApkFormatException, NoSuchAlgorithmException {
         return verify(apkFilenameInResources, null, null);
diff --git a/src/test/java/com/android/apksig/SourceStampVerifierTest.java b/src/test/java/com/android/apksig/SourceStampVerifierTest.java
index 46323a3..d99f0a0 100644
--- a/src/test/java/com/android/apksig/SourceStampVerifierTest.java
+++ b/src/test/java/com/android/apksig/SourceStampVerifierTest.java
@@ -16,6 +16,13 @@
 
 package com.android.apksig;
 
+import static com.android.apksig.SourceStampVerifier.Result;
+import static com.android.apksig.SourceStampVerifier.Result.SignerInfo;
+import static com.android.apksig.apk.ApkUtilsLite.computeSha256DigestBytes;
+import static com.android.apksig.internal.apk.ApkSigningBlockUtilsLite.toHex;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.fail;
 
 import com.android.apksig.internal.util.Resources;
@@ -26,6 +33,8 @@
 import org.junit.runners.JUnit4;
 
 import java.nio.ByteBuffer;
+import java.security.cert.X509Certificate;
+import java.util.List;
 
 @RunWith(JUnit4.class)
 public class SourceStampVerifierTest {
@@ -33,10 +42,12 @@
             "fb5dbd3c669af9fc236c6991e6387b7f11ff0590997f22d0f5c74ff40e04fca8";
     private static final String EC_P256_CERT_SHA256_DIGEST =
             "6a8b96e278e58f62cfe3584022cec1d0527fcb85a9e5d2e1694eb0405be5b599";
+    private static final String EC_P256_2_CERT_SHA256_DIGEST =
+            "d78405f761ff6236cc9b570347a570aba0c62a129a3ac30c831c64d09ad95469";
 
     @Test
     public void verifySourceStamp_correctSignature() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("valid-stamp.apk");
+        Result verificationResult = verifySourceStamp("valid-stamp.apk");
         // Since the API is only verifying the source stamp the result itself should be marked as
         // verified.
         assertVerified(verificationResult);
@@ -54,8 +65,38 @@
     }
 
     @Test
+    public void verifySourceStamp_rotatedV3Key_signingCertDigestsMatch() throws Exception {
+        // The SourceStampVerifier should return a result that includes all of the latest signing
+        // certificates for each of the signature schemes that are applicable to the specified
+        // min / max SDK versions.
+
+        // Verify when platform versions that support the V1 - V3 signature schemes are specified
+        // that an APK signed with all signature schemes has its expected signers returned in the
+        // result.
+        Result verificationResult = verifySourceStamp("./v1v2v3-rotated-v3-key-valid-stamp.apk", 23,
+                28);
+        assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, EC_P256_CERT_SHA256_DIGEST,
+                EC_P256_CERT_SHA256_DIGEST, EC_P256_2_CERT_SHA256_DIGEST);
+
+        // Verify when the specified platform versions only support a single signature scheme that
+        // scheme's signer is the only one in the result.
+        verificationResult = verifySourceStamp("./v1v2v3-rotated-v3-key-valid-stamp.apk", 18, 18);
+        assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, EC_P256_CERT_SHA256_DIGEST, null, null);
+
+        verificationResult = verifySourceStamp("./v1v2v3-rotated-v3-key-valid-stamp.apk", 24, 24);
+        assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, EC_P256_CERT_SHA256_DIGEST, null);
+
+        verificationResult = verifySourceStamp("./v1v2v3-rotated-v3-key-valid-stamp.apk", 28, 28);
+        assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, null, EC_P256_2_CERT_SHA256_DIGEST);
+    }
+
+    @Test
     public void verifySourceStamp_signatureMissing() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-without-block.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_SIG_MISSING);
@@ -63,7 +104,7 @@
 
     @Test
     public void verifySourceStamp_certificateMismatch() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-certificate-mismatch.apk");
         assertSourceStampVerificationFailure(
                 verificationResult,
@@ -72,44 +113,50 @@
 
     @Test
     public void verifySourceStamp_v1OnlySignatureValidStamp() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("v1-only-with-stamp.apk");
+        Result verificationResult = verifySourceStamp("v1-only-with-stamp.apk");
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, EC_P256_CERT_SHA256_DIGEST, null, null);
 
         // Confirm that the source stamp verification succeeds when specifying platform versions
         // that supported later signature scheme versions.
         verificationResult = verifySourceStamp("v1-only-with-stamp.apk", 28, 28);
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, EC_P256_CERT_SHA256_DIGEST, null, null);
 
         verificationResult = verifySourceStamp("v1-only-with-stamp.apk", 24, 24);
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, EC_P256_CERT_SHA256_DIGEST, null, null);
     }
 
     @Test
     public void verifySourceStamp_v2OnlySignatureValidStamp() throws Exception {
         // The SourceStampVerifier will not query the APK's manifest for the minSdkVersion, so
         // set the min / max versions to prevent failure due to a missing V1 signature.
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("v2-only-with-stamp.apk",
+        Result verificationResult = verifySourceStamp("v2-only-with-stamp.apk",
                 24, 24);
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, EC_P256_CERT_SHA256_DIGEST, null);
 
         // Confirm that the source stamp verification succeeds when specifying a platform version
         // that supports a later signature scheme version.
         verificationResult = verifySourceStamp("v2-only-with-stamp.apk", 28, 28);
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, EC_P256_CERT_SHA256_DIGEST, null);
     }
 
     @Test
     public void verifySourceStamp_v3OnlySignatureValidStamp() throws Exception {
         // The SourceStampVerifier will not query the APK's manifest for the minSdkVersion, so
         // set the min / max versions to prevent failure due to a missing V1 signature.
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
+        Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
                 28, 28);
         assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, null, EC_P256_CERT_SHA256_DIGEST);
     }
 
     @Test
     public void verifySourceStamp_apkHashMismatch_v1SignatureScheme() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-apk-hash-mismatch-v1.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_DID_NOT_VERIFY);
@@ -117,7 +164,7 @@
 
     @Test
     public void verifySourceStamp_apkHashMismatch_v2SignatureScheme() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-apk-hash-mismatch-v2.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_DID_NOT_VERIFY);
@@ -125,7 +172,7 @@
 
     @Test
     public void verifySourceStamp_apkHashMismatch_v3SignatureScheme() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-apk-hash-mismatch-v3.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_DID_NOT_VERIFY);
@@ -133,7 +180,7 @@
 
     @Test
     public void verifySourceStamp_malformedSignature() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-malformed-signature.apk");
         assertSourceStampVerificationFailure(
                 verificationResult, ApkVerificationIssue.SOURCE_STAMP_MALFORMED_SIGNATURE);
@@ -144,7 +191,7 @@
         // The ApkVerifier provides an API to specify the expected certificate digest; this test
         // verifies that the test runs through to completion when the actual digest matches the
         // provided value.
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
+        Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
                 RSA_2048_CERT_SHA256_DIGEST, 28, 28);
         assertVerified(verificationResult);
     }
@@ -153,7 +200,7 @@
     public void verifySourceStamp_expectedDigestMismatch() throws Exception {
         // If the caller requests source stamp verification with an expected cert digest that does
         // not match the actual digest in the APK the verifier should report the mismatch.
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
+        Result verificationResult = verifySourceStamp("v3-only-with-stamp.apk",
                 EC_P256_CERT_SHA256_DIGEST);
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_EXPECTED_DIGEST_MISMATCH);
@@ -164,43 +211,59 @@
         // The caller of this API expects that the provided APK should be signed with a source
         // stamp; if no artifacts of the stamp are present ensure that the API fails indicating the
         // missing stamp.
-        SourceStampVerifier.Result verificationResult = verifySourceStamp("original.apk");
+        Result verificationResult = verifySourceStamp("original.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_CERT_DIGEST_AND_SIG_BLOCK_MISSING);
     }
 
     @Test
     public void verifySourceStamp_validStampLineage() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-lineage-valid.apk");
         assertVerified(verificationResult);
     }
 
     @Test
     public void verifySourceStamp_invalidStampLineage() throws Exception {
-        SourceStampVerifier.Result verificationResult = verifySourceStamp(
+        Result verificationResult = verifySourceStamp(
                 "stamp-lineage-invalid.apk");
         assertSourceStampVerificationFailure(verificationResult,
                 ApkVerificationIssue.SOURCE_STAMP_POR_CERT_MISMATCH);
     }
 
-    private SourceStampVerifier.Result verifySourceStamp(String apkFilenameInResources)
+    @Test
+    public void verifySourceStamp_noApkSignature_succeeds()
+            throws Exception {
+        // The SourceStampVerifier is designed to verify an APK's source stamp with minimal
+        // verification of the APK signature schemes. This test verifies if just the MANIFEST.MF
+        // is present without any other APK signatures the stamp signature can still be successfully
+        // verified.
+        Result verificationResult = verifySourceStamp("stamp-without-apk-signature.apk", 18, 28);
+        assertVerified(verificationResult);
+        assertSigningCertificates(verificationResult, null, null, null);
+        // While the source stamp verification should succeed a warning should still be logged to
+        // notify the caller that there were no signers.
+        assertSourceStampVerificationWarning(verificationResult,
+                ApkVerificationIssue.JAR_SIG_NO_SIGNATURES);
+    }
+
+    private Result verifySourceStamp(String apkFilenameInResources)
             throws Exception {
         return verifySourceStamp(apkFilenameInResources, null, null, null);
     }
 
-    private SourceStampVerifier.Result verifySourceStamp(String apkFilenameInResources,
+    private Result verifySourceStamp(String apkFilenameInResources,
             String expectedCertDigest) throws Exception {
         return verifySourceStamp(apkFilenameInResources, expectedCertDigest, null, null);
     }
 
-    private SourceStampVerifier.Result verifySourceStamp(String apkFilenameInResources,
+    private Result verifySourceStamp(String apkFilenameInResources,
             Integer minSdkVersionOverride, Integer maxSdkVersionOverride) throws Exception {
         return verifySourceStamp(apkFilenameInResources, null, minSdkVersionOverride,
                 maxSdkVersionOverride);
     }
 
-    private SourceStampVerifier.Result verifySourceStamp(String apkFilenameInResources,
+    private Result verifySourceStamp(String apkFilenameInResources,
             String expectedCertDigest, Integer minSdkVersionOverride, Integer maxSdkVersionOverride)
             throws Exception {
         byte[] apkBytes = Resources.toByteArray(getClass(), apkFilenameInResources);
@@ -215,7 +278,7 @@
         return builder.build().verifySourceStamp(expectedCertDigest);
     }
 
-    private static void assertVerified(SourceStampVerifier.Result result) {
+    private static void assertVerified(Result result) {
         if (result.isVerified()) {
             return;
         }
@@ -229,17 +292,24 @@
         fail("APK failed source stamp verification: " + msg.toString());
     }
 
-    private static void assertSourceStampVerificationFailure(SourceStampVerifier.Result result,
-            int expectedIssueId) {
+    private static void assertSourceStampVerificationFailure(Result result, int expectedIssueId) {
         if (result.isVerified()) {
             fail(
                     "APK source stamp verification succeeded instead of failing with "
                             + expectedIssueId);
             return;
         }
+        assertSourceStampVerificationIssue(result.getAllErrors(), expectedIssueId);
+    }
 
+    private static void assertSourceStampVerificationWarning(Result result, int expectedIssueId) {
+        assertSourceStampVerificationIssue(result.getAllWarnings(), expectedIssueId);
+    }
+
+    private static void assertSourceStampVerificationIssue(List<ApkVerificationIssue> issues,
+            int expectedIssueId) {
         StringBuilder msg = new StringBuilder();
-        for (ApkVerificationIssue issue : result.getAllErrors()) {
+        for (ApkVerificationIssue issue : issues) {
             if (issue.getIssueId() == expectedIssueId) {
                 return;
             }
@@ -250,10 +320,60 @@
         }
 
         fail(
-                "APK source stamp failed verification for the wrong reason"
-                        + ". Expected error ID: "
+                "APK source stamp verification did not report the expected issue. "
+                        + "Expected error ID: "
                         + expectedIssueId
                         + ", actual: "
-                        + msg);
+                        + (msg.length() > 0 ? msg.toString() : "No reported issues"));
     }
-}
\ No newline at end of file
+
+    /**
+     * Asserts that the provided {@code expectedCertDigests} match their respective signing
+     * certificate digest in the specified {@code result}.
+     *
+     * <p>{@code expectedCertDigests} should be provided in order of the signature schemes with V1
+     * being the first element, V2 the second, etc. If a signer is not expected to be present for
+     * a signature scheme version a {@code null} value should be provided; for instance if only a V3
+     * signing certificate is expected the following should be provided: {@code null, null,
+     * v3ExpectedCertDigest}.
+     *
+     * <p>Note, this method only supports a single signer per signature scheme; if an expected
+     * certificate digest is provided for a signature scheme and multiple signers are found an
+     * assertion exception will be thrown.
+     */
+    private static void assertSigningCertificates(Result result, String... expectedCertDigests)
+            throws Exception {
+        for (int i = 0; i < expectedCertDigests.length; i++) {
+            List<SignerInfo> signers = null;
+            switch (i) {
+                case 0:
+                    signers = result.getV1SchemeSigners();
+                    break;
+                case 1:
+                    signers = result.getV2SchemeSigners();
+                    break;
+                case 2:
+                    signers = result.getV3SchemeSigners();
+                    break;
+                default:
+                    fail("This method only supports verification of the signing certificates up "
+                            + "through the V3 Signature Scheme");
+            }
+            if (expectedCertDigests[i] == null) {
+                assertEquals(
+                        "Did not expect any V" + (i + 1) + " signers, found " + signers.size(), 0,
+                        signers.size());
+                continue;
+            }
+            if (signers.size() != 1) {
+                fail("Expected one V" + (i + 1) + " signer with certificate digest "
+                        + expectedCertDigests[i] + ", found " + signers.size() + " V" + (i + 1)
+                        + " signers");
+            }
+            X509Certificate signingCertificate = signers.get(0).getSigningCertificate();
+            assertNotNull(signingCertificate);
+            assertEquals(expectedCertDigests[i],
+                    toHex(computeSha256DigestBytes(signingCertificate.getEncoded())));
+        }
+    }
+}
diff --git a/src/test/resources/com/android/apksig/stamp-without-apk-signature.apk b/src/test/resources/com/android/apksig/stamp-without-apk-signature.apk
new file mode 100644
index 0000000..c2e6826
--- /dev/null
+++ b/src/test/resources/com/android/apksig/stamp-without-apk-signature.apk
Binary files differ
diff --git a/src/test/resources/com/android/apksig/v1v2v3-rotated-v3-key-valid-stamp.apk b/src/test/resources/com/android/apksig/v1v2v3-rotated-v3-key-valid-stamp.apk
new file mode 100644
index 0000000..5f1103a
--- /dev/null
+++ b/src/test/resources/com/android/apksig/v1v2v3-rotated-v3-key-valid-stamp.apk
Binary files differ