diff options
author | Makoto Onuki <omakoto@google.com> | 2010-01-08 13:34:57 -0800 |
---|---|---|
committer | Makoto Onuki <omakoto@google.com> | 2010-01-12 10:27:13 -0800 |
commit | 8f028a94fc533e75077485a7d11a04e4de820335 (patch) | |
tree | 318a2faf102a12a01aaaae4e25567fe7a841c936 /common/java | |
parent | aee3c6394a367abf283936cb8b8bd85ed028c050 (diff) | |
download | frameworks_base-8f028a94fc533e75077485a7d11a04e4de820335.zip frameworks_base-8f028a94fc533e75077485a7d11a04e4de820335.tar.gz frameworks_base-8f028a94fc533e75077485a7d11a04e4de820335.tar.bz2 |
Moved DomainNameChecker to android common.
- Moved DomainNameChecker from android.net.http to android common, and renamed to DomainNameValidator.
- Added a simplified version of DNParser, which DomainNameValidator uses instead of X509Name in order to extract Subject Name from a certificate.
- Added unit tests for DomainNameChecker and DNParser.
There's a suspicious comment in DomainNameChecker saying something like "X509Certificate fails to parse a certificate when a subject alt name begins with '*'". I think we should fix it if it's really the case -- otherwise certificates with the wildcard wouldn't work. I'll see if it's true after submitting this patch.
Diffstat (limited to 'common/java')
-rw-r--r-- | common/java/com/android/common/DNParser.java | 447 | ||||
-rw-r--r-- | common/java/com/android/common/DomainNameValidator.java | 281 |
2 files changed, 728 insertions, 0 deletions
diff --git a/common/java/com/android/common/DNParser.java b/common/java/com/android/common/DNParser.java new file mode 100644 index 0000000..32d57c0 --- /dev/null +++ b/common/java/com/android/common/DNParser.java @@ -0,0 +1,447 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.common; + +import android.util.Log; + +import java.io.IOException; + +import javax.security.auth.x500.X500Principal; + +/** + * A simple distinguished name(DN) parser. + * + * <p>This class is based on org.apache.harmony.security.x509.DNParser. It's customized to remove + * external references which are unnecessary for our requirements. + * + * <p>This class is only meant for extracting a string value from a DN. e.g. it doesn't support + * values in the hex-string style. + * + * <p>This class is used by {@link DomainNameValidator} only. However, in order to make this + * class visible from unit tests, it's made public. + */ +public final class DNParser { + private static final String TAG = "DNParser"; + + /** DN to be parsed. */ + private final String dn; + + // length of distinguished name string + private final int length; + + private int pos, beg, end; + + // tmp vars to store positions of the currently parsed item + private int cur; + + // distinguished name chars + private char[] chars; + + /** + * Exception message thrown when we failed to parse DN, which shouldn't happen because we + * only handle DNs that {@link X500Principal#getName} returns, which shouldn't be malformed. + */ + private static final String ERROR_PARSE_ERROR = "Failed to parse DN"; + + /** + * Constructor. + * + * @param principal - {@link X500Principal} to be parsed + */ + public DNParser(X500Principal principal) { + this.dn = principal.getName(X500Principal.RFC2253); + this.length = dn.length(); + } + + // gets next attribute type: (ALPHA 1*keychar) / oid + private String nextAT() throws IOException { + + // skip preceding space chars, they can present after + // comma or semicolon (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + if (pos == length) { + return null; // reached the end of DN + } + + // mark the beginning of attribute type + beg = pos; + + // attribute type chars + pos++; + for (; pos < length && chars[pos] != '=' && chars[pos] != ' '; pos++) { + // we don't follow exact BNF syntax here: + // accept any char except space and '=' + } + if (pos >= length) { + // unexpected end of DN + throw new IOException(ERROR_PARSE_ERROR); + } + + // mark the end of attribute type + end = pos; + + // skip trailing space chars between attribute type and '=' + // (compatibility with RFC 1779) + if (chars[pos] == ' ') { + for (; pos < length && chars[pos] != '=' && chars[pos] == ' '; pos++) { + } + + if (chars[pos] != '=' || pos == length) { + // unexpected end of DN + throw new IOException(ERROR_PARSE_ERROR); + } + } + + pos++; //skip '=' char + + // skip space chars between '=' and attribute value + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + // in case of oid attribute type skip its prefix: "oid." or "OID." + // (compatibility with RFC 1779) + if ((end - beg > 4) && (chars[beg + 3] == '.') + && (chars[beg] == 'O' || chars[beg] == 'o') + && (chars[beg + 1] == 'I' || chars[beg + 1] == 'i') + && (chars[beg + 2] == 'D' || chars[beg + 2] == 'd')) { + beg += 4; + } + + return new String(chars, beg, end - beg); + } + + // gets quoted attribute value: QUOTATION *( quotechar / pair ) QUOTATION + private String quotedAV() throws IOException { + + pos++; + beg = pos; + end = beg; + while (true) { + + if (pos == length) { + // unexpected end of DN + throw new IOException(ERROR_PARSE_ERROR); + } + + if (chars[pos] == '"') { + // enclosing quotation was found + pos++; + break; + } else if (chars[pos] == '\\') { + chars[end] = getEscaped(); + } else { + // shift char: required for string with escaped chars + chars[end] = chars[pos]; + } + pos++; + end++; + } + + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + return new String(chars, beg, end - beg); + } + + // gets hex string attribute value: "#" hexstring + private String hexAV() throws IOException { + + if (pos + 4 >= length) { + // encoded byte array must be not less then 4 c + throw new IOException(ERROR_PARSE_ERROR); + } + + beg = pos; // store '#' position + pos++; + while (true) { + + // check for end of attribute value + // looks for space and component separators + if (pos == length || chars[pos] == '+' || chars[pos] == ',' + || chars[pos] == ';') { + end = pos; + break; + } + + if (chars[pos] == ' ') { + end = pos; + pos++; + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + break; + } else if (chars[pos] >= 'A' && chars[pos] <= 'F') { + chars[pos] += 32; //to low case + } + + pos++; + } + + // verify length of hex string + // encoded byte array must be not less then 4 and must be even number + int hexLen = end - beg; // skip first '#' char + if (hexLen < 5 || (hexLen & 1) == 0) { + throw new IOException(ERROR_PARSE_ERROR); + } + + // get byte encoding from string representation + byte[] encoded = new byte[hexLen / 2]; + for (int i = 0, p = beg + 1; i < encoded.length; p += 2, i++) { + encoded[i] = (byte) getByte(p); + } + + return new String(chars, beg, hexLen); + } + + // gets string attribute value: *( stringchar / pair ) + private String escapedAV() throws IOException { + + beg = pos; + end = pos; + while (true) { + + if (pos >= length) { + // the end of DN has been found + return new String(chars, beg, end - beg); + } + + switch (chars[pos]) { + case '+': + case ',': + case ';': + // separator char has beed found + return new String(chars, beg, end - beg); + case '\\': + // escaped char + chars[end++] = getEscaped(); + pos++; + break; + case ' ': + // need to figure out whether space defines + // the end of attribute value or not + cur = end; + + pos++; + chars[end++] = ' '; + + for (; pos < length && chars[pos] == ' '; pos++) { + chars[end++] = ' '; + } + if (pos == length || chars[pos] == ',' || chars[pos] == '+' + || chars[pos] == ';') { + // separator char or the end of DN has beed found + return new String(chars, beg, cur - beg); + } + break; + default: + chars[end++] = chars[pos]; + pos++; + } + } + } + + // returns escaped char + private char getEscaped() throws IOException { + + pos++; + if (pos == length) { + throw new IOException(ERROR_PARSE_ERROR); + } + + switch (chars[pos]) { + case '"': + case '\\': + case ',': + case '=': + case '+': + case '<': + case '>': + case '#': + case ';': + case ' ': + case '*': + case '%': + case '_': + //FIXME: escaping is allowed only for leading or trailing space char + return chars[pos]; + default: + // RFC doesn't explicitly say that escaped hex pair is + // interpreted as UTF-8 char. It only contains an example of such DN. + return getUTF8(); + } + } + + // decodes UTF-8 char + // see http://www.unicode.org for UTF-8 bit distribution table + private char getUTF8() throws IOException { + + int res = getByte(pos); + pos++; //FIXME tmp + + if (res < 128) { // one byte: 0-7F + return (char) res; + } else if (res >= 192 && res <= 247) { + + int count; + if (res <= 223) { // two bytes: C0-DF + count = 1; + res = res & 0x1F; + } else if (res <= 239) { // three bytes: E0-EF + count = 2; + res = res & 0x0F; + } else { // four bytes: F0-F7 + count = 3; + res = res & 0x07; + } + + int b; + for (int i = 0; i < count; i++) { + pos++; + if (pos == length || chars[pos] != '\\') { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + pos++; + + b = getByte(pos); + pos++; //FIXME tmp + if ((b & 0xC0) != 0x80) { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + + res = (res << 6) + (b & 0x3F); + } + return (char) res; + } else { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + } + + // Returns byte representation of a char pair + // The char pair is composed of DN char in + // specified 'position' and the next char + // According to BNF syntax: + // hexchar = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + // / "a" / "b" / "c" / "d" / "e" / "f" + private int getByte(int position) throws IOException { + + if ((position + 1) >= length) { + // to avoid ArrayIndexOutOfBoundsException + throw new IOException(ERROR_PARSE_ERROR); + } + + int b1, b2; + + b1 = chars[position]; + if (b1 >= '0' && b1 <= '9') { + b1 = b1 - '0'; + } else if (b1 >= 'a' && b1 <= 'f') { + b1 = b1 - 87; // 87 = 'a' - 10 + } else if (b1 >= 'A' && b1 <= 'F') { + b1 = b1 - 55; // 55 = 'A' - 10 + } else { + throw new IOException(ERROR_PARSE_ERROR); + } + + b2 = chars[position + 1]; + if (b2 >= '0' && b2 <= '9') { + b2 = b2 - '0'; + } else if (b2 >= 'a' && b2 <= 'f') { + b2 = b2 - 87; // 87 = 'a' - 10 + } else if (b2 >= 'A' && b2 <= 'F') { + b2 = b2 - 55; // 55 = 'A' - 10 + } else { + throw new IOException(ERROR_PARSE_ERROR); + } + + return (b1 << 4) + b2; + } + + /** + * Parses the DN and returns the attribute value for an attribute type. + * + * @param attributeType attribute type to look for (e.g. "ca") + * @return value of the attribute that first found, or null if none found + */ + public String find(String attributeType) { + try { + // Initialize internal state. + pos = 0; + beg = 0; + end = 0; + cur = 0; + chars = dn.toCharArray(); + + String attType = nextAT(); + if (attType == null) { + return null; + } + while (true) { + String attValue = ""; + + if (pos == length) { + return null; + } + + switch (chars[pos]) { + case '"': + attValue = quotedAV(); + break; + case '#': + attValue = hexAV(); + break; + case '+': + case ',': + case ';': // compatibility with RFC 1779: semicolon can separate RDNs + //empty attribute value + break; + default: + attValue = escapedAV(); + } + + if (attributeType.equalsIgnoreCase(attType)) { + return attValue; + } + + if (pos >= length) { + return null; + } + + if (chars[pos] == ',' || chars[pos] == ';') { + } else if (chars[pos] != '+') { + throw new IOException(ERROR_PARSE_ERROR); + } + + pos++; + attType = nextAT(); + if (attType == null) { + throw new IOException(ERROR_PARSE_ERROR); + } + } + } catch (IOException e) { + // Parse error shouldn't happen, because we only handle DNs that + // X500Principal.getName() returns, which shouldn't be malformed. + Log.e(TAG, "Failed to parse DN: " + dn); + return null; + } + } +} diff --git a/common/java/com/android/common/DomainNameValidator.java b/common/java/com/android/common/DomainNameValidator.java new file mode 100644 index 0000000..ad44a7d --- /dev/null +++ b/common/java/com/android/common/DomainNameValidator.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2010 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.common; + +import android.util.Config; +import android.util.Log; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import javax.security.auth.x500.X500Principal; + +public class DomainNameValidator { + private final static String TAG = "DomainNameValidator"; + + private static final boolean DEBUG = false; + private static final boolean LOG_ENABLED = DEBUG ? Config.LOGD : Config.LOGV; + + private static Pattern QUICK_IP_PATTERN; + static { + try { + QUICK_IP_PATTERN = Pattern.compile("^[a-f0-9\\.:]+$"); + } catch (PatternSyntaxException e) {} + } + + private static final int ALT_DNS_NAME = 2; + private static final int ALT_IPA_NAME = 7; + + /** + * Checks the site certificate against the domain name of the site being visited + * @param certificate The certificate to check + * @param thisDomain The domain name of the site being visited + * @return True iff if there is a domain match as specified by RFC2818 + */ + public static boolean match(X509Certificate certificate, String thisDomain) { + if (certificate == null || thisDomain == null || thisDomain.length() == 0) { + return false; + } + + thisDomain = thisDomain.toLowerCase(); + if (!isIpAddress(thisDomain)) { + return matchDns(certificate, thisDomain); + } else { + return matchIpAddress(certificate, thisDomain); + } + } + + /** + * @return True iff the domain name is specified as an IP address + */ + private static boolean isIpAddress(String domain) { + boolean rval = (domain != null && domain.length() != 0); + if (rval) { + try { + // do a quick-dirty IP match first to avoid DNS lookup + rval = QUICK_IP_PATTERN.matcher(domain).matches(); + if (rval) { + rval = domain.equals( + InetAddress.getByName(domain).getHostAddress()); + } + } catch (UnknownHostException e) { + String errorMessage = e.getMessage(); + if (errorMessage == null) { + errorMessage = "unknown host exception"; + } + + if (LOG_ENABLED) { + Log.v(TAG, "DomainNameValidator.isIpAddress(): " + errorMessage); + } + + rval = false; + } + } + + return rval; + } + + /** + * Checks the site certificate against the IP domain name of the site being visited + * @param certificate The certificate to check + * @param thisDomain The DNS domain name of the site being visited + * @return True iff if there is a domain match as specified by RFC2818 + */ + private static boolean matchIpAddress(X509Certificate certificate, String thisDomain) { + if (LOG_ENABLED) { + Log.v(TAG, "DomainNameValidator.matchIpAddress(): this domain: " + thisDomain); + } + + try { + Collection subjectAltNames = certificate.getSubjectAlternativeNames(); + if (subjectAltNames != null) { + Iterator i = subjectAltNames.iterator(); + while (i.hasNext()) { + List altNameEntry = (List)(i.next()); + if (altNameEntry != null && 2 <= altNameEntry.size()) { + Integer altNameType = (Integer)(altNameEntry.get(0)); + if (altNameType != null) { + if (altNameType.intValue() == ALT_IPA_NAME) { + String altName = (String)(altNameEntry.get(1)); + if (altName != null) { + if (LOG_ENABLED) { + Log.v(TAG, "alternative IP: " + altName); + } + if (thisDomain.equalsIgnoreCase(altName)) { + return true; + } + } + } + } + } + } + } + } catch (CertificateParsingException e) {} + + return false; + } + + /** + * Checks the site certificate against the DNS domain name of the site being visited + * @param certificate The certificate to check + * @param thisDomain The DNS domain name of the site being visited + * @return True iff if there is a domain match as specified by RFC2818 + */ + private static boolean matchDns(X509Certificate certificate, String thisDomain) { + boolean hasDns = false; + try { + Collection subjectAltNames = certificate.getSubjectAlternativeNames(); + if (subjectAltNames != null) { + Iterator i = subjectAltNames.iterator(); + while (i.hasNext()) { + List altNameEntry = (List)(i.next()); + if (altNameEntry != null && 2 <= altNameEntry.size()) { + Integer altNameType = (Integer)(altNameEntry.get(0)); + if (altNameType != null) { + if (altNameType.intValue() == ALT_DNS_NAME) { + hasDns = true; + String altName = (String)(altNameEntry.get(1)); + if (altName != null) { + if (matchDns(thisDomain, altName)) { + return true; + } + } + } + } + } + } + } + } catch (CertificateParsingException e) { + // one way we can get here is if an alternative name starts with + // '*' character, which is contrary to one interpretation of the + // spec (a valid DNS name must start with a letter); there is no + // good way around this, and in order to be compatible we proceed + // to check the common name (ie, ignore alternative names) + if (LOG_ENABLED) { + String errorMessage = e.getMessage(); + if (errorMessage == null) { + errorMessage = "failed to parse certificate"; + } + + Log.v(TAG, "DomainNameValidator.matchDns(): " + errorMessage); + } + } + + if (!hasDns) { + final String cn = new DNParser(certificate.getSubjectX500Principal()) + .find("cn"); + if (LOG_ENABLED) { + Log.v(TAG, "Validating subject: DN:" + + certificate.getSubjectX500Principal().getName(X500Principal.CANONICAL) + + " CN:" + cn); + } + if (cn != null) { + return matchDns(thisDomain, cn); + } + } + + return false; + } + + /** + * @param thisDomain The domain name of the site being visited + * @param thatDomain The domain name from the certificate + * @return True iff thisDomain matches thatDomain as specified by RFC2818 + */ + // not private for testing + public static boolean matchDns(String thisDomain, String thatDomain) { + if (LOG_ENABLED) { + Log.v(TAG, "DomainNameValidator.matchDns():" + + " this domain: " + thisDomain + + " that domain: " + thatDomain); + } + + if (thisDomain == null || thisDomain.length() == 0 || + thatDomain == null || thatDomain.length() == 0) { + return false; + } + + thatDomain = thatDomain.toLowerCase(); + + // (a) domain name strings are equal, ignoring case: X matches X + boolean rval = thisDomain.equals(thatDomain); + if (!rval) { + String[] thisDomainTokens = thisDomain.split("\\."); + String[] thatDomainTokens = thatDomain.split("\\."); + + int thisDomainTokensNum = thisDomainTokens.length; + int thatDomainTokensNum = thatDomainTokens.length; + + // (b) OR thatHost is a '.'-suffix of thisHost: Z.Y.X matches X + if (thisDomainTokensNum >= thatDomainTokensNum) { + for (int i = thatDomainTokensNum - 1; i >= 0; --i) { + rval = thisDomainTokens[i].equals(thatDomainTokens[i]); + if (!rval) { + // (c) OR we have a special *-match: + // *.Y.X matches Z.Y.X but *.X doesn't match Z.Y.X + rval = (i == 0 && thisDomainTokensNum == thatDomainTokensNum); + if (rval) { + rval = thatDomainTokens[0].equals("*"); + if (!rval) { + // (d) OR we have a *-component match: + // f*.com matches foo.com but not bar.com + rval = domainTokenMatch( + thisDomainTokens[0], thatDomainTokens[0]); + } + } + break; + } + } + } else { + // (e) OR thatHost has a '*.'-prefix of thisHost: + // *.Y.X matches Y.X + rval = thatDomain.equals("*." + thisDomain); + } + } + + return rval; + } + + /** + * @param thisDomainToken The domain token from the current domain name + * @param thatDomainToken The domain token from the certificate + * @return True iff thisDomainToken matches thatDomainToken, using the + * wildcard match as specified by RFC2818-3.1. For example, f*.com must + * match foo.com but not bar.com + */ + private static boolean domainTokenMatch(String thisDomainToken, String thatDomainToken) { + if (thisDomainToken != null && thatDomainToken != null) { + int starIndex = thatDomainToken.indexOf('*'); + if (starIndex >= 0) { + if (thatDomainToken.length() - 1 <= thisDomainToken.length()) { + String prefix = thatDomainToken.substring(0, starIndex); + String suffix = thatDomainToken.substring(starIndex + 1); + + return thisDomainToken.startsWith(prefix) && thisDomainToken.endsWith(suffix); + } + } + } + + return false; + } +} |