From d342a45d98c4795b3a3fe1aaef5236ad4a782b55 Mon Sep 17 00:00:00 2001 From: Yuuta Liang Date: Thu, 12 Oct 2023 12:10:33 +0800 Subject: Implement data structures from X.680, X.501, X.509, and PKCS#10, with X.690 encoding / decoding support The implementation took four days, and it is still a little bit rough. Updated version should arrive soon. Signed-off-by: Yuuta Liang --- src/main/model/asn1/ASN1Length.java | 101 ++++++++++ src/main/model/asn1/ASN1Object.java | 201 ++++++++++++++++++++ src/main/model/asn1/ASN1String.java | 85 +++++++++ src/main/model/asn1/ASN1Time.java | 66 +++++++ src/main/model/asn1/BitString.java | 95 ++++++++++ src/main/model/asn1/Bool.java | 60 ++++++ src/main/model/asn1/Encodable.java | 9 + src/main/model/asn1/GeneralizedTime.java | 90 +++++++++ src/main/model/asn1/IA5String.java | 53 ++++++ src/main/model/asn1/Int.java | 81 ++++++++ src/main/model/asn1/Null.java | 45 +++++ src/main/model/asn1/ObjectIdentifier.java | 204 +++++++++++++++++++++ src/main/model/asn1/OctetString.java | 47 +++++ src/main/model/asn1/PrintableString.java | 49 +++++ src/main/model/asn1/Tag.java | 109 +++++++++++ src/main/model/asn1/TagClass.java | 42 +++++ src/main/model/asn1/UTF8String.java | 40 ++++ src/main/model/asn1/UtcTime.java | 91 +++++++++ src/main/model/asn1/exceptions/ParseException.java | 10 + src/main/model/asn1/parsing/BytesReader.java | 105 +++++++++++ 20 files changed, 1583 insertions(+) create mode 100644 src/main/model/asn1/ASN1Length.java create mode 100644 src/main/model/asn1/ASN1Object.java create mode 100644 src/main/model/asn1/ASN1String.java create mode 100644 src/main/model/asn1/ASN1Time.java create mode 100644 src/main/model/asn1/BitString.java create mode 100644 src/main/model/asn1/Bool.java create mode 100644 src/main/model/asn1/Encodable.java create mode 100644 src/main/model/asn1/GeneralizedTime.java create mode 100644 src/main/model/asn1/IA5String.java create mode 100644 src/main/model/asn1/Int.java create mode 100644 src/main/model/asn1/Null.java create mode 100644 src/main/model/asn1/ObjectIdentifier.java create mode 100644 src/main/model/asn1/OctetString.java create mode 100644 src/main/model/asn1/PrintableString.java create mode 100644 src/main/model/asn1/Tag.java create mode 100644 src/main/model/asn1/TagClass.java create mode 100644 src/main/model/asn1/UTF8String.java create mode 100644 src/main/model/asn1/UtcTime.java create mode 100644 src/main/model/asn1/exceptions/ParseException.java create mode 100644 src/main/model/asn1/parsing/BytesReader.java (limited to 'src/main/model/asn1') diff --git a/src/main/model/asn1/ASN1Length.java b/src/main/model/asn1/ASN1Length.java new file mode 100644 index 0000000..e85689c --- /dev/null +++ b/src/main/model/asn1/ASN1Length.java @@ -0,0 +1,101 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.util.Arrays; + +/** + * Represents the Length part in DER encoding. It appears after Tag and before Value. It represents the length of the + * encoded Value in bytes. + * For encoding, if the length is <= 127, it is encoded as a single byte, with the highest bit cleared. If it is > 127, + * the initial byte will have its highest bit set, with the remaining 7 bits representing how many of bytes in advance + * are needed to represent the multibyte length value. Then, following are the multibyte length value, encoded in a + * big-endian unsigned integer. + */ +public class ASN1Length implements Encodable { + /** + * The length. It is represented in Java signed 32bit integer, but it should be unsigned. + * Operations should use Integer#unsigned* methods. + */ + private final int length; + + /** + * EFFECTS: Initialize the object with the given length. + * REQUIRES: length >= 0 + */ + public ASN1Length(int length) { + this.length = length; + } + + /** + * EFFECTS: Parse the length from the given DER input. + * Throws {@link ParseException} if the input is invalid: + * - Indefinite length + * - Not enough bytes + * - Initial byte 0b11111111 (See X.690$8.1.3.5) + * - Value too long (compliant to RFC but unsupported by this program): multibyte and # of bytes > 3 + * MODIFIES: reader (bytes are read, at least one byte, at most 5 bytes) + */ + public ASN1Length(BytesReader reader) throws ParseException { + final Byte first = reader.require(1, true)[0]; + if (first < 0) { + // Multibyte + // 0b11111111 + if (first == -1) { + throw new ParseException("The initial byte must not be 0xFF"); + } + // Clear the sign bit and get the remaining. + int count = first & 127; + if (count == 0) { + throw new ParseException("Indefinite length is forbidden by DER"); + } + final Byte[] values = reader.require(count, true); + // Pad one byte to the top - so it is always unsigned. + Byte[] b1 = new Byte[values.length + 1]; + System.arraycopy(values, 0, b1, 1, values.length); + b1[0] = 0x0; + this.length = Utils.bytesToInt(b1); + } else { + this.length = first; + } + } + + /** + * EFFECTS: Compute the length to add in the Tag - Length - Value format. For a detailed description on length, see + * class specification. + */ + @Override + public Byte[] encodeDER() { + // Determine the length of the length. + // If the length is <= 127 bytes, use a single byte. + // If the length is > 127 bytes, set the highest bit as 1, and the + // rest of the bits specify how many more bytes the length is, followed + // by a sequence of bytes of the length. + // Setting the length 80 (0b10000000) means indefinite length, which is forbidden + // by DER. + // DER prefers the shortest form. + if (length <= 127) { + // Possible in a single byte. + return new Byte[]{ (byte) length }; + } else { + // Big-endian encoding of the length. DER uses big-endian. + final Byte[] lengthBytes = Utils.valToByte(length); + final Byte[] result = new Byte[lengthBytes.length + 1]; + // Turn-on the highest bit. + result[0] = (byte) (lengthBytes.length | -128); + // Append the length. + System.arraycopy(lengthBytes, 0, + result, 1, lengthBytes.length); + return result; + } + } + + /** + * EFFECT: Returns the unsigned integer length. + */ + public int getLength() { + return length; + } +} diff --git a/src/main/model/asn1/ASN1Object.java b/src/main/model/asn1/ASN1Object.java new file mode 100644 index 0000000..1af26ce --- /dev/null +++ b/src/main/model/asn1/ASN1Object.java @@ -0,0 +1,201 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Represents an encode-able ASN.1 object. It can be a SEQUENCE, an INTEGER, an OID, or any other ASN.1 type. + * It has a parent tag and length (if explicitly encoded), a tag and length, and a value. + * Child classes have specific parsed values available. + */ +public class ASN1Object implements Encodable { + /** + * The X.680 UNIVERSAL tag assignment for SEQUENCE and SEQUENCE OF. Because there is no such Java + * model class that represents an abstract ASN1Sequence, implementations may just use this constant + * as their default tag. + */ + public static final Tag TAG_SEQUENCE = new Tag(TagClass.UNIVERSAL, true, 0x10); + + /** + * The X.680 UNIVERSAL tag assignment for SET and SET OF. Because there is no such Java + * model class that represents an abstract ASN1Set, implementations may just use this constant + * as their default tag. + */ + public static final Tag TAG_SET = new Tag(TagClass.UNIVERSAL, true, 0x11); + + // The ASN.1 type tag. + private final Tag tag; + + // The value length for implementation parsing purposes (only available if the object is parsed) + private final int length; + + // The parsed raw value (only available if the object is parsed) + private final Byte[] value; + + // The parent ASN.1 type tag, if required for EXPLICIT tagging with a CONTEXT SPECIFIC tag number. + private final Tag parentTag; + + /** + * EFFECTS: Initiate the object with the given tag and an optional context-specific tag number for explicit + * encoding. It will always have 0 length and null value. + * By default, the tag should be the corresponding default tag specified in the constants + * of the corresponding types. However, applications may use context-specific + * or private tags for corresponding fields, either implicitly encoded or explicitly encoded. + * REQUIRES: Three cases: + * 1. No context-specific tag: parentTag must be null. + * 2. Implicit encoding: parentTag must be null, and the tag must be CONTEXT_SPECIFIC. + * 3. Explicit encoding: parentTag must be constructive and CONTEXT_SPECIFIC. + */ + public ASN1Object(Tag tag, Tag parentTag) { + this.tag = tag; + this.parentTag = parentTag; + this.length = 0; + this.value = null; + } + + /** + * EFFECTS: Init the object (tag, parentTag, length) with the specified input DER bytes. It will have length + * and value (length = 0 if no value in DER, but never null). It will fill the value and length but will not mark + * the value as read (only the tag will be marked). Subtypes are responsible for deserializing the values. This + * method is not appropriate for parsing an unknown input (use subtypes instead) since values will be left unread. + * Throws {@link ParseException} if input is invalid: + * The input data must have a valid + * parentTag (optional) - parentLength (optional) - tag - length - value (optional). + * The value must match the corresponding type (e.g., an INTEGER value cannot go to an OctetString type). + * The value must be supported by the corresponding type (e.g., a Printable must only contain valid chars). + * If parentTag presents, its class must be CONTEXT_SPECIFIC, and it must be constructive. + * If parentLength presents, it must not be 0. + * MODIFIES: this, encoded (bytes are read) + * REQUIRES: If hasParentTag is true, parentTag and parentLength must present. Otherwise, they must be null. Assumes + * that the length won't be lower than actual. Assumes parentLength = length(tag + length + value). + */ + public ASN1Object(BytesReader encoded, boolean hasParentTag) throws ParseException { + if (hasParentTag) { + this.parentTag = new Tag(encoded); + if (parentTag.getCls() != TagClass.CONTEXT_SPECIFIC) { + throw new ParseException("Parent tag must be CONTEXT_SPECIFIC, but found " + + parentTag.getCls() + "[" + parentTag.getNumber() + "]."); + } + if (!parentTag.isConstructive()) { + throw new ParseException("Parent tag must be constructive."); + } + int parentLen = new ASN1Length(encoded).getLength(); + // Validate length + encoded.validateSize(parentLen); + if (Integer.compareUnsigned(parentLen, 2) < 0) { + throw new ParseException("Parent tag with incorrect length."); + } + } else { + parentTag = null; + } + // len = the length of value; i = the length of tag - length + this.tag = new Tag(encoded); + int len = new ASN1Length(encoded).getLength(); + this.length = len; + if (len == 0) { + this.value = new Byte[0]; + } else { + this.value = encoded.require(len, false); + } + } + + /** + * EFFECTS: Automatically detect the UNIVERSAL type and parse into the corresponding ASN1Object type, or ASN1Object + * if unrecognized or application-defined (SEQUENCE or SET). It will always mark anything to be read, including + * unrecognized type values. This method is appropriate to decode an unknown input stream into known or unknown + * types. All values will be read. + * Throws {@link ParseException} if the input is invalid. + * MODIFIES: encoded + */ + public static ASN1Object parse(BytesReader encoded, boolean hasParentTag) throws ParseException { + final Tag t = encoded.getTag(hasParentTag); + switch (t.getNumber()) { + case 0x1: return new Bool(encoded, hasParentTag); + case 0x2: return new Int(encoded, hasParentTag); + case 0x3: return new BitString(encoded, hasParentTag); + case 0x4: return new OctetString(encoded, hasParentTag); + case 0x5: return new Null(encoded, hasParentTag); + case 0x6: return new ObjectIdentifier(encoded, hasParentTag); + case 0xC: return new UTF8String(encoded, hasParentTag); + case 0x13: return new PrintableString(encoded, hasParentTag); + case 0x16: return new IA5String(encoded, hasParentTag); + case 0x17: return new UtcTime(encoded, hasParentTag); + case 0x18: return new GeneralizedTime(encoded, hasParentTag); + default: { + ASN1Object object = new ASN1Object(encoded, hasParentTag); + // Mark as read unconditionally because there aren't any type handlers that read them. + encoded.require(object.length, true); + return object; + } + } + } + + /** + * EFFECTS: Encode the object to DER bytes in the tag-length-value format, as specified in DER specs. + * The encoding will result in: + * (Parent Tag)(Tag)(Length)(Value) + * Parent Tag - Only exists if the field has a context-specific parent tag number and use explicit tagging. In this + * case, the parent tag is the tag supplied in the constructor. If the field uses implicit tag + * encoding or does not have a context-specific tag number, this field does not exist. This field, + * as specified in the REQUIRES clause in the constructor, is always constructive. + * Parent Length - The length of the following (tag, length, and value). A detailed length description, see follows. + * Tag - The tag value. + * Length - The length of the value, in number of bytes. If the length is <= 127, it will contain only a single + * byte of length value, with the highest bit cleared. If the length is > 127, the first length byte + * will have its highest bit set, with the remaining bits representing how many bytes are needed to + * store the length integer. Followed are the integer, in multiple bytes, representing the length. The + * multibyte integer are encoded in big-endian. + * Value - The value, with a total length (in bytes) corresponding to the Length field. + * REQUIRES: encodeValueDER() != null + */ + @Override + public final Byte[] encodeDER() { + final Byte[] val = encodeValueDER(); + final List list = new ArrayList<>(val.length + 3); + + list.addAll(Arrays.asList(tag.encodeDER())); + list.addAll(Arrays.asList(new ASN1Length(val.length).encodeDER())); + list.addAll(Arrays.asList(encodeValueDER())); + + if (parentTag != null) { // Explicit + final List newList = new ArrayList<>(list.size() + 3); + newList.addAll(Arrays.asList(parentTag.encodeDER())); + newList.addAll(Arrays.asList(new ASN1Length(list.size()).encodeDER())); + newList.addAll(list); + return newList.toArray(new Byte[0]); + } else { + return list.toArray(new Byte[0]); + } + } + + /** + * EFFECTS: Encode the value of that object to DER bytes. The length of the returned value + * is <= (255,255,255,...) (127 in total). + */ + public Byte[] encodeValueDER() { + return value; + } + + public Tag getTag() { + return tag; + } + + public Tag getParentTag() { + return parentTag; + } + + /** + * EFFECTS: Get the unsigned int of value length. Only available if the data is parsed. + */ + public int getLength() { + return length; + } +} diff --git a/src/main/model/asn1/ASN1String.java b/src/main/model/asn1/ASN1String.java new file mode 100644 index 0000000..148c564 --- /dev/null +++ b/src/main/model/asn1/ASN1String.java @@ -0,0 +1,85 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.charset.StandardCharsets; + +/** + * Represents an ASN.1 string with an optional character set restriction. Users should choose from one of its + * implementations that encode a specific ASN.1 string type (e.g., {@link PrintableString} or {@link IA5String}). + */ +public abstract class ASN1String extends ASN1Object { + private String rawString; + + /** + * EFFECTS: Constructs an ASN1String with the given tag, parent tag, and string. + * - Throws {@link ParseException} if the string does not pass corresponding restrictions of the specific + * string type (same as {@link ASN1String#validate(String)}) + * REQUIRES: For the requirements of tag and parentTag, consult {@link ASN1Object}. + */ + public ASN1String(Tag tag, Tag parentTag, String string) throws ParseException { + super(tag, parentTag); + setString(string); + } + + /** + * EFFECTS: Parse the input value. See {@link ASN1Object} with the rawString. + * Throws {@link ParseException} when invalid: + * - String does not pass type restrictions + * - Early EOF + * - Other cases as seen in {@link ASN1Object} + * MODIFIES: this, encoded (bytes are read) + */ + public ASN1String(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + } + + /** + * EFFECTS: Validate and set the string. + * Throws {@link ParseException} if the string is invalid. + * MODIFIES: this + */ + protected void setString(String rawString) throws ParseException { + if (!validate(rawString)) { + throw new ParseException(String.format("The string '%s' is illegal for '%s'.", + rawString, + getClass().getSimpleName())); + } + this.rawString = rawString; + } + + /** + * EFFECTS: Validate whether the given string matches the ASN.1 restrictions on this specific string type. By + * default, it always returns true. + */ + protected boolean validate(String newString) { + return true; + } + + /** + * Same as {@link ASN1String#getString()}. + */ + @Override + public String toString() { + return rawString; + } + + /** + * EFFECTS: Encode the string in DER bytes (big-endian UTF-8). + */ + @Override + public Byte[] encodeValueDER() { + final byte[] bytes = rawString.getBytes(StandardCharsets.UTF_8); + Byte[] b = new Byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + b[i] = bytes[i]; + } + return b; + } + + public String getString() { + return rawString; + } +} diff --git a/src/main/model/asn1/ASN1Time.java b/src/main/model/asn1/ASN1Time.java new file mode 100644 index 0000000..08f861e --- /dev/null +++ b/src/main/model/asn1/ASN1Time.java @@ -0,0 +1,66 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; + +/** + * Common base-class for models like UTCTime and GeneralizedTime. Despite all the formatting and encoding differences, + * it stores a timestamp and corresponding timezone. + */ +public abstract class ASN1Time extends ASN1Object { + /** + * The time. + */ + private ZonedDateTime timestamp; + + /** + * EFFECTS: Initialize the time with the specific tag, parentTag, and timestamp. For tag and parentTag, consult + * {@link ASN1Object}. + */ + public ASN1Time(Tag tag, Tag parentTag, ZonedDateTime timestamp) { + super(tag, parentTag); + this.timestamp = timestamp; + } + + /** + * EFFECTS: Parse and decode DER bytes into the corresponding time type. For more info on decoding, take a look at + * {@link ASN1Object}. + * Throws {@link ParseException} if the input is invalid: + * - Invalid date format + * - Zero length + * - Other circumstances (e.g., early EOF) as seen in {@link ASN1Object} + * MODIFIES: this, encoded + */ + public ASN1Time(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + this.timestamp = toDate(new String(Utils.byteToByte(encoded.require(getLength(), true)))); + } + + /** + * EFFECTS: Generate the byte array for the formatted string. + */ + @Override + public Byte[] encodeValueDER() { + return Utils.byteToByte(toString().getBytes(StandardCharsets.UTF_8)); + } + + /** + * EFFECTS: Convert the given string into corresponding timestamp. + * Throws {@link ParseException} if the time is malformed. + */ + public abstract ZonedDateTime toDate(String str) throws ParseException; + + /** + * EFFECTS: Convert getTimestamp() into corresponding ASN.1 time format. + */ + @Override + public abstract String toString(); + + public ZonedDateTime getTimestamp() { + return timestamp; + } +} diff --git a/src/main/model/asn1/BitString.java b/src/main/model/asn1/BitString.java new file mode 100644 index 0000000..0561f24 --- /dev/null +++ b/src/main/model/asn1/BitString.java @@ -0,0 +1,95 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.math.BigInteger; + +/** + * Represents the ASN.1 BIT STRING (0x3) type. Bit strings represent bytes by padding to the LSB (like a bitstream). + * The bits are encoded as multiple bytes, but the unused bits are stored on the lowest part. + * For example, consider: + *
+ *     0b 00000001 10111001 01110111 (pad to the highest)
+ *     Will be encoded into
+ *     0b 01101110 01011101 11000000 (pad to the lowest)
+ *                            ^^^^^^
+ * 
+ * Before the encoded value, there will be another byte denoting how many padding bits are added to the right. + * That is, the final encoding is: + *
+ *     0b 00000110 01101110 01011101 11000000
+ *        ^  6   ^ ^ Original Number  ^^Pad^
+ * 
+ * + * BIT STRING has nothing to do with encoding bytes as printable strings (base10 or base16 or ASCII). + */ +public class BitString extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x3); + + private final int unused; + private final Byte[] val; + + /** + * EFFECT: Init with tags, unused, and val. For tags, see {@link ASN1Object}. + * REQUIRES: 0 <= unused < 8, the last byte in val must have the lowest $unused bits zero. + */ + public BitString(Tag tag, Tag parentTag, + final int unused, + final Byte[] val) { + super(tag, parentTag); + this.unused = unused; + this.val = val; + } + + /** + * EFFECT: Parse the input DER. + * Throws {@link ParseException} if the input is invalid: + * - Unused is not in 0 <= unused < 8 + * - The last byte does not have its lowest $unused bits zero + * - Other issues found according to {@link ASN1Object} + */ + public BitString(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + this.unused = encoded.require(1, true)[0]; + if (unused < 0 || unused > 7) { + throw new ParseException("Illegal unused byte: " + unused); + } + this.val = encoded.require(getLength() - 1, true); + if ((byte) (val[val.length - 1] << (8 - unused)) != 0) { + throw new ParseException(String.format("The last byte: 0x%02X does not have %d zero bits.", + val[val.length - 1], + unused)); + } + } + + /** + * EFFECTS: Get the converted form that has padding on MSB. The leftmost zero byte, if any, is removed. + */ + public Byte[] getConvertedVal() { + return Utils.byteToByte(new BigInteger(Utils.byteToByte(val)).shiftRight(unused).toByteArray()); + } + + /** + * EFFECTS: Encode into DER. + */ + @Override + public Byte[] encodeValueDER() { + Byte[] arr = new Byte[val.length + 1]; + arr[0] = (byte) unused; + System.arraycopy(val, 0, arr, 1, val.length); + return arr; + } + + public int getUnused() { + return unused; + } + + public Byte[] getVal() { + return val; + } +} diff --git a/src/main/model/asn1/Bool.java b/src/main/model/asn1/Bool.java new file mode 100644 index 0000000..d9f1851 --- /dev/null +++ b/src/main/model/asn1/Bool.java @@ -0,0 +1,60 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +/** + * Represents the ASN.1 BOOLEAN type. It always has one byte length. Its content is either 0xFF (true) or 0x00 (false). + */ +public class Bool extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x1); + + private final boolean value; + + /** + * EFFECTS: Initiate the BOOLEAN with the given tag, an optional context-specific tag number for explicit + * encoding, and its value. For more information, consult {@link ASN1Object}. + * REQUIRES: Consult {@link ASN1Object}. + */ + public Bool(Tag tag, Tag parentTag, boolean value) { + super(tag, parentTag); + this.value = value; + } + + /** + * EFFECTS: Parse input bytes. For more information on tags parsing, consult {@link ASN1Object}. + * Throws {@link ParseException} if the input data is invalid: + * - The length is not 1 + * - The value is neither 0x00 nor 0xFF + * - Other cases as denoted in {@link ASN1Object} + */ + public Bool(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + if (getLength() != 1) { + throw new ParseException("Invalid boolean length: " + getLength()); + } + final Byte val = encoded.require(1, true)[0]; + if (val == 0) { + this.value = false; + } else if (val == -1) { + this.value = true; + } else { + throw new ParseException("Unknown boolean value: " + val); + } + } + + /** + * EFFECTS: Encode the boolean to either 0x00 or 0xFF. + */ + @Override + public Byte[] encodeValueDER() { + return new Byte[]{ value ? (byte) -1 : 0 }; + } + + public boolean getValue() { + return value; + } +} diff --git a/src/main/model/asn1/Encodable.java b/src/main/model/asn1/Encodable.java new file mode 100644 index 0000000..547029c --- /dev/null +++ b/src/main/model/asn1/Encodable.java @@ -0,0 +1,9 @@ +package model.asn1; + +/** + * Provides the method of encoding the specific object into a DER sequence of bytes. + */ +@FunctionalInterface +public interface Encodable { + Byte[] encodeDER(); +} diff --git a/src/main/model/asn1/GeneralizedTime.java b/src/main/model/asn1/GeneralizedTime.java new file mode 100644 index 0000000..385642d --- /dev/null +++ b/src/main/model/asn1/GeneralizedTime.java @@ -0,0 +1,90 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; + +/** + * Represents the ASN.1 GeneralizedTime type. It encodes the time in "YYYYMMDDhhmm[ss]Z" string format, in UTC. + */ +public class GeneralizedTime extends ASN1Time { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x18); + + /** + * Rather stupid impl ... + */ + private static final DateTimeFormatter formatterNoSecs = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4) + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral('Z') + .toFormatter() + .withZone(ZoneId.of("UTC")); + + private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4) + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalEnd() + .appendLiteral('Z') + .toFormatter() + .withZone(ZoneId.of("UTC")); + + /** + * EFFECT: Construct the UTCTime with the given tag, parentTag, and timestamp. For tag and parentTag, + * consult {@link ASN1Object}. + * REQUIRES: timestamp must be in UTC. + */ + public GeneralizedTime(Tag tag, Tag parentTag, ZonedDateTime timestamp) { + super(tag, parentTag, timestamp); + } + + /** + * EFFECT: Parse the given DER input. Time will be assumed to be in UTC. + * Throws {@link ParseException}: + * - The time is not in the string format specified in class specification + * - Other invalid input is found. See {@link ASN1Object} for more details on parsing + */ + public GeneralizedTime(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + } + + /** + * EFFECT: Parse the string into time, in the format specified in class specification. + * Throws {@link ParseException} if the input is malformed. + */ + @Override + public ZonedDateTime toDate(String str) throws ParseException { + try { + return ZonedDateTime.parse(str, formatter); + } catch (DateTimeParseException e) { + throw new ParseException(e.getMessage()); + } + } + + /** + * EFFECT: Convert the time into format "YYYYMMDDhhmm[ss]Z". + */ + @Override + public String toString() { + if (getTimestamp().getSecond() == 0) { + return getTimestamp().format(formatterNoSecs); + } + return getTimestamp().format(formatter); + } +} diff --git a/src/main/model/asn1/IA5String.java b/src/main/model/asn1/IA5String.java new file mode 100644 index 0000000..ea5cf91 --- /dev/null +++ b/src/main/model/asn1/IA5String.java @@ -0,0 +1,53 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Represents an ASN.1 IA5String type. It is a string that is restricted to ISO 646 / T.50 characters. + */ +public class IA5String extends ASN1String { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x16); + + /** + * EFFECTS: Constructs an IA5String with the given tag and string. + * Throws {@link ParseException} if the string is invalid. It must only contain T.50 chars. + * REQUIRES: For the requirements of tag and parentTag, consult {@link ASN1Object}. + */ + public IA5String(Tag tag, Tag parentTag, String string) throws ParseException { + super(tag, parentTag, string); + } + + /** + * EFFECTS: Parse from user input. Tags are parsed as-per {@link ASN1Object}. The value will be parsed as UTF-8 big + * endian. + * Throws {@link ParseException} if the encoded data is invalid: + * - Illegal string (containing non-T.50 chars) + * - Early EOF + * - Other cases in {@link ASN1Object} + * MODIFIES: this, encoded + */ + public IA5String(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + setString(new String(Utils.byteToByte(encoded.require(getLength(), true)), + StandardCharsets.UTF_8)); + } + + /** + * EFFECTS: Checks whether the given string only contains ISO 646 / T.50 chars. + */ + @Override + protected boolean validate(String newString) { + // Java doesn't have unsigned bytes - that is, bytes greater than 0x7F will + // overflow and become < 0. Thus, just compare b >= 0 will suffice. + return Arrays.stream(Utils.byteToByte(newString.getBytes(StandardCharsets.UTF_8))) + .noneMatch(b -> b < 0); + } +} diff --git a/src/main/model/asn1/Int.java b/src/main/model/asn1/Int.java new file mode 100644 index 0000000..5b75a73 --- /dev/null +++ b/src/main/model/asn1/Int.java @@ -0,0 +1,81 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import model.pki.cert.TbsCertificate; +import ui.Utils; + +import java.math.BigInteger; +import java.util.Arrays; + +/** + * An ASN.1 INTEGER type. By spec, it can be arbitrarily long. But just like it's impossible to have an + * endless tape in a turing machine, this implementation uses fixed length internally to represent ints. + */ +public class Int extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x2); + + private final BigInteger value; + + /** + * EFFECTS: Initiate the INTEGER object with the given tag and an optional context-specific tag number for explicit + * encoding. For more information, consult {@link ASN1Object}. + * REQUIRES: Consult {@link ASN1Object}. + */ + public Int(Tag tag, Tag parentTag, long value) { + super(tag, parentTag); + this.value = BigInteger.valueOf(value); + } + + /** + * EFFECTS: Parse input and get the int value. Tags are parsed in {@link ASN1Object}. + * Throws {@link ParseException} if encoded value are invalid: + * - Early EOF (not enough bytes) + * - Zero bytes length + * - Other issues denoted in {@link ASN1Object} + * MODIFIES: this, encoded + */ + public Int(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + if (getLength() == 0) { + throw new ParseException("Integer with zero length"); + } + this.value = new BigInteger(Utils.byteToByte(encoded.require(getLength(), true))); + } + + /** + * EFFECTS: Produce the big-endian two's complement encoding of the value, in the shortest possible way (i.e., no + * leading 0x0 bytes, and no leading 0xFF bytes if negative). Notes, if a positive number is desired (or mandated + * like {@link TbsCertificate#getSerialNumber()}, append 0x0 to the MSB manually. This method always results in a + * signed integer. For simplicity, the first 0x0 is always removed except when the number itself is 0, and others + * are kept. + */ + @Override + public Byte[] encodeValueDER() { + Byte[] bytes = Utils.byteToByte(value.toByteArray()); + if (bytes.length == 1) { + return bytes; + } + if (bytes[0] == 0x0) { + return Arrays.stream(bytes) + .skip(1) + .toArray(Byte[]::new); + } + return bytes; + } + + /** + * EFFECTS: Get the value in long. + * Throws {@link ArithmeticException} if the value is too large for long. + */ + public long getLong() throws ArithmeticException { + return value.longValueExact(); + } + + public BigInteger getValue() { + return value; + } +} diff --git a/src/main/model/asn1/Null.java b/src/main/model/asn1/Null.java new file mode 100644 index 0000000..019db85 --- /dev/null +++ b/src/main/model/asn1/Null.java @@ -0,0 +1,45 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +/** + * Represents the ASN.1 NULL type. It always has zero length. With the default assigned tag, it always encodes + * into 0x05 0x00. + */ +public class Null extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x5); + + /** + * EFFECTS: Initiate the NULL with the given tag and an optional context-specific tag number for explicit + * encoding. For more information, consult {@link ASN1Object}. NULL does not take any other arguments. + * REQUIRES: Consult {@link ASN1Object}. + */ + public Null(Tag tag, Tag parentTag) { + super(tag, parentTag); + } + + /** + * EFFECTS: Parse input bytes. For more information on tags parsing, consult {@link ASN1Object}. + * Throws {@link ParseException} if the input data is invalid: + * - The length is not 0 + * - Other cases as denoted in {@link ASN1Object} + */ + public Null(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + if (getLength() != 0) { + throw new ParseException("NULL must have zero length!"); + } + } + + /** + * EFFECTS: Always produce an empty array. + */ + @Override + public Byte[] encodeValueDER() { + return new Byte[0]; + } +} diff --git a/src/main/model/asn1/ObjectIdentifier.java b/src/main/model/asn1/ObjectIdentifier.java new file mode 100644 index 0000000..e2b9dfe --- /dev/null +++ b/src/main/model/asn1/ObjectIdentifier.java @@ -0,0 +1,204 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Represents an X.680 OID, which is a global-unique multi-component int value (with a registry managing all OIDs). + */ +public class ObjectIdentifier extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x6); + + public static final Integer[] OID_CN = new Integer[]{ 2, 5, 4, 3 }; + public static final Integer[] OID_SN = new Integer[]{ 2, 5, 4, 4 }; + public static final Integer[] OID_C = new Integer[]{ 2, 5, 4, 6 }; + public static final Integer[] OID_L = new Integer[]{ 2, 5, 4, 7 }; + public static final Integer[] OID_O = new Integer[]{ 2, 5, 4, 10 }; + public static final Integer[] OID_OU = new Integer[]{ 2, 5, 4, 11 }; + public static final Integer[] OID_DC = new Integer[]{ 0, 9, 2342, 19200300, 100, 1, 25 }; + + public static final Integer[] OID_EXTENSION_REQUEST = + new Integer[]{ 1, 2, 840, 113549, 1, 9, 14 }; + + public static final Integer[] OID_RSA_ENCRYPTION = + new Integer[]{ 1, 2, 840, 113549, 1, 1, 1 }; + public static final Integer[] OID_SHA256_WITH_RSA_ENCRYPTION = + new Integer[]{ 1, 2, 840, 113549, 1, 1, 11 }; + + public static final Integer[] OID_EC_PUBLIC_KEY = + new Integer[]{ 1, 2, 840, 10045, 2, 1 }; + public static final Integer[] OID_ECDSA_WITH_SHA256 = + new Integer[]{ 1, 2, 840, 10045, 4, 3, 2 }; + public static final Integer[] OID_ECDSA_WITH_SHA512 = + new Integer[]{ 1, 2, 840, 10045, 4, 3, 4 }; + public static final Integer[] OID_PRIME256_V1 = + new Integer[]{ 1, 2, 840, 10045, 3, 1, 7 }; + + public static final Integer[] OID_SUBJECT_KEY_IDENTIFIER = + new Integer[]{ 2, 5, 29, 14 }; + public static final Integer[] OID_KEY_USAGE = + new Integer[]{ 2, 5, 29, 15 }; + public static final Integer[] OID_BASIC_CONSTRAINTS = + new Integer[]{ 2, 5, 29, 19 }; + public static final Integer[] OID_AUTHORITY_KEY_IDENTIFIER = + new Integer[]{ 2, 5, 29, 35 }; + public static final Integer[] OID_CRL_DISTRIBUTION_POINTS = + new Integer[]{ 2, 5, 29, 31 }; + public static final Integer[] OID_AUTHORITY_INFO_ACCESS = + new Integer[]{ 1, 3, 6, 1, 5, 5, 7, 1, 1 }; + + public static final Integer[] OID_CURVED_25519 = + new Integer[]{ 1, 3, 101, 112 }; + + public static final Integer[] OID_CRL_REASON = + new Integer[]{ 2, 5, 29, 21 }; + + private final Integer[] ints; + + /** + * EFFECTS: Construct the OID object with the given array of OID numbers. For the tag and parentTag, + * consult {@link ASN1Object}. + * REQUIRES: The ints array must have at least two elements, and the first element must be 0, 1, or 2. + * If the first element is 0 or 1, the second element must be < 40. For the tag and parentTag, + * consult {@link ASN1Object}. + */ + public ObjectIdentifier(Tag tag, Tag parentTag, Integer[] ints) { + super(tag, parentTag); + this.ints = ints; + } + + /** + * EFFECTS: Parse the input DER. + * Throws {@link ParseException} if the input is invalid: + * - Zero bytes long + * - A multibyte integer is unterminated until the end of input + */ + public ObjectIdentifier(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + if (getLength() < 1) { + throw new ParseException("Invalid OID"); + } + List nums = new ArrayList<>(); + final Byte[] raw = encoded.require(getLength(), true); + Byte first = raw[0]; + if (first >= 80) { + nums.add(2); + nums.add(first - 80); + } else if (first >= 40) { + nums.add(1); + nums.add(first - 40); + } else { + nums.add(0); + nums.add((int) first); + } + List num = new ArrayList<>(); + for (int i = 1; i < raw.length; i++) { + Byte b = raw[i]; + num.add(BitSet.valueOf(new byte[]{ (byte) (b & 127) })); + if ((b & -128) == 0) { + BitSet bitSet = new BitSet(num.size() * 7); + int z = 0; + + for (int j = num.size() - 1; j >= 0; j--) { + for (int k = 0; k < 7; k++) { + bitSet.set(z++, num.get(j).get(k)); + } + } + + List bs1 = Arrays.asList(Utils.byteToByte(bitSet.toByteArray())); + Collections.reverse(bs1); + nums.add(new BigInteger(Utils.byteToByte(bs1.toArray(new Byte[0]))).intValueExact()); + num.clear(); + } + } + if (!num.isEmpty()) { + throw new ParseException("Unterminated byte. Currently " + + num.stream().map(BitSet::toByteArray).map(Utils::byteToByte) + .flatMap(Arrays::stream) + .map(b -> String.format("0x%02X", b)) + .collect(Collectors.toList())); + } + this.ints = nums.toArray(new Integer[0]); + } + + /** + * EFFECTS: Generate a human-readable output of that OID, in the format of 0.1.2. In case of a well-known OID, its + * name is returned. + */ + @Override + public String toString() { + if (Arrays.equals(ints, OID_CN)) { + return "CN"; + } else if (Arrays.equals(ints, OID_SN)) { + return "SN"; + } else if (Arrays.equals(ints, OID_C)) { + return "C"; + } else if (Arrays.equals(ints, OID_L)) { + return "L"; + } else if (Arrays.equals(ints, OID_O)) { + return "O"; + } else if (Arrays.equals(ints, OID_OU)) { + return "OU"; + } else if (Arrays.equals(ints, OID_DC)) { + return "DC"; + } + return Arrays.stream(ints) + .map(i -> Integer.toString(i)) + .collect(Collectors.joining(".")); + } + + /** + * EFFECTS: Encode the OID into DER bytes, following the DER rules as follows: + * - First two ints: first * 40 + second + * - Remaining: Int components are encoded as-is if they are <= 127. Otherwise, they are encoded into multiple 7bit + * bytes, with the MSB set on every byte except for the last (rightmost byte) of each component. + * - Integers are in big-endian. + */ + @Override + public Byte[] encodeValueDER() { + return Stream.of( + Arrays.asList(Utils.valToByte(ints[0] * 40 + ints[1])), + Stream.of(ints) + .skip(2) + .map(i -> { + BigInteger bi = BigInteger.valueOf(i); + List bs = Arrays.asList(Utils.byteToByte(bi.toByteArray())); + Collections.reverse(bs); + final BitSet bitSetOriginal = BitSet.valueOf(Utils.byteToByte(bs.toArray(new Byte[0]))); + BitSet bitSet = new BitSet(bs.size() * 16); + int k = 0; + for (int j = 0; j < bs.size() * 8; j++) { + if (j == 0 || j % 7 != 0) { + bitSet.set(k++, bitSetOriginal.get(j)); + } else { + bitSet.set(k++, j != 7); + bitSet.set(k++, bitSetOriginal.get(j)); + } + } + byte[] bs1 = bitSet.toByteArray(); + List res = + Arrays.asList(Utils.byteToByte(bs1)); + Collections.reverse(res); + return res; + }) + .flatMap(Collection::stream) + .collect(Collectors.toList()) + ).flatMap(Collection::stream) + .toArray(Byte[]::new); + } + + public Integer[] getInts() { + return ints; + } +} diff --git a/src/main/model/asn1/OctetString.java b/src/main/model/asn1/OctetString.java new file mode 100644 index 0000000..b552e52 --- /dev/null +++ b/src/main/model/asn1/OctetString.java @@ -0,0 +1,47 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +/** + * Represents the ASN.1 OCTET STRING type. An OCTET STRING is just a sequence of bytes. + */ +public class OctetString extends ASN1Object { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x4); + + private final Byte[] bytes; + + /** + * EFFECTS: Initiate the OctetString object with the given bytes array. The byte array can be arbitrary. + * REQUIRES: For the requirements of tag and parentTag, consult {@link ASN1Object}. + */ + public OctetString(Tag tag, Tag parentTag, Byte[] bytes) { + super(tag, parentTag); + this.bytes = bytes; + } + + /** + * EFFECTS: Parse tags and value from user input. Tags are parsed as-per {@link ASN1Object}. + * Throws {@link ParseException} if the encoded data is invalid, see {@link ASN1Object}. + * MODIFIES: this, encoded + */ + public OctetString(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + this.bytes = encoded.require(getLength(), true); + } + + /** + * EFFECTS: Get the value bytes ready to encode into DER. + */ + @Override + public Byte[] encodeValueDER() { + return bytes; + } + + public Byte[] getBytes() { + return bytes; + } +} \ No newline at end of file diff --git a/src/main/model/asn1/PrintableString.java b/src/main/model/asn1/PrintableString.java new file mode 100644 index 0000000..73e33a6 --- /dev/null +++ b/src/main/model/asn1/PrintableString.java @@ -0,0 +1,49 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.charset.StandardCharsets; + +/** + * An ASN.1 PrintableString that only allows ([a-z]|[A-Z]| |[0-9]|['()+,-./:=?])*. + */ +public class PrintableString extends ASN1String { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x13); + + /** + * EFFECTS: Constructs with the given string. + * Throws {@link ParseException} if the given string is illegal (contains chars out of the PrintableString set). + * REQUIRES: For the requirements of tag and parentTag, consult {@link ASN1Object}. + */ + public PrintableString(Tag tag, Tag parentTag, String rawString) throws ParseException { + super(tag, parentTag, rawString); + } + + /** + * EFFECTS: Parse from user input. Tags are parsed as-per {@link ASN1Object}. The value will be parsed as UTF-8 big + * endian. + * Throws {@link ParseException} if the encoded data is invalid: + * - Early EOF and other cases in {@link ASN1Object} + * - Illegal string: Contains non-printable chars + * MODIFIES: this, encoded + */ + public PrintableString(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + setString(new String(Utils.byteToByte(encoded.require(getLength(), true)), + StandardCharsets.UTF_8)); + } + + /** + * EFFECTS: Validate the given string against PrintableString spec. + * REQUIRES: newString != null + */ + @Override + protected boolean validate(String newString) { + return newString.matches("([a-z]|[A-Z]| |[0-9]|['()+,-./:=?])*"); + } +} diff --git a/src/main/model/asn1/Tag.java b/src/main/model/asn1/Tag.java new file mode 100644 index 0000000..15c144f --- /dev/null +++ b/src/main/model/asn1/Tag.java @@ -0,0 +1,109 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +/** + * Represents the metadata (tag) of an ASN.1 type. + */ +public class Tag implements Encodable { + private final TagClass cls; + private final boolean constructive; + private final int number; + + /** + * EFFECTS: Construct the ASN.1 tag with the given class, constructive / primitive, and number. + * REQUIRES: number > 0 (X.680$8.2) and number <= 31 (no high tag number support currently); + * the constructive or primitive value must follow the X.680 spec, and it must be primitive if it + * can be both constructive or primitive (according to X.690 DER). + */ + public Tag(TagClass cls, boolean constructive, int number) { + this.cls = cls; + this.constructive = constructive; + this.number = number; + } + + /** + * EFFECTS: Initialize the tag by parsing class / constructive / number from the encoded DER bytes. + * {@link ParseException} is thrown if the input is invalid: + * - The encoded array must have at least one byte. + * - The tag number is zero if the class is UNIVERSAL. + * REQUIRES: The highest two bits must contain the class, and then the constructive bit, and finally the low 5 bits + * must contain the tag number <= 31. + * MODIFIES: encoded (one byte read) + */ + public Tag(BytesReader encoded) throws ParseException { + final Byte val = encoded.require(1, true)[0]; + // Take the highest two bits + // -64 = 2's complement of 0b11000000 + final int highestTwo = val & -64; + if (highestTwo == TagClass.UNIVERSAL.getVal()) { + this.cls = TagClass.UNIVERSAL; + } else if (highestTwo == TagClass.APPLICATION.getVal()) { + this.cls = TagClass.APPLICATION; + } else if (highestTwo == TagClass.PRIVATE.getVal()) { + this.cls = TagClass.PRIVATE; + } else { + this.cls = TagClass.CONTEXT_SPECIFIC; + } + + // Detect the constructive bit by only keeping the corresponding bit and shifting it to the lowestbit. + this.constructive = (val & 0x20) >> 5 == 1; + + // Parse the number by clearing the high 3 bits. + this.number = val & 0x1F; + + if (this.cls == TagClass.UNIVERSAL && this.number == 0) { + throw new ParseException(String.format("The tag number must not be zero for UNIVERSAL tags" + + "(byte 0x%02X @ %d)", val, encoded.getIndex())); + } + } + + /** + * EFFECTS: Encode that tag as DER bytes, as follows: + * HI 7 6 | 5 | 4 3 2 1 0 LO + * Class | C/P | Tag Number + * Notes, In the domain of this application (PKI), a single byte is always returned + * (as nothing requires high tag number). However, the return type is held as byte[] + * to 1) compliant with the spec, 2) reserve for future scalability. + */ + @Override + public Byte[] encodeDER() { + // Fill the low 5bits with tag number + byte value = (byte) number; + // Fill the bit in-between with constructive bit + if (constructive) { + value |= 0x20; // 0b00100000: Enable the 5th bit + } else { + value &= 0xDF; // 0b11011111: Disable the 5th bit + } + // Fill the high two bits with tag class + value |= cls.getVal(); + return new Byte[] { value }; + } + + /** + * EFFECTS: Throw {@link ParseException} if this tag is not exactly the given tag. Useful for parsing. + */ + public void enforce(Tag tag) throws ParseException { + if (this.number != tag.number + || this.constructive != tag.constructive + || this.cls != tag.cls) { + throw new ParseException(String.format("Illegal tag: Expected %s[%d] but got %s[%d].", + tag.cls, tag.number, + cls, number)); + } + } + + public TagClass getCls() { + return cls; + } + + public boolean isConstructive() { + return constructive; + } + + public int getNumber() { + return number; + } +} diff --git a/src/main/model/asn1/TagClass.java b/src/main/model/asn1/TagClass.java new file mode 100644 index 0000000..83dd4e9 --- /dev/null +++ b/src/main/model/asn1/TagClass.java @@ -0,0 +1,42 @@ +package model.asn1; + +/** + * Represents the class (UNIVERSAL, APPLICATION, PRIVATE, CONTEXT-SPECIFIC) of an ASN.1 tag. See X.680$8.1. + * The purpose of UNIVERSAL, APPLICATION, PRIVATE, and CONTEXT_SPECIFIC can be found in X.680 spec. + * For example, UNIVERSAL means tags specified in the core ASN.1 spec. + * This class also represents the value to the two highest bits of DER-encoded tag values. + */ +public enum TagClass { + UNIVERSAL(Values.UNIVERSAL), + APPLICATION(Values.APPLICATION), + PRIVATE(Values.PRIVATE), + CONTEXT_SPECIFIC(Values.CONTENT_SPECIFIC); + + private final Byte val; + + /** + * EFFECT: Constructs the tag class with the given DER tag byte value. + * REQUIRES: The Byte value must have low 6bits cleared. + */ + TagClass(Byte val) { + this.val = val; + } + + public Byte getVal() { + return val; + } + + /** + * The constants of high-two-bit values for Tag DER encoding. + */ + public static final class Values { + // 0b00000000 + public static final Byte UNIVERSAL = 0x0; + // 0b01000000 + public static final Byte APPLICATION = 0x40; + // 0b11000000 + public static final Byte PRIVATE = -64; + // 0b10000000 + public static final Byte CONTENT_SPECIFIC = -128; + } +} diff --git a/src/main/model/asn1/UTF8String.java b/src/main/model/asn1/UTF8String.java new file mode 100644 index 0000000..e6b101e --- /dev/null +++ b/src/main/model/asn1/UTF8String.java @@ -0,0 +1,40 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; +import ui.Utils; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Represents an ASN.1 UTF8String type. It accepts any UTF-8 chars. Because UTF-8 character set is large and its chars + * have variable width, this implementation does not validate against legal UTF-8 characters. + */ +public class UTF8String extends ASN1String { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x0C); + + /** + * EFFECTS: Constructs a UTF8String with the given tag and string. + * Throws {@link ParseException} if the string is illegal. + * REQUIRES: For the requirements of tag and parentTag, consult {@link ASN1Object}. + */ + public UTF8String(Tag tag, Tag parentTag, String string) throws ParseException { + super(tag, parentTag, string); + } + + /** + * EFFECTS: Parse from user input. Tags are parsed as-per {@link ASN1Object}. The value will be parsed as UTF-8 big + * endian. + * Throws {@link ParseException} if the encoded data is invalid. + * MODIFIES: this, encoded + */ + public UTF8String(BytesReader encoded, boolean hasParentTag) throws ParseException, IllegalArgumentException { + super(encoded, hasParentTag); + setString(new String(Utils.byteToByte(encoded.require(getLength(), true)), + StandardCharsets.UTF_8)); + } +} diff --git a/src/main/model/asn1/UtcTime.java b/src/main/model/asn1/UtcTime.java new file mode 100644 index 0000000..3acf524 --- /dev/null +++ b/src/main/model/asn1/UtcTime.java @@ -0,0 +1,91 @@ +package model.asn1; + +import model.asn1.exceptions.ParseException; +import model.asn1.parsing.BytesReader; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; + +/** + * Represents the ASN.1 UTCTime type. It encodes the time in "YYMMDDhhmm[ss]Z" string format, in UTC. + * It is not named UTCTime to get around the checkstyle guidelines, but it is actually called UTCTime in X.680. + */ +public class UtcTime extends ASN1Time { + /** + * The X.680 universal class tag assignment. + */ + public static final Tag TAG = new Tag(TagClass.UNIVERSAL, false, 0x17); + + /** + * Rather stupid impl ... + */ + private static final DateTimeFormatter formatterNoSecs = new DateTimeFormatterBuilder() + .appendPattern("yy") + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral('Z') + .toFormatter() + .withZone(ZoneId.of("UTC")); + + private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder() + .appendPattern("yy") + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalEnd() + .appendLiteral('Z') + .toFormatter() + .withZone(ZoneId.of("UTC")); + + /** + * EFFECT: Construct the UTCTime with the given tag, parentTag, and timestamp. For tag and parentTag, + * consult {@link ASN1Object}. + * REQUIRES: timestamp must be in UTC. + */ + public UtcTime(Tag tag, Tag parentTag, ZonedDateTime timestamp) { + super(tag, parentTag, timestamp); + } + + /** + * EFFECT: Parse the given DER input. Time will be assumed to be in UTC. + * Throws {@link ParseException} if invalid: + * - The time is not in the string format specified in class specification + * - Other invalid input is found. See {@link ASN1Object} for more details on parsing + */ + public UtcTime(BytesReader encoded, boolean hasParentTag) throws ParseException { + super(encoded, hasParentTag); + } + + /** + * EFFECT: Parse the string into time, in the format specified in class specification. + * Throws {@link ParseException} if the input is malformed. + */ + @Override + public ZonedDateTime toDate(String str) throws ParseException { + try { + return ZonedDateTime.parse(str, formatter); + } catch (DateTimeParseException e) { + throw new ParseException(e.getMessage()); + } + } + + /** + * EFFECT: Convert the time into format "YYMMDDhhmm[ss]Z". + */ + @Override + public String toString() { + if (getTimestamp().getSecond() == 0) { + return getTimestamp().format(formatterNoSecs); + } + return getTimestamp().format(formatter); + } +} diff --git a/src/main/model/asn1/exceptions/ParseException.java b/src/main/model/asn1/exceptions/ParseException.java new file mode 100644 index 0000000..30533f6 --- /dev/null +++ b/src/main/model/asn1/exceptions/ParseException.java @@ -0,0 +1,10 @@ +package model.asn1.exceptions; + +/** + * Thrown when an invalid user DER input is supplied. + */ +public class ParseException extends Exception { + public ParseException(String message) { + super(message); + } +} diff --git a/src/main/model/asn1/parsing/BytesReader.java b/src/main/model/asn1/parsing/BytesReader.java new file mode 100644 index 0000000..3e11ea6 --- /dev/null +++ b/src/main/model/asn1/parsing/BytesReader.java @@ -0,0 +1,105 @@ +package model.asn1.parsing; + +import model.asn1.ASN1Length; +import model.asn1.Tag; +import model.asn1.exceptions.ParseException; + +/** + * A mutable model represents a one-way pipe of reading the input DER bytes. It keeps track of the total input and the + * current location, and it provides useful methods of requiring one or more bytes to present in the next. + */ +public class BytesReader { + private final Byte[] rawInput; + private int index; + + /** + * EFFECTS: Initialize the reader with the given input and index set to 0. + * REQUIRES: rawInput.length > 0 + */ + public BytesReader(Byte[] rawInput) { + this.rawInput = rawInput; + this.index = 0; + } + + /** + * EFFECTS: Calculate the number of bytes remaining to read. + */ + public int bytesRemaining() { + return rawInput.length - index; + } + + /** + * EFFECTS: Copy the given number of bytes from the [getIndex(), getIndex() + size) and optionally mark as read. + * MODIFIES: this (if markAsRead == true) + * REQUIRES: size <= bytesRemaining(), size > 0 + */ + public Byte[] read(int size, boolean markAsRead) { + Byte[] result = new Byte[size]; + System.arraycopy(rawInput, index, result, 0, size); + if (markAsRead) { + index += size; + } + return result; + } + + /** + * EFFECTS: Copy the given number of bytes from [getIndex(), getIndex() + size) and optionally mark as read. + * Throws {@link ParseException} if size > bytesRemaining(). + * MODIFIES: this (if markAsRead == true) + * REQUIRES: size > 0 + */ + public Byte[] require(int size, boolean markAsRead) throws ParseException { + validateSize(size); + return read(size, markAsRead); + } + + /** + * EFFECTS: Check if size <= bytesRemaining(). + * Throws {@link ParseException if not}. + * REQUIRES: size > 0 + */ + public void validateSize(int size) throws ParseException { + if (size > bytesRemaining()) { + throw new ParseException(String.format("%d required at location %d, but only has %d before EOF.", + size, + index, + bytesRemaining())); + } + } + + /** + * EFFECTS: Check if the next byte has the desired tag, without changing the index. + * Throws {@link ParseException} if the input is illegal (not even a tag or EOF). + */ + public boolean detectTag(Tag desired) throws ParseException { + final int i = index; + final Tag t = new Tag(this); + index = i; + return t.getCls() == desired.getCls() + && t.isConstructive() == desired.isConstructive() + && t.getNumber() == desired.getNumber(); + } + + /** + * EFFECTS: Get the current tag or the tag immediately following (inner) without changing the index. + * Throws {@link ParseException} if the input is illegal (not even a tag or EOF). + */ + public Tag getTag(boolean inner) throws ParseException { + final int i = index; + Tag t = new Tag(this); + if (inner) { + new ASN1Length(this); + t = new Tag(this); + } + index = i; + return t; + } + + public Byte[] getRawInput() { + return rawInput; + } + + public int getIndex() { + return index; + } +} -- cgit v1.2.3