content: /*
   * Copyright (C) 2016 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package com.android.server.updates;
  
  import com.android.internal.util.HexDump;
  import android.os.FileUtils;
  import android.system.Os;
  import android.system.ErrnoException;
  import android.util.Base64;
  import android.util.Slog;
  import java.io.File;
  import java.io.FileFilter;
  import java.io.FileOutputStream;
  import java.io.IOException;
  import java.io.OutputStreamWriter;
  import java.io.StringBufferInputStream;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.security.PublicKey;
  import java.security.NoSuchAlgorithmException;
  import org.json.JSONArray;
  import org.json.JSONException;
  import org.json.JSONObject;
  
  public class CertificateTransparencyLogInstallReceiver extends ConfigUpdateInstallReceiver {
  
      private static final String TAG = "CTLogInstallReceiver";
      private static final String LOGDIR_PREFIX = "logs-";
  
      public CertificateTransparencyLogInstallReceiver() {
          super("/data/misc/keychain/trusted_ct_logs/", "ct_logs", "metadata/", "version");
      }
  
      @Override
      protected void install(byte[] content, int version) throws IOException {
          /* Install is complicated here because we translate the input, which is a JSON file
           * containing log information to a directory with a file per log. To support atomically
           * replacing the old configuration directory with the new there's a bunch of steps. We
           * create a new directory with the logs and then do an atomic update of the current symlink
           * to point to the new directory.
           */
  
          // 1. Ensure that the update dir exists and is readable
          updateDir.mkdir();
          if (!updateDir.isDirectory()) {
              throw new IOException("Unable to make directory " + updateDir.getCanonicalPath());
          }
          if (!updateDir.setReadable(true, false)) {
              throw new IOException("Unable to set permissions on " +
                      updateDir.getCanonicalPath());
          }
          File currentSymlink = new File(updateDir, "current");
          File newVersion = new File(updateDir, LOGDIR_PREFIX + String.valueOf(version));
          File oldDirectory;
          // 2. Handle the corner case where the new directory already exists.
          if (newVersion.exists()) {
              // If the symlink has already been updated then the update died between steps 7 and 8
              // and so we cannot delete the directory since its in use. Instead just bump the version
              // and return.
              if (newVersion.getCanonicalPath().equals(currentSymlink.getCanonicalPath())) {
                  writeUpdate(updateDir, updateVersion, Long.toString(version).getBytes());
                  deleteOldLogDirectories();
                  return;
              } else {
                  FileUtils.deleteContentsAndDir(newVersion);
              }
          }
          try {
              // 3. Create /data/misc/keychain/trusted_ct_logs/<new_version>/ .
              newVersion.mkdir();
              if (!newVersion.isDirectory()) {
                  throw new IOException("Unable to make directory " + newVersion.getCanonicalPath());
              }
              if (!newVersion.setReadable(true, false)) {
                  throw new IOException("Failed to set " +newVersion.getCanonicalPath() +
                          " readable");
              }
  
              // 4. For each log in the log file create the corresponding file in <new_version>/ .
              try {
                  JSONObject json = new JSONObject(new String(content, StandardCharsets.UTF_8));
                  JSONArray logs = json.getJSONArray("logs");
                  for (int i = 0; i < logs.length(); i++) {
                      JSONObject log = logs.getJSONObject(i);
                      installLog(newVersion, log);
                  }
              } catch (JSONException e) {
                  throw new IOException("Failed to parse logs", e);
              }
  
              // 5. Create the temp symlink. We'll rename this to the target symlink to get an atomic
              // update.
              File tempSymlink = new File(updateDir, "new_symlink");
              try {
                  Os.symlink(newVersion.getCanonicalPath(), tempSymlink.getCanonicalPath());
              } catch (ErrnoException e) {
                  throw new IOException("Failed to create symlink", e);
              }
  
              // 6. Update the symlink target, this is the actual update step.
              tempSymlink.renameTo(currentSymlink.getAbsoluteFile());
          } catch (IOException | RuntimeException e) {
              FileUtils.deleteContentsAndDir(newVersion);
              throw e;
          }
          Slog.i(TAG, "CT log directory updated to " + newVersion.getAbsolutePath());
          // 7. Update the current version information
          writeUpdate(updateDir, updateVersion, Long.toString(version).getBytes());
          // 8. Cleanup
          deleteOldLogDirectories();
      }
  
      private void installLog(File directory, JSONObject logObject) throws IOException {
          try {
              String logFilename = getLogFileName(logObject.getString("key"));
              File file = new File(directory, logFilename);
              try (OutputStreamWriter out =
                      new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
                  writeLogEntry(out, "key", logObject.getString("key"));
                  writeLogEntry(out, "url", logObject.getString("url"));
                  writeLogEntry(out, "description", logObject.getString("description"));
              }
              if (!file.setReadable(true, false)) {
                  throw new IOException("Failed to set permissions on " + file.getCanonicalPath());
              }
          } catch (JSONException e) {
              throw new IOException("Failed to parse log", e);
          }
  
      }
  
      /**
       * Get the filename for a log based on its public key. This must be kept in sync with
       * org.conscrypt.ct.CTLogStoreImpl.
       */
      private String getLogFileName(String base64PublicKey) {
          byte[] keyBytes = Base64.decode(base64PublicKey, Base64.DEFAULT);
          try {
              byte[] id = MessageDigest.getInstance("SHA-256").digest(keyBytes);
              return HexDump.toHexString(id, false);
          } catch (NoSuchAlgorithmException e) {
              // SHA-256 is guaranteed to be available.
              throw new RuntimeException(e);
          }
      }
  
      private void writeLogEntry(OutputStreamWriter out, String key, String value)
              throws IOException {
          out.write(key + ":" + value + "\n");
      }
  
      private void deleteOldLogDirectories() throws IOException {
          if (!updateDir.exists()) {
              return;
          }
          File currentTarget = new File(updateDir, "current").getCanonicalFile();
          FileFilter filter = new FileFilter() {
              @Override
              public boolean accept(File file) {
                  return !currentTarget.equals(file) && file.getName().startsWith(LOGDIR_PREFIX);
              }
          };
          for (File f : updateDir.listFiles(filter)) {
              FileUtils.deleteContentsAndDir(f);
          }
      }
  }
  