package org.certificatetransparency.ctlog.comm; import java.io.ByteArrayInputStream; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.util.ArrayList; import java.util.List; import org.apache.commons.codec.binary.Base64; import org.apache.http.NameValuePair; import org.apache.http.message.BasicNameValuePair; import org.certificatetransparency.ctlog.CertificateInfo; import org.certificatetransparency.ctlog.CertificateTransparencyException; import org.certificatetransparency.ctlog.ParsedLogEntry; import org.certificatetransparency.ctlog.MerkleAuditProof; import org.certificatetransparency.ctlog.ParsedLogEntryWithProof; import org.certificatetransparency.ctlog.SignedTreeHead; import org.certificatetransparency.ctlog.proto.Ct; import org.certificatetransparency.ctlog.serialization.Deserializer; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.JSONValue; import com.google.common.base.Function; import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import com.google.protobuf.ByteString; /** A CT HTTP client. Abstracts away the json encoding necessary for the server. */ public class HttpLogClient { private static final String ADD_PRE_CHAIN_PATH = "add-pre-chain"; private static final String ADD_CHAIN_PATH = "add-chain"; private static final String GET_STH_PATH = "get-sth"; private static final String GET_ROOTS_PATH = "get-roots"; private static final String GET_ENTRIES = "get-entries"; private static final String GET_STH_CONSISTENCY = "get-sth-consistency"; private static final String GET_ENTRY_AND_PROOF = "get-entry-and-proof"; private static final String GET_PROOF_BY_HASH = "get-proof-by-hash"; private final String logUrl; private final HttpInvoker postInvoker; /** * New HttpLogClient. * * @param logUrl CT Log's full URL, e.g. "http://ct.googleapis.com/pilot/ct/v1/" */ public HttpLogClient(String logUrl) { this(logUrl, new HttpInvoker()); } /** * For testing specify an HttpInvoker * * @param logUrl URL of the log. * @param postInvoker HttpInvoker instance to use. */ public HttpLogClient(String logUrl, HttpInvoker postInvoker) { this.logUrl = logUrl; this.postInvoker = postInvoker; } /** * JSON-encodes the list of certificates into a JSON object. * * @param certs Certificates to encode. * @return A JSON object with one field, "chain", holding a JSON array of base64-encoded certs. */ @SuppressWarnings("unchecked") // Because JSONObject, JSONArray extend raw types. JSONObject encodeCertificates(List<Certificate> certs) { JSONArray retObject = new JSONArray(); try { for (Certificate cert : certs) { retObject.add(Base64.encodeBase64String(cert.getEncoded())); } } catch (CertificateEncodingException e) { throw new CertificateTransparencyException("Error encoding certificate", e); } JSONObject jsonObject = new JSONObject(); jsonObject.put("chain", retObject); return jsonObject; } /** * Parses the CT Log's json response into a proper proto. * * @param responseBody Response string to parse. * @return SCT filled from the JSON input. */ static Ct.SignedCertificateTimestamp parseServerResponse(String responseBody) { if (responseBody == null) { return null; } JSONObject parsedResponse = (JSONObject) JSONValue.parse(responseBody); Ct.SignedCertificateTimestamp.Builder builder = Ct.SignedCertificateTimestamp.newBuilder(); int numericVersion = ((Number) parsedResponse.get("sct_version")).intValue(); Ct.Version version = Ct.Version.valueOf(numericVersion); if (version == null) { throw new IllegalArgumentException( String.format("Input JSON has an invalid version: %d", numericVersion)); } builder.setVersion(version); Ct.LogID.Builder logIdBuilder = Ct.LogID.newBuilder(); logIdBuilder.setKeyId( ByteString.copyFrom(Base64.decodeBase64((String) parsedResponse.get("id")))); builder.setId(logIdBuilder.build()); builder.setTimestamp(((Number) parsedResponse.get("timestamp")).longValue()); String extensions = (String) parsedResponse.get("extensions"); if (!extensions.isEmpty()) { builder.setExtensions(ByteString.copyFrom(Base64.decodeBase64(extensions))); } String base64Signature = (String) parsedResponse.get("signature"); builder.setSignature( Deserializer.parseDigitallySignedFromBinary( new ByteArrayInputStream(Base64.decodeBase64(base64Signature)))); return builder.build(); } /** * Adds a certificate to the log. * * @param certificatesChain The certificate chain to add. * @return SignedCertificateTimestamp if the log added the chain successfully. */ public Ct.SignedCertificateTimestamp addCertificate(List<Certificate> certificatesChain) { Preconditions.checkArgument( !certificatesChain.isEmpty(), "Must have at least one certificate to submit."); boolean isPreCertificate = CertificateInfo.isPreCertificate(certificatesChain.get(0)); if (isPreCertificate && CertificateInfo.isPreCertificateSigningCert(certificatesChain.get(1))) { Preconditions.checkArgument( certificatesChain.size() >= 3, "When signing a PreCertificate with a PreCertificate Signing Cert," + " the issuer certificate must follow."); } return addCertificate(certificatesChain, isPreCertificate); } private Ct.SignedCertificateTimestamp addCertificate( List<Certificate> certificatesChain, boolean isPreCertificate) { String jsonPayload = encodeCertificates(certificatesChain).toJSONString(); String methodPath; if (isPreCertificate) { methodPath = ADD_PRE_CHAIN_PATH; } else { methodPath = ADD_CHAIN_PATH; } String response = postInvoker.makePostRequest(logUrl + methodPath, jsonPayload); return parseServerResponse(response); } /** * Retrieves Latest Signed Tree Head from the log. The signature of the Signed Tree Head component * is not verified. * * @return latest STH */ public SignedTreeHead getLogSTH() { String response = postInvoker.makeGetRequest(logUrl + GET_STH_PATH); return parseSTHResponse(response); } /** * Retrieves accepted Root Certificates. * * @return a list of root certificates. */ public List<Certificate> getLogRoots() { String response = postInvoker.makeGetRequest(logUrl + GET_ROOTS_PATH); return parseRootCertsResponse(response); } /** * Retrieve Entries from Log. * * @param start 0-based index of first entry to retrieve, in decimal. * @param end 0-based index of last entry to retrieve, in decimal. * @return list of Log's entries. */ public List<ParsedLogEntry> getLogEntries(long start, long end) { Preconditions.checkArgument(0 <= start && end >= start); List<NameValuePair> params = createParamsList("start", "end", Long.toString(start), Long.toString(end)); String response = postInvoker.makeGetRequest(logUrl + GET_ENTRIES, params); return parseLogEntries(response); } /** * Retrieve Merkle Consistency Proof between Two Signed Tree Heads. * * @param first The tree_size of the first tree, in decimal. * @param second The tree_size of the second tree, in decimal. * @return A list of base64 decoded Merkle Tree nodes serialized to ByteString objects. */ public List<ByteString> getSTHConsistency(long first, long second) { Preconditions.checkArgument(0 <= first && second >= first); List<NameValuePair> params = createParamsList("first", "second", Long.toString(first), Long.toString(second)); String response = postInvoker.makeGetRequest(logUrl + GET_STH_CONSISTENCY, params); return parseConsistencyProof(response); } /** * Retrieve Entry+Merkle Audit Proof from Log. * * @param leafindex The index of the desired entry. * @param treeSize The tree_size of the tree for which the proof is desired. * @return ParsedLog entry object with proof. */ public ParsedLogEntryWithProof getLogEntryAndProof(long leafindex, long treeSize) { Preconditions.checkArgument(0 <= leafindex && treeSize >= leafindex); List<NameValuePair> params = createParamsList( "leaf_index", "tree_size", Long.toString(leafindex), Long.toString(treeSize)); String response = postInvoker.makeGetRequest(logUrl + GET_ENTRY_AND_PROOF, params); JSONObject entry = (JSONObject) JSONValue.parse(response); JSONArray auditPath = (JSONArray) entry.get("audit_path"); return Deserializer.parseLogEntryWithProof( jsonToLogEntry.apply(entry), auditPath, leafindex, treeSize); } /** * Retrieve Merkle Audit Proof from Log by Merkle Leaf Hash. * * @param leafHash sha256 hash of MerkleTreeLeaf. * @return MerkleAuditProof object. */ public MerkleAuditProof getProofByHash(byte[] leafHash) { Preconditions.checkArgument(leafHash != null && leafHash.length > 0); String encodedMerkleLeafHash = Base64.encodeBase64String(leafHash); SignedTreeHead sth = getLogSTH(); return getProofByEncodedHash(encodedMerkleLeafHash, sth.treeSize); } /** * Retrieve Merkle Audit Proof from Log by Merkle Leaf Hash. * * @param encodedMerkleLeafHash Base64 encoded of sha256 hash of MerkleTreeLeaf. * @param treeSize The tree_size of the tree for which the proof is desired. It can be obtained * from latest STH. * @return MerkleAuditProof object. */ public MerkleAuditProof getProofByEncodedHash(String encodedMerkleLeafHash, long treeSize) { Preconditions.checkArgument( encodedMerkleLeafHash != null && encodedMerkleLeafHash.length() > 0); List<NameValuePair> params = createParamsList("tree_size", "hash", Long.toString(treeSize), encodedMerkleLeafHash); String response = postInvoker.makeGetRequest(logUrl + GET_PROOF_BY_HASH, params); JSONObject entry = (JSONObject) JSONValue.parse(response); JSONArray auditPath = (JSONArray) entry.get("audit_path"); long leafindex = Long.valueOf(String.valueOf(entry.get("leaf_index"))); return Deserializer.parseAuditProof(auditPath, leafindex, treeSize); } /** * Creates a list of NameValuePair objects. * * @param firstParamName The first parameter name. * @param firstParamValue The first parameter value. * @param secondParamName The second parameter name. * @param secondParamValue The second parameter value. * @return A list of NameValuePair objects. */ private List<NameValuePair> createParamsList( String firstParamName, String secondParamName, String firstParamValue, String secondParamValue) { List<NameValuePair> params = new ArrayList<NameValuePair>(); params.add(new BasicNameValuePair(firstParamName, firstParamValue)); params.add(new BasicNameValuePair(secondParamName, secondParamValue)); return params; } /** * Parses CT log's response for "get-entries" into a list of {@link ParsedLogEntry} objects. * * @param response Log response to pars. * @return list of Log's entries. */ @SuppressWarnings("unchecked") private List<ParsedLogEntry> parseLogEntries(String response) { Preconditions.checkNotNull(response, "Log entries response from Log should not be null."); JSONObject responseJson = (JSONObject) JSONValue.parse(response); JSONArray arr = (JSONArray) responseJson.get("entries"); return Lists.transform(arr, jsonToLogEntry); } private Function<JSONObject, ParsedLogEntry> jsonToLogEntry = new Function<JSONObject, ParsedLogEntry>() { @Override public ParsedLogEntry apply(JSONObject entry) { String leaf = (String) entry.get("leaf_input"); String extra = (String) entry.get("extra_data"); return Deserializer.parseLogEntry( new ByteArrayInputStream(Base64.decodeBase64(leaf)), new ByteArrayInputStream(Base64.decodeBase64(extra))); } }; /** * Parses CT log's response for the "get-sth-consistency" request. * * @param response JsonObject containing an array of Merkle Tree nodes. * @return A list of base64 decoded Merkle Tree nodes serialized to ByteString objects. */ private List<ByteString> parseConsistencyProof(String response) { Preconditions.checkNotNull(response, "Merkle Consistency response should not be null."); JSONObject responseJson = (JSONObject) JSONValue.parse(response); JSONArray arr = (JSONArray) responseJson.get("consistency"); List<ByteString> proof = new ArrayList<ByteString>(); for (Object node : arr) { proof.add(ByteString.copyFrom(Base64.decodeBase64((String) node))); } return proof; } /** * Parses CT log's response for "get-sth" into a proto object. * * @param sthResponse Log response to parse * @return a proto object of SignedTreeHead type. */ SignedTreeHead parseSTHResponse(String sthResponse) { Preconditions.checkNotNull( sthResponse, "Sign Tree Head response from a CT log should not be null"); JSONObject response = (JSONObject) JSONValue.parse(sthResponse); long treeSize = (Long) response.get("tree_size"); long timestamp = (Long) response.get("timestamp"); if (treeSize < 0 || timestamp < 0) { throw new CertificateTransparencyException( String.format( "Bad response. Size of tree or timestamp cannot be a negative value. " + "Log Tree size: %d Timestamp: %d", treeSize, timestamp)); } String base64Signature = (String) response.get("tree_head_signature"); String sha256RootHash = (String) response.get("sha256_root_hash"); SignedTreeHead sth = new SignedTreeHead(Ct.Version.V1); sth.treeSize = treeSize; sth.timestamp = timestamp; sth.sha256RootHash = Base64.decodeBase64(sha256RootHash); sth.signature = Deserializer.parseDigitallySignedFromBinary( new ByteArrayInputStream(Base64.decodeBase64(base64Signature))); if (sth.sha256RootHash.length != 32) { throw new CertificateTransparencyException( String.format( "Bad response. The root hash of the Merkle Hash Tree must be 32 bytes. " + "The size of the root hash is %d", sth.sha256RootHash.length)); } return sth; } /** * Parses the response from "get-roots" GET method. * * @param response JSONObject with certificates to parse. * @return a list of root certificates. */ List<Certificate> parseRootCertsResponse(String response) { List<Certificate> certs = new ArrayList<>(); JSONObject entries = (JSONObject) JSONValue.parse(response); JSONArray entriesArray = (JSONArray) entries.get("certificates"); for (Object i : entriesArray) { // We happen to know that JSONArray contains strings. byte[] in = Base64.decodeBase64((String) i); try { certs.add( CertificateFactory.getInstance("X509") .generateCertificate(new ByteArrayInputStream(in))); } catch (CertificateException e) { throw new CertificateTransparencyException( "Malformed data from a CT log have been received: " + e.getLocalizedMessage(), e); } } return certs; } }