• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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