diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java b/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java
new file mode 100644
index 00000000..bb39f423
--- /dev/null
+++ b/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java
@@ -0,0 +1,86 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2015 Richard "Shred" Körber
+ * http://acme4j.shredzone.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+package org.shredzone.acme4j.util;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Parses a timestamp as defined in RFC 3339.
+ *
+ * @see RFC 3339
+ * @author Richard "Shred" Körber
+ */
+public class TimestampParser {
+
+ private static final Pattern DATE_PATTERN = Pattern.compile(
+ "^(\\d{4})-(\\d{2})-(\\d{2})T"
+ + "(\\d{2}):(\\d{2}):(\\d{2})"
+ + "(?:\\.(\\d{1,3})\\d*)?"
+ + "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern TZ_PATTERN = Pattern.compile(
+ "([+-])(\\d{2}):?(\\d{2})$");
+
+ /**
+ * Parses a RFC 3339 formatted date.
+ *
+ * @param str
+ * Date string
+ * @return {@link Date} that was parsed
+ * @throws IllegalArgumentException
+ * if the date string was not RFC 3339 formatted
+ */
+ public static Date parse(String str) {
+ Matcher m = DATE_PATTERN.matcher(str);
+ if (!m.matches()) {
+ throw new IllegalArgumentException("Illegal date: " + str);
+ }
+
+ int year = Integer.parseInt(m.group(1));
+ int month = Integer.parseInt(m.group(2));
+ int dom = Integer.parseInt(m.group(3));
+ int hour = Integer.parseInt(m.group(4));
+ int minute = Integer.parseInt(m.group(5));
+ int second = Integer.parseInt(m.group(6));
+
+ String msStr = m.group(7);
+ if (msStr == null) {
+ msStr = "000";
+ } else {
+ while (msStr.length() < 3) {
+ msStr += '0';
+ }
+ }
+ int ms = Integer.parseInt(msStr);
+
+ String tz = m.group(8);
+ if ("Z".equalsIgnoreCase(tz)) {
+ tz = "GMT";
+ } else {
+ tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3");
+ }
+
+ Calendar cal = GregorianCalendar.getInstance(TimeZone.getTimeZone(tz));
+ cal.clear();
+ cal.set(year, month - 1, dom, hour, minute, second);
+ cal.set(Calendar.MILLISECOND, ms);
+ return cal.getTime();
+ }
+
+}
diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java
new file mode 100644
index 00000000..f5247544
--- /dev/null
+++ b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java
@@ -0,0 +1,163 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2015 Richard "Shred" Körber
+ * http://acme4j.shredzone.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+package org.shredzone.acme4j.util;
+
+import static org.junit.Assert.*;
+import static org.shredzone.acme4j.util.TimestampParser.parse;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link TimestampParser}.
+ *
+ * @author Richard "Shred" Körber
+ */
+public class TimestampParserTest {
+
+ /**
+ * Test valid strings.
+ */
+ @Test
+ public void testParser() {
+ assertThat(parse("2015-12-27T22:58:35.006769519Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.00676951Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.0067695Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.006769Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.00676Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.0067Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.006Z"), isDate(2015, 12, 27, 22, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.01Z"), isDate(2015, 12, 27, 22, 58, 35, 10));
+ assertThat(parse("2015-12-27T22:58:35.2Z"), isDate(2015, 12, 27, 22, 58, 35, 200));
+ assertThat(parse("2015-12-27T22:58:35Z"), isDate(2015, 12, 27, 22, 58, 35));
+ assertThat(parse("2015-12-27t22:58:35z"), isDate(2015, 12, 27, 22, 58, 35));
+
+ assertThat(parse("2015-12-27T22:58:35.006769519+02:00"), isDate(2015, 12, 27, 20, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.006+02:00"), isDate(2015, 12, 27, 20, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35+02:00"), isDate(2015, 12, 27, 20, 58, 35));
+
+ assertThat(parse("2015-12-27T21:58:35.006769519-02:00"), isDate(2015, 12, 27, 23, 58, 35, 6));
+ assertThat(parse("2015-12-27T21:58:35.006-02:00"), isDate(2015, 12, 27, 23, 58, 35, 6));
+ assertThat(parse("2015-12-27T21:58:35-02:00"), isDate(2015, 12, 27, 23, 58, 35));
+
+ assertThat(parse("2015-12-27T22:58:35.006769519+0200"), isDate(2015, 12, 27, 20, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35.006+0200"), isDate(2015, 12, 27, 20, 58, 35, 6));
+ assertThat(parse("2015-12-27T22:58:35+0200"), isDate(2015, 12, 27, 20, 58, 35));
+
+ assertThat(parse("2015-12-27T21:58:35.006769519-0200"), isDate(2015, 12, 27, 23, 58, 35, 6));
+ assertThat(parse("2015-12-27T21:58:35.006-0200"), isDate(2015, 12, 27, 23, 58, 35, 6));
+ assertThat(parse("2015-12-27T21:58:35-0200"), isDate(2015, 12, 27, 23, 58, 35));
+ }
+
+ /**
+ * Test invalid strings.
+ */
+ @Test
+ public void testInvalid() {
+ try {
+ parse("");
+ fail("accepted empty string");
+ } catch (IllegalArgumentException ex) {
+ // expected
+ }
+
+ try {
+ parse("abc");
+ fail("accepted nonsense string");
+ } catch (IllegalArgumentException ex) {
+ // expected
+ }
+
+ try {
+ parse("2015-12-27");
+ fail("accepted year only string");
+ } catch (IllegalArgumentException ex) {
+ // expected
+ }
+
+ try {
+ parse("2015-12-27T");
+ fail("accepted year only string");
+ } catch (IllegalArgumentException ex) {
+ // expected
+ }
+ }
+
+ /**
+ * Matches the given time.
+ */
+ private DateMatcher isDate(int year, int month, int dom, int hour, int minute, int second) {
+ return isDate(year, month, dom, hour, minute, second, 0);
+ }
+
+ /**
+ * Matches the given time and milliseconds.
+ */
+ private DateMatcher isDate(int year, int month, int dom, int hour, int minute, int second, int ms) {
+ Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ cal.clear();
+ cal.set(year, month - 1, dom, hour, minute, second);
+ cal.set(Calendar.MILLISECOND, ms);
+ return new DateMatcher(cal);
+ }
+
+ /**
+ * Date matcher that gives a readable output on mismatch.
+ */
+ private static class DateMatcher extends BaseMatcher {
+
+ private final Calendar cal;
+ private final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH);
+
+ public DateMatcher(Calendar cal) {
+ this.cal = cal;
+ sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+ }
+
+ @Override
+ public boolean matches(Object item) {
+ if (!(item instanceof Date)) {
+ return false;
+ }
+
+ Date date = (Date) item;
+ return date.equals(cal.getTime());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendValue(sdf.format(cal.getTime()));
+ }
+
+ @Override
+ public void describeMismatch(Object item, Description description) {
+ if (!(item instanceof Date)) {
+ description.appendText("is not a Date");
+ return;
+ }
+
+ Date date = (Date) item;
+ description.appendText("was ").appendValue(sdf.format(date));
+ }
+
+ }
+
+}