1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.file.transforms; 17 18 import android.net.Uri; 19 import android.text.TextUtils; 20 import android.util.Base64; 21 import com.google.android.libraries.mobiledatadownload.file.common.Fragment; 22 import com.google.android.libraries.mobiledatadownload.file.common.ParamComputer; 23 import com.google.android.libraries.mobiledatadownload.file.spi.Transform; 24 import com.google.common.primitives.Ints; 25 import java.io.IOException; 26 import java.io.InputStream; 27 import java.io.OutputStream; 28 import java.security.DigestInputStream; 29 import java.security.DigestOutputStream; 30 import java.security.MessageDigest; 31 import java.security.NoSuchAlgorithmException; 32 import java.util.Arrays; 33 import javax.annotation.Nullable; 34 35 /** 36 * A transform that implements integrity check using a cryptographic hash (SHA-256). This transform 37 * can work in 3 modes: 38 * 39 * <ol> 40 * <li>While reading, verify that content matches hash present in URI. 41 * <li>Compute hash from existing file and return with {@link 42 * ComputedUriInputStream#consumeStreamAndGetComputedUri} 43 * <li>Compute hash while writing, and return with {@link ComputedUriOutputStream#getComputedUri} 44 * </ol> 45 * 46 * There are vulnerabilities present in this design: 47 * 48 * <ul> 49 * <li>While reading, the verification happens only when the end of the stream is reached. No 50 * assumptions can be made about integrity of data until then. 51 * <li>If the hash is computed on an existing file stored on insecure medium, it's possible for 52 * that file to have already been modified, or to be modified while the hash is being 53 * computed. 54 * </ul> 55 * 56 * <p>Clients are expected to use the computed URI methods to produce a valid URI with hash embedded 57 * in it. The name of the digest subparam (eg, "sha256") is used to identify the hash and hashing 58 * algorithm. Future implementations may use different algorithms and subparams, but are expected to 59 * retain backwards compatibility. 60 * 61 * <p>Computed URIs that contain hashes must be stored in a safe place to avoid tampering. 62 * 63 * <p>Does not support appending to an existing file. 64 * 65 * <p>See <internal> for further documentation. 66 */ 67 public final class IntegrityTransform implements Transform { 68 69 private static final String TRANSFORM_NAME = "integrity"; 70 private static final String SUBPARAM_NAME = "sha256"; 71 72 /** Returns the base64-encoded SHA-256 digest encoded in {@code uri}, or null if not present. */ 73 @Nullable getDigestIfPresent(Uri uri)74 public static String getDigestIfPresent(Uri uri) { 75 return Fragment.getTransformSubParam(uri, TRANSFORM_NAME, SUBPARAM_NAME); 76 } 77 78 @Override name()79 public String name() { 80 return TRANSFORM_NAME; 81 } 82 newDigester()83 private static MessageDigest newDigester() { 84 try { 85 return MessageDigest.getInstance("SHA-256"); 86 } catch (NoSuchAlgorithmException e) { 87 throw new IllegalArgumentException(e); 88 } 89 } 90 91 @Override wrapForRead(Uri uri, InputStream wrapped)92 public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException { 93 Fragment.ParamValue param = Fragment.getTransformParamValue(uri, name()); 94 return new DigestVerifyingInputStream(wrapped, param); 95 } 96 97 @Override wrapForWrite(Uri uri, OutputStream wrapped)98 public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException { 99 Fragment.ParamValue param = Fragment.getTransformParamValue(uri, name()); 100 return new DigestComputingOutputStream(wrapped, param); 101 } 102 103 private static class DigestComputingOutputStream extends DigestOutputStream 104 implements ParamComputer { 105 Fragment.ParamValue param; 106 ParamComputer.Callback paramComputerCallback; 107 boolean didComputeDigest = false; 108 DigestComputingOutputStream(OutputStream stream, Fragment.ParamValue param)109 DigestComputingOutputStream(OutputStream stream, Fragment.ParamValue param) { 110 super(stream, newDigester()); 111 this.param = param; 112 } 113 114 @Override close()115 public void close() throws IOException { 116 super.close(); 117 if (!didComputeDigest) { 118 didComputeDigest = true; 119 if (paramComputerCallback != null) { 120 paramComputerCallback.onParamValueComputed( 121 param.toBuilder() 122 .addSubParam( 123 SUBPARAM_NAME, Base64.encodeToString(digest.digest(), Base64.NO_WRAP)) 124 .build()); 125 } 126 } 127 } 128 129 @Override setCallback(ParamComputer.Callback callback)130 public void setCallback(ParamComputer.Callback callback) { 131 this.paramComputerCallback = callback; 132 } 133 } 134 135 private static class DigestVerifyingInputStream extends DigestInputStream 136 implements ParamComputer { 137 byte[] skipBuffer = new byte[4096]; 138 byte[] expectedDigest; 139 byte[] computedDigest; 140 Fragment.ParamValue param; 141 ParamComputer.Callback paramComputerCallback; 142 DigestVerifyingInputStream(InputStream stream, Fragment.ParamValue param)143 DigestVerifyingInputStream(InputStream stream, Fragment.ParamValue param) { 144 super(stream, newDigester()); 145 this.param = param; 146 String digest = param.findSubParamValue(SUBPARAM_NAME); 147 if (!TextUtils.isEmpty(digest)) { 148 this.expectedDigest = Base64.decode(digest, Base64.NO_WRAP); 149 } 150 } 151 152 @Override read(byte[] b, int off, int len)153 public int read(byte[] b, int off, int len) throws IOException { 154 int result = super.read(b, off, len); 155 if (result == -1) { 156 checkDigest(); 157 } 158 return result; 159 } 160 161 @Override read()162 public int read() throws IOException { 163 int result = super.read(); 164 if (result == -1) { 165 checkDigest(); 166 } 167 return result; 168 } 169 170 @Override close()171 public void close() throws IOException { 172 // Close underlying stream first since checkDigest can throw exception. If the underlying 173 // close fails, OTOH, digest should be assumed to be invalid. 174 super.close(); 175 checkDigest(); 176 } 177 178 @Override skip(long n)179 public long skip(long n) throws IOException { 180 // Consume min(buffer.length, n) bytes, processing the bytes the ensure the digest is updated. 181 int length = Math.min(skipBuffer.length, Ints.checkedCast(n)); 182 return read(skipBuffer, 0, length); 183 } 184 185 @Override setCallback(ParamComputer.Callback callback)186 public void setCallback(ParamComputer.Callback callback) { 187 this.paramComputerCallback = callback; 188 } 189 checkDigest()190 private void checkDigest() throws IOException { 191 if (computedDigest != null) { 192 return; 193 } 194 computedDigest = digest.digest(); 195 if (paramComputerCallback != null) { 196 paramComputerCallback.onParamValueComputed( 197 param.toBuilder() 198 .addSubParam(SUBPARAM_NAME, Base64.encodeToString(computedDigest, Base64.NO_WRAP)) 199 .build()); 200 } 201 if (expectedDigest != null && !Arrays.equals(computedDigest, expectedDigest)) { 202 String a = Base64.encodeToString(computedDigest, Base64.NO_WRAP); 203 String e = Base64.encodeToString(expectedDigest, Base64.NO_WRAP); 204 throw new IOException("Mismatched digest: " + a + " expected: " + e); 205 } 206 } 207 } 208 } 209