1 /*
2  * Copyright (C) 2016 The Android Open Source Project
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 
17 package com.android.server.updates;
18 
19 import android.os.FileUtils;
20 import android.system.ErrnoException;
21 import android.system.Os;
22 import android.util.Base64;
23 import android.util.Slog;
24 
25 import com.android.internal.util.HexDump;
26 
27 import libcore.io.Streams;
28 
29 import org.json.JSONArray;
30 import org.json.JSONException;
31 import org.json.JSONObject;
32 
33 import java.io.ByteArrayInputStream;
34 import java.io.File;
35 import java.io.FileFilter;
36 import java.io.FileOutputStream;
37 import java.io.IOException;
38 import java.io.InputStream;
39 import java.io.OutputStreamWriter;
40 import java.nio.charset.StandardCharsets;
41 import java.security.MessageDigest;
42 import java.security.NoSuchAlgorithmException;
43 
44 public class CertificateTransparencyLogInstallReceiver extends ConfigUpdateInstallReceiver {
45 
46     private static final String TAG = "CTLogInstallReceiver";
47     private static final String LOGDIR_PREFIX = "logs-";
48 
CertificateTransparencyLogInstallReceiver()49     public CertificateTransparencyLogInstallReceiver() {
50         super("/data/misc/keychain/trusted_ct_logs/", "ct_logs", "metadata/", "version");
51     }
52 
53     @Override
install(InputStream inputStream, int version)54     protected void install(InputStream inputStream, int version) throws IOException {
55         /* Install is complicated here because we translate the input, which is a JSON file
56          * containing log information to a directory with a file per log. To support atomically
57          * replacing the old configuration directory with the new there's a bunch of steps. We
58          * create a new directory with the logs and then do an atomic update of the current symlink
59          * to point to the new directory.
60          */
61         // 1. Ensure that the update dir exists and is readable
62         updateDir.mkdir();
63         if (!updateDir.isDirectory()) {
64             throw new IOException("Unable to make directory " + updateDir.getCanonicalPath());
65         }
66         if (!updateDir.setReadable(true, false)) {
67             throw new IOException("Unable to set permissions on " +
68                     updateDir.getCanonicalPath());
69         }
70         File currentSymlink = new File(updateDir, "current");
71         File newVersion = new File(updateDir, LOGDIR_PREFIX + String.valueOf(version));
72         File oldDirectory;
73         // 2. Handle the corner case where the new directory already exists.
74         if (newVersion.exists()) {
75             // If the symlink has already been updated then the update died between steps 7 and 8
76             // and so we cannot delete the directory since its in use. Instead just bump the version
77             // and return.
78             if (newVersion.getCanonicalPath().equals(currentSymlink.getCanonicalPath())) {
79                 writeUpdate(updateDir, updateVersion,
80                         new ByteArrayInputStream(Long.toString(version).getBytes()));
81                 deleteOldLogDirectories();
82                 return;
83             } else {
84                 FileUtils.deleteContentsAndDir(newVersion);
85             }
86         }
87         try {
88             // 3. Create /data/misc/keychain/trusted_ct_logs/<new_version>/ .
89             newVersion.mkdir();
90             if (!newVersion.isDirectory()) {
91                 throw new IOException("Unable to make directory " + newVersion.getCanonicalPath());
92             }
93             if (!newVersion.setReadable(true, false)) {
94                 throw new IOException("Failed to set " +newVersion.getCanonicalPath() +
95                         " readable");
96             }
97 
98             // 4. For each log in the log file create the corresponding file in <new_version>/ .
99             try {
100                 byte[] content = Streams.readFullyNoClose(inputStream);
101                 JSONObject json = new JSONObject(new String(content, StandardCharsets.UTF_8));
102                 JSONArray logs = json.getJSONArray("logs");
103                 for (int i = 0; i < logs.length(); i++) {
104                     JSONObject log = logs.getJSONObject(i);
105                     installLog(newVersion, log);
106                 }
107             } catch (JSONException e) {
108                 throw new IOException("Failed to parse logs", e);
109             }
110 
111             // 5. Create the temp symlink. We'll rename this to the target symlink to get an atomic
112             // update.
113             File tempSymlink = new File(updateDir, "new_symlink");
114             try {
115                 Os.symlink(newVersion.getCanonicalPath(), tempSymlink.getCanonicalPath());
116             } catch (ErrnoException e) {
117                 throw new IOException("Failed to create symlink", e);
118             }
119 
120             // 6. Update the symlink target, this is the actual update step.
121             tempSymlink.renameTo(currentSymlink.getAbsoluteFile());
122         } catch (IOException | RuntimeException e) {
123             FileUtils.deleteContentsAndDir(newVersion);
124             throw e;
125         }
126         Slog.i(TAG, "CT log directory updated to " + newVersion.getAbsolutePath());
127         // 7. Update the current version information
128         writeUpdate(updateDir, updateVersion,
129                 new ByteArrayInputStream(Long.toString(version).getBytes()));
130         // 8. Cleanup
131         deleteOldLogDirectories();
132     }
133 
installLog(File directory, JSONObject logObject)134     private void installLog(File directory, JSONObject logObject) throws IOException {
135         try {
136             String logFilename = getLogFileName(logObject.getString("key"));
137             File file = new File(directory, logFilename);
138             try (OutputStreamWriter out =
139                     new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
140                 writeLogEntry(out, "key", logObject.getString("key"));
141                 writeLogEntry(out, "url", logObject.getString("url"));
142                 writeLogEntry(out, "description", logObject.getString("description"));
143             }
144             if (!file.setReadable(true, false)) {
145                 throw new IOException("Failed to set permissions on " + file.getCanonicalPath());
146             }
147         } catch (JSONException e) {
148             throw new IOException("Failed to parse log", e);
149         }
150 
151     }
152 
153     /**
154      * Get the filename for a log based on its public key. This must be kept in sync with
155      * org.conscrypt.ct.CTLogStoreImpl.
156      */
getLogFileName(String base64PublicKey)157     private String getLogFileName(String base64PublicKey) {
158         byte[] keyBytes = Base64.decode(base64PublicKey, Base64.DEFAULT);
159         try {
160             byte[] id = MessageDigest.getInstance("SHA-256").digest(keyBytes);
161             return HexDump.toHexString(id, false);
162         } catch (NoSuchAlgorithmException e) {
163             // SHA-256 is guaranteed to be available.
164             throw new RuntimeException(e);
165         }
166     }
167 
writeLogEntry(OutputStreamWriter out, String key, String value)168     private void writeLogEntry(OutputStreamWriter out, String key, String value)
169             throws IOException {
170         out.write(key + ":" + value + "\n");
171     }
172 
deleteOldLogDirectories()173     private void deleteOldLogDirectories() throws IOException {
174         if (!updateDir.exists()) {
175             return;
176         }
177         File currentTarget = new File(updateDir, "current").getCanonicalFile();
178         FileFilter filter = new FileFilter() {
179             @Override
180             public boolean accept(File file) {
181                 return !currentTarget.equals(file) && file.getName().startsWith(LOGDIR_PREFIX);
182             }
183         };
184         for (File f : updateDir.listFiles(filter)) {
185             FileUtils.deleteContentsAndDir(f);
186         }
187     }
188 }
189