aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuuta Liang <yuutaw@student.cs.ubc.ca>2023-11-23 08:09:01 +0800
committerYuuta Liang <yuutaw@student.cs.ubc.ca>2023-11-23 08:09:01 +0800
commit65ea6c17a0c1348aa9ef4e158102ddf173936882 (patch)
tree7615366f76b6c94f46d8039aa20091f9ccd5609a
parentb94b18c133f06cb176d8aa8bb40a8e24918d9ed6 (diff)
downloadjca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar
jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar.gz
jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.tar.bz2
jca-65ea6c17a0c1348aa9ef4e158102ddf173936882.zip
Add GUI
Signed-off-by: Yuuta Liang <yuutaw@student.cs.ubc.ca>
-rw-r--r--README.md26
-rw-r--r--src/main/model/GroupObserver.java48
-rw-r--r--src/main/model/ObservedData.java47
-rw-r--r--src/main/model/Observer.java33
-rw-r--r--src/main/model/asn1/ObjectIdentifier.java16
-rw-r--r--src/main/model/ca/CertificationAuthority.java94
-rw-r--r--src/main/model/ca/Template.java2
-rw-r--r--src/main/model/x501/AttributeTypeAndValue.java9
-rw-r--r--src/main/model/x501/Name.java107
-rw-r--r--src/main/ui/IssueDialog.java110
-rw-r--r--src/main/ui/Main.java4
-rw-r--r--src/main/ui/MainUI.form111
-rw-r--r--src/main/ui/MainUI.java690
-rw-r--r--src/main/ui/RevokeDialog.java186
-rw-r--r--src/main/ui/TemplateEditDialog.java82
-rw-r--r--src/main/ui/widgets/CertEditDialog.java108
-rw-r--r--src/main/ui/widgets/CertTableModel.java129
-rw-r--r--src/main/ui/widgets/GCBuilder.java213
-rw-r--r--src/main/ui/widgets/LogTableModel.java79
-rw-r--r--src/main/ui/widgets/TemplateTableModel.java105
-rw-r--r--src/main/ui/widgets/UIUtils.java168
-rw-r--r--src/resources/deletetest.pngbin0 -> 1464 bytes
-rw-r--r--src/resources/disable.pngbin0 -> 831 bytes
-rw-r--r--src/resources/enable.pngbin0 -> 639 bytes
-rw-r--r--src/resources/export.pngbin0 -> 691 bytes
-rw-r--r--src/resources/new.pngbin0 -> 497 bytes
-rw-r--r--src/resources/open.pngbin0 -> 508 bytes
-rw-r--r--src/resources/publisher.pngbin0 -> 969 bytes
-rw-r--r--src/resources/saveall.pngbin0 -> 368 bytes
-rw-r--r--src/resources/verified.pngbin0 -> 1139 bytes
-rw-r--r--src/test/model/GroupObserverTest.java65
-rw-r--r--src/test/model/MockObserver.java41
-rw-r--r--src/test/model/ObservedDataTest.java44
-rw-r--r--src/test/model/asn1/ObjectIdentifierTest.java17
-rw-r--r--src/test/model/ca/CertificationAuthorityTest.java79
-rw-r--r--src/test/model/ca/TemplateTest.java3
-rw-r--r--src/test/model/x501/AttributeTypeAndValueTest.java4
-rw-r--r--src/test/model/x501/NameTest.java34
-rw-r--r--src/test/persistence/DecoderTest.java3
-rw-r--r--tests/Makefile3
40 files changed, 2494 insertions, 166 deletions
diff --git a/README.md b/README.md
index 9b4f359..1040e4b 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,32 @@ As a user, I want to be able to:
For example, the user can add arbitrary number of `Cert`'s into a `CA`
container, upon each successful sign.
+## Instructions
+
+1. Load / store to `./data/ca.json` by clicking on the Load / Store button on the top left. Signing certificates,
+ revoking certificates, publishing CRLs will automatically save the database. The unsaved changes are visible at the
+ bottom status line.
+2. The main operations are at the upper panel, from the toolbar until the audit logs pane. The lower panel shows audit
+ logs.
+3. Start by generating a RSA2048 private key, by clicking on the "Generate" button. Then, sign a CSR to be sent to the
+ upper level CA. Finally, get the signed certificate back and install it by clicking on the "Install" button.
+4. Define a template. Navigate to the "Templates" tab and click on "New". Set template name, subject (optional), and
+ validity period (in days). These settings will be applied when signing certificates. You can add arbitrary number of
+ templates to the database, don't forget to save.
+5. Before a template can be used, it must be enabled. Select a template and click on the "Enable" button on the top.
+ Optionally, disable or delete the templates no longer needed.
+6. Sign certificates. Navigate to the "Certs" tab and click on "Sign" button in the toolbar. Select the CSR. In the popup
+ dialog, you can select the template and apply certificate properties. When you are ready, click on the "Issue" button.
+ The CA will sign this certificate and add it to the database. The database will be automatically saved. You can view
+ your certificate from the list and export it.
+7. If a certificate has to be revoked ahead of validity period, select it from the list and click on the "Revoke" button
+ in the toolbar. From the dialog, confirm certificate subject and serial number, and select an appropriate reason. You
+ may also customize the revocation time, but it must be typed in ISO-8601 format with offset. After revocation, the list
+ automatically updates, and you can see the certificate you just revoked now has a red cross icon. Note that the database
+ automatically saves after revoking.
+8. Periodically publish CRLs. On the "CA" tab, click on the "CRL" button on the top right, and select a path for the CRL.
+ The database automatically saves after issuing a new CRL.
+
## Author
Yuuta Liang <yuutaw@student.ubc.ca>
diff --git a/src/main/model/GroupObserver.java b/src/main/model/GroupObserver.java
new file mode 100644
index 0000000..41a9aac
--- /dev/null
+++ b/src/main/model/GroupObserver.java
@@ -0,0 +1,48 @@
+package model;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A group of observers with different types registered.
+ */
+public class GroupObserver implements Observer {
+ /**
+ * The map. Because Java doesn't have dependent maps, they are left here as unchecked.
+ */
+ private final Map<Class, Observer> map = new HashMap<>();
+
+ /**
+ * EFFECTS: Register a data type with an observer. Override existing registrations with the same type.
+ * REQUIRES: cls != null, obs != null.
+ */
+ public <T> void register(Class<T> cls, Observer<T> obs) {
+ map.put(cls, obs);
+ }
+
+ /**
+ * EFFECTS: Notify the registered observer based on the type of 'o' with given arguments.
+ * Does nothing if no registration found. Subclasses are supported as long as a parent class observer is registered.
+ * Specific type match takes priority. Non-specific type match is not guaranteed to be the closest.
+ * REQUIRES: o != null, direction be DIRECTION_*, index >= 0 or == INDEX_NOT_IN_LIST.
+ */
+ @Override
+ public void accept(Object o, int direction, int i) {
+ Observer obs = map.get(o.getClass());
+ if (obs == null) {
+ Class supertype = map.keySet().stream().filter(clz -> clz.isInstance(o)).findFirst().orElse(null);
+ if (supertype == null) {
+ return;
+ }
+ obs = map.get(supertype);
+ }
+ obs.accept(o, direction, i);
+ }
+
+ /**
+ * EFFECTS: Count the observers.
+ */
+ public int getRegisteredObserverCount() {
+ return map.size();
+ }
+}
diff --git a/src/main/model/ObservedData.java b/src/main/model/ObservedData.java
new file mode 100644
index 0000000..5ad91a3
--- /dev/null
+++ b/src/main/model/ObservedData.java
@@ -0,0 +1,47 @@
+package model;
+
+/**
+ * A single observed data that notifies the observer once changed.
+ */
+public class ObservedData<T> {
+ /**
+ * The data.
+ */
+ private T data;
+
+ /**
+ * The observer.
+ */
+ private Observer<T> acceptor;
+
+ /**
+ * EFFECTS: Init with the given initial value and observer.
+ * REQUIRES: acceptor != null
+ */
+ public ObservedData(T initialValue, Observer<T> acceptor) {
+ this.data = initialValue;
+ this.acceptor = acceptor;
+ }
+
+ /**
+ * EFFECTS: Set the data and notify the observer with the new data and DIRECTION_CHANGE + INDEX_NOT_IN_LIST.
+ */
+ public void set(T data) {
+ this.data = data;
+ acceptor.accept(data, Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
+ }
+
+ /**
+ * EFFECTS: Get the data.
+ */
+ public T get() {
+ return data;
+ }
+
+ /**
+ * EFFECTS: Get the observer.
+ */
+ public Observer<T> getAcceptor() {
+ return acceptor;
+ }
+}
diff --git a/src/main/model/Observer.java b/src/main/model/Observer.java
new file mode 100644
index 0000000..df9cbc2
--- /dev/null
+++ b/src/main/model/Observer.java
@@ -0,0 +1,33 @@
+package model;
+
+/**
+ * Data observers for GUI data binding.
+ */
+@FunctionalInterface
+public interface Observer<T> {
+ /**
+ * The data is just added to a list. Index will be its index.
+ */
+ int DIRECTION_ADD = 0;
+
+ /**
+ * The data is just removed from a list. Index will be its previous index.
+ */
+ int DIRECTION_REMOVE = 1;
+
+ /**
+ * The data is modified, either or not in a list. Index will be either INDEX_NOT_IN_LIST or its index.
+ */
+ int DIRECTION_CHANGE = 2;
+
+ /**
+ * A special index representing that the data is not in a list.
+ */
+ int INDEX_NOT_IN_LIST = -1;
+
+ /**
+ * EFFECTS: Handle data change.
+ * REQUIRES: data != null, direction be DIRECTION_*, index >= 0 or == INDEX_NOT_IN_LIST.
+ */
+ void accept(T data, int direction, int index);
+}
diff --git a/src/main/model/asn1/ObjectIdentifier.java b/src/main/model/asn1/ObjectIdentifier.java
index f6e850a..64ade8e 100644
--- a/src/main/model/asn1/ObjectIdentifier.java
+++ b/src/main/model/asn1/ObjectIdentifier.java
@@ -105,6 +105,22 @@ public class ObjectIdentifier extends ASN1Object {
}
/**
+ * EFFECTS: Get OID from a known part, case insensitive. Currently known: C CN OU O L DC.
+ * Throws {@link ParseException} if the name is unsupported.
+ */
+ public static Integer[] getKnown(String name) throws ParseException {
+ switch (name.toUpperCase()) {
+ case "C": return OID_C;
+ case "CN": return OID_CN;
+ case "OU": return OID_OU;
+ case "O": return OID_O;
+ case "L": return OID_L;
+ case "DC": return OID_DC;
+ default: throw new ParseException("Unsupported DN part: " + name);
+ }
+ }
+
+ /**
* EFFECTS: Parse input OID bytes.
* REQUIRES: raw.length >= 1
*/
diff --git a/src/main/model/ca/CertificationAuthority.java b/src/main/model/ca/CertificationAuthority.java
index 038d209..5181f1a 100644
--- a/src/main/model/ca/CertificationAuthority.java
+++ b/src/main/model/ca/CertificationAuthority.java
@@ -1,5 +1,6 @@
package model.ca;
+import model.Observer;
import model.asn1.*;
import model.asn1.exceptions.InvalidCAException;
import model.asn1.exceptions.ParseException;
@@ -28,11 +29,12 @@ import java.security.spec.RSAPrivateKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.time.ZoneId;
import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Stream;
/**
- * Holds a CA private key, its certificate, signed / revoked list, template list, and logs list.
+ * Holds a CA private key, its certificate, signed / revoked list, template list, and logs list. Data can be observed.
*/
public class CertificationAuthority {
public static final int SERIAL_DEFAULT = 1;
@@ -83,7 +85,12 @@ public class CertificationAuthority {
private final String user;
/**
- * EFFECT: Init with the given parameters and user "yuuta".
+ * Data observers.
+ */
+ private final List<Observer> observers;
+
+ /**
+ * EFFECT: Init with the given parameters, user "yuuta", and no observers.
* Throws {@link NoSuchAlgorithmException} if the key is specified but RSA is not supported.
* Throws {@link InvalidKeySpecException} if the key specified is invalid.
* Throws {@link InvalidCAException} or {@link ParseException} if the CA specified is invalid.
@@ -111,11 +118,12 @@ public class CertificationAuthority {
this.templates = new ArrayList<>(templates);
this.logs = new ArrayList<>(logs);
this.user = "yuuta";
+ this.observers = new ArrayList<>();
}
/**
* EFFECT: Init with a null key and null certificate, empty signed, revoked template, and log list,
- * serial at SERIAL_DEFAULT, and user "yuuta".
+ * serial at SERIAL_DEFAULT, user "yuuta", and no observers.
*/
public CertificationAuthority() {
this.key = null;
@@ -127,10 +135,13 @@ public class CertificationAuthority {
this.templates = new ArrayList<>();
this.logs = new ArrayList<>();
this.user = "yuuta";
+ this.observers = new ArrayList<>();
}
/**
* EFFECTS: Generate a new RSA2048 private key. This action will be logged.
+ * Observers will be notified for (RSAPublicKey.class, DIRECTION_CHANGE, INDEX_NOT_IN_LIST).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: getPublicKey() is null (i.e., no private key had been installed)
* MODIFIES: this
*/
@@ -140,6 +151,7 @@ public class CertificationAuthority {
final KeyPair pair = gen.generateKeyPair();
this.key = (RSAPrivateKey) pair.getPrivate();
this.publicKey = (RSAPublicKey) pair.getPublic();
+ notif(getPublicKey(), Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
log("Generated CA private key.");
}
@@ -159,6 +171,8 @@ public class CertificationAuthority {
/**
* EFFECTS: Load the RSA private and public exponents. This action will be logged.
+ * Observers will be notified for (RSAPublicKey.class, DIRECTION_CHANGE, INDEX_NOT_IN_LIST).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* Throws {@link NoSuchAlgorithmException} if RSA is not available on the platform.
* Throws {@link InvalidKeySpecException} if the input is invalid.
* REQUIRES: getPublicKey() is null (i.e., no private key had been installed)
@@ -167,6 +181,7 @@ public class CertificationAuthority {
public void loadKey(BigInteger n, BigInteger p, BigInteger e)
throws NoSuchAlgorithmException, InvalidKeySpecException {
setKey(n, p, e);
+ notif(getPublicKey(), Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
log("Installed CA private key.");
}
@@ -252,6 +267,8 @@ public class CertificationAuthority {
* - The new certificate must have the same algorithm and public key as getPublicKey()
* - It must have basicConstraints { cA = TRUE }
* - It must contain key usage Digital Signature, Certificate Sign, CRL Sign
+ * Observers will be notified for (Certificate.class, DIRECTION_CHANGE, INDEX_NOT_IN_LIST).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* Throws {@link ParseException} if the cert has invalid extension values.
* This action will be logged.
* REQUIRES:
@@ -261,6 +278,7 @@ public class CertificationAuthority {
public void installCertificate(Certificate certificate) throws InvalidCAException, ParseException {
validateCertificate(certificate);
this.certificate = certificate;
+ notif(certificate, Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
log("CA certificate is installed.");
}
@@ -325,6 +343,7 @@ public class CertificationAuthority {
/**
* EFFECT: Generate CSR and sign it, so the CA can request itself a certificate.
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: The CA cert must not be installed.
* MODIFIES: this (This action will be logged)
*/
@@ -351,6 +370,8 @@ public class CertificationAuthority {
/**
* EFFECTS: Sign the CSR based on the template.
+ * Observers will be notified for (Certificate.class, DIRECTION_ADD, i).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: The CA cert must be installed first, req must have a subject, template must be enabled.
* MODIFIES: this
*/
@@ -363,7 +384,8 @@ public class CertificationAuthority {
new BitString(BitString.TAG, null, 0,
signBytes(newCert.encodeValueDER())));
this.signed.add(cert);
- log("Signed a cert with serial number " + cert.getCertificate().getSerialNumber());
+ notif(cert, Observer.DIRECTION_ADD, this.signed.size() - 1);
+ log("Signed a cert with serial number " + cert.getCertificate().getSerialNumber().getLong());
return cert;
}
@@ -406,17 +428,22 @@ public class CertificationAuthority {
/**
* EFFECTS: Add the revocation info to revoked list. This action will be logged.
+ * Observers will be notified for (RevokedCertificate.class, DIRECTION_ADD, i).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: revoked should have the serial of an issued certificate; its date should be current.
* MODIFIES: this
*/
public void revoke(RevokedCertificate rev) {
revoked.add(rev);
- log("Certificate " + rev.getSerialNumber().getLong() + " is revoked with reason " + rev.getReason());
+ notif(rev, Observer.DIRECTION_ADD, revoked.size() - 1);
+ log("Certificate " + rev.getSerialNumber().getLong() + " is revoked with reason " + rev.getReason()
+ + " at " + rev.getRevocationDate().getTimestamp().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
/**
* EFFECTS: Generate and sign the CRL, based on getRevokedCerts(). The CSR will have current time as thisUpdate with
* no nextUptime, and it will have issuer same as the CA's subject.
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: The CA cert must be installed first.
* MODIFIES: this (This action will be logged)
*/
@@ -439,10 +466,29 @@ public class CertificationAuthority {
/**
* EFFECTS: Log the action with the current date and user.
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* MODIFIES: this
*/
private void log(String message) {
- this.logs.add(new AuditLogEntry(user, ZonedDateTime.now(), message));
+ final AuditLogEntry i = new AuditLogEntry(user, ZonedDateTime.now(), message);
+ this.logs.add(i);
+ notif(i, Observer.DIRECTION_ADD, logs.size() - 1);
+ }
+
+ /**
+ * EFFECTS: Register the given observer, so it will be called upon changes.
+ * MODIFIES: this
+ */
+ public void registerObserver(final Observer<?> observer) {
+ this.observers.add(observer);
+ }
+
+ /**
+ * EFFECTS: Notify the observers.
+ * REQUIRES: direction must be valid Observer constants, i must be either >= 0 or Observer.INDEX_NOT_IN_LIST.
+ */
+ private void notif(Object o, int direction, int i) {
+ observers.forEach(e -> e.accept(o, direction, i));
}
/**
@@ -460,33 +506,43 @@ public class CertificationAuthority {
/**
* EFFECTS: Install the new template. This action will be logged.
+ * Observers will be notified for (Template.class, DIRECTION_ADD, i).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: findTemplate(template.getName(), false) == null
* MODIFIES: this
*/
public void addTemplate(Template template) {
this.templates.add(template);
+ notif(template, Observer.DIRECTION_ADD, templates.size() - 1);
log("Added a new template: " + template.getName());
}
/**
- * EFFECTS: Set the given template to enabled / disabled. This action will be logged.
+ * EFFECTS: Set the given template to enabled / disabled, order will be kept. This action will be logged.
+ * Observers will be notified for (Template.class, DIRECTION_CHANGE, i).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: the template is valid (findTemplate does not return null)
* MODIFIES: this
*/
public void setTemplateEnable(Template template, boolean enable) {
final Template t = findTemplate(template.getName(), false);
- templates.remove(t);
- templates.add(new Template(t.getName(), enable, t.getSubject(), t.getValidity()));
+ int i = templates.indexOf(t);
+ templates.set(i, new Template(t.getName(), enable, t.getSubject(), t.getValidity()));
+ notif(template, Observer.DIRECTION_CHANGE, i);
log("Template " + template.getName() + " has been " + (enable ? "enabled" : "disabled"));
}
/**
* EFFECTS: Remove the given template. This action will be logged.
+ * Observers will be notified for (Template.class, DIRECTION_REMOVE, i).
+ * Observers will be notified for (AuditLogEntry.class, DIRECTION_ADD, i).
* REQUIRES: the template is valid (findTemplate does not return null)
* MODIFIES: this
*/
public void removeTemplate(Template template) {
+ int i = templates.indexOf(template);
templates.remove(findTemplate(template.getName(), false));
+ notif(template, Observer.DIRECTION_REMOVE, i);
log("Template " + template.getName() + " is removed");
}
@@ -496,40 +552,28 @@ public class CertificationAuthority {
return certificate;
}
- /**
- * EFFECT: Get a read-only view of the signed certificates.
- */
public List<Certificate> getSigned() {
- return List.copyOf(signed);
+ return signed;
}
- /**
- * EFFECT: Get a read-only view of the revoked certificates.
- */
public List<RevokedCertificate> getRevoked() {
- return List.copyOf(revoked);
+ return revoked;
}
public int getSerial() {
return serial;
}
- /**
- * EFFECT: Get a read-only view of the templates.
- */
public List<Template> getTemplates() {
- return List.copyOf(templates);
+ return templates;
}
public String getUser() {
return user;
}
- /**
- * EFFECT: Get a read-only view of the logs.
- */
public List<AuditLogEntry> getLogs() {
- return List.copyOf(logs);
+ return logs;
}
public RSAPublicKey getPublicKey() {
diff --git a/src/main/model/ca/Template.java b/src/main/model/ca/Template.java
index 84e639e..bc64562 100644
--- a/src/main/model/ca/Template.java
+++ b/src/main/model/ca/Template.java
@@ -41,7 +41,7 @@ public class Template {
boolean enabled,
String commonName,
long validity) throws ParseException {
- this(name, enabled, parseString(commonName), validity);
+ this(name, enabled, commonName == null ? null : parseString(commonName), validity);
}
/**
diff --git a/src/main/model/x501/AttributeTypeAndValue.java b/src/main/model/x501/AttributeTypeAndValue.java
index 54b3352..179d6ff 100644
--- a/src/main/model/x501/AttributeTypeAndValue.java
+++ b/src/main/model/x501/AttributeTypeAndValue.java
@@ -72,11 +72,16 @@ public class AttributeTypeAndValue extends ASN1Object {
/**
* EFFECTS: Return in TYPE=Value format. Type will be either x.x.x.x.x or human-readable strings like CN. Value is
- * input-defined.
+ * input-defined. ',' '+' '=' will be escaped.
*/
@Override
public String toString() {
- return type.toString() + "=" + value.toString();
+ return type.toString().replace(",", "\\,")
+ .replace("=", "\\=")
+ .replace("+", "\\+")
+ + "=" + value.toString().replace(",", "\\,")
+ .replace("=", "\\=")
+ .replace("+", "\\+");
}
public ObjectIdentifier getType() {
diff --git a/src/main/model/x501/Name.java b/src/main/model/x501/Name.java
index 19cde56..7477005 100644
--- a/src/main/model/x501/Name.java
+++ b/src/main/model/x501/Name.java
@@ -1,8 +1,6 @@
package model.x501;
-import model.asn1.ASN1Object;
-import model.asn1.Encodable;
-import model.asn1.Tag;
+import model.asn1.*;
import model.asn1.exceptions.ParseException;
import model.asn1.parsing.BytesReader;
@@ -53,6 +51,109 @@ public class Name extends ASN1Object {
}
/**
+ * EFFECTS: Parse OID after last KV and clear context if input is '='. Otherwise add to context.
+ * Throws {@link ParseException} if input is '+' or ',', or if the oid cannot be recognized.
+ * MODIFIES: context
+ */
+ private static ObjectIdentifier handleKey(char c, List<Character> context) throws ParseException {
+ if (c == '=') {
+ if (context.isEmpty()) {
+ throw new ParseException("Unterminated key");
+ }
+ final ObjectIdentifier oid = new ObjectIdentifier(ObjectIdentifier.TAG, null,
+ ObjectIdentifier.getKnown(
+ context.stream().map(Object::toString).collect(Collectors.joining(""))));
+ context.clear();
+ return oid;
+ } else if (c == '+' || c == ',') {
+ throw new ParseException("Unterminated key part: " + context);
+ } else {
+ context.add(c);
+ return null;
+ }
+ }
+
+ /**
+ * EFFECTS: Parse KV after '='. Clear context.
+ * Throws {@link ParseException} if context is empty.
+ * MODIFIES: context
+ * REQUIRES: curKey to be a valid OID
+ */
+ private static AttributeTypeAndValue flushKV(ObjectIdentifier curKey, List<Character> context)
+ throws ParseException {
+ if (context.isEmpty()) {
+ throw new ParseException("Unterminated value");
+ }
+ final AttributeTypeAndValue tv = new AttributeTypeAndValue(ASN1Object.TAG_SEQUENCE, null, curKey,
+ new PrintableString(PrintableString.TAG, null,
+ context.stream().map(Object::toString).collect(Collectors.joining(""))));
+ context.clear();
+ return tv;
+ }
+
+ /**
+ * EFFECTS: Handle value after =, optionally flush to rdns after ',', or add to curListKT if after '+'. Clears
+ * context if flushed, otherwise add to context. Returns whether switch to the state of reading key.
+ * Throws {@link ParseException} if c is '=' or context is empty.
+ * MODIFIES: context, curListKT, rdns
+ * REQUIRES: curKey to be a valid OID
+ */
+ private static boolean handleValue(char c, List<Character> context, List<AttributeTypeAndValue> curListKT,
+ ObjectIdentifier curKey, List<RelativeDistinguishedName> rdns)
+ throws ParseException {
+ if (c == ',') {
+ if (context.isEmpty()) {
+ throw new ParseException("Unterminated value");
+ }
+ curListKT.add(flushKV(curKey, context));
+ rdns.add(new RelativeDistinguishedName(ASN1Object.TAG_SET, null,
+ curListKT.toArray(AttributeTypeAndValue[]::new)));
+ curListKT.clear();
+ return true;
+ } else if (c == '+') {
+ curListKT.add(flushKV(curKey, context));
+ return true;
+ } else if (c == '=') {
+ throw new ParseException("Unterminated value part: " + context);
+ } else {
+ context.add(c);
+ return false;
+ }
+ }
+
+ /**
+ * EFFECTS: Parse the given DN string into structural X.509 RDN Sequence.
+ * Character literals = + , must be escaped.
+ * Values will always be PrintableString.
+ * Throws {@link ParseException} if invalid.
+ */
+ public static Name parseString(String dn) throws ParseException {
+ char state = 0; // 0 - Key, 1 - Value; MSB: Escaped
+ List<RelativeDistinguishedName> rdns = new ArrayList<>();
+ List<AttributeTypeAndValue> curListKT = new ArrayList<>();
+ ObjectIdentifier curKey = null;
+ List<Character> context = new ArrayList<>();
+ for (char c : (dn + ",").toCharArray()) {
+ if ((state >> 7) == 1) {
+ context.add(c);
+ state &= 127;
+ continue;
+ } else if (c == '\\') {
+ state |= 128;
+ continue;
+ }
+ if (state == 0) {
+ if ((curKey = handleKey(c, context)) != null) {
+ state = 1;
+ }
+ } else if (handleValue(c, context, curListKT, curKey, rdns)) {
+ state = 0;
+ }
+ }
+ return new Name(ASN1Object.TAG_SEQUENCE, null, rdns.toArray(RelativeDistinguishedName[]::new));
+ }
+
+ /**
* EFFECTS: Encode the SEQUENCE OF into DER, keep order. RDNs will be encoded one-by-one.
*/
@Override
diff --git a/src/main/ui/IssueDialog.java b/src/main/ui/IssueDialog.java
new file mode 100644
index 0000000..905b8df
--- /dev/null
+++ b/src/main/ui/IssueDialog.java
@@ -0,0 +1,110 @@
+package ui;
+
+import model.asn1.exceptions.ParseException;
+import model.ca.Template;
+import model.csr.CertificationRequest;
+import model.x501.Name;
+import ui.widgets.CertEditDialog;
+
+import javax.swing.*;
+import java.awt.event.ActionEvent;
+import java.util.List;
+
+import static ui.widgets.UIUtils.alert;
+
+/**
+ * Dialog that allows user to choose a template and edit the incoming CSR properties to get it signed.
+ * ┌───────────────────────────┐
+ * │ Issue new certificate X │
+ * │ │
+ * │Template: (Drop down)│
+ * │Subject: _________ │
+ * │Validity (Days): (Spinner)│
+ * │ │
+ * │ Issue Cancel│
+ * └───────────────────────────┘
+ */
+public class IssueDialog extends CertEditDialog<Template> {
+ /**
+ * The list of templates.
+ */
+ private final List<Template> templates;
+
+ /**
+ * The incoming CSR.
+ */
+ private final CertificationRequest csr;
+
+ /**
+ * Combo box to choose template.
+ */
+ private JComboBox<String> componentTemp;
+
+ /**
+ * The selected template, immutable.
+ */
+ private Template selectedTemplate;
+
+ /**
+ * EFFECTS: Init the dialog with CSR and templates.
+ * REQUIRES: csr must have a subject; templates must have at least one enabled.
+ */
+ public IssueDialog(CertificationRequest csr, List<Template> templates) {
+ super();
+ this.csr = csr;
+ this.templates = templates;
+
+ setTitle("Issue new certificate");
+ buttonOK.setText("Issue");
+ componentTemp.setModel(new DefaultComboBoxModel<>(templates.stream()
+ .filter(Template::isEnabled).map(Template::getName).toArray(String[]::new)));
+ componentTemp.addActionListener(this::onTemplateChange);
+
+ onTemplateChange(null);
+ pack();
+ }
+
+ /**
+ * EFFECTS: Create the templates combo box.
+ * MODIFIES: this
+ */
+ @Override
+ protected JComponent createTemplateComponent() {
+ return componentTemp = new JComboBox<>();
+ }
+
+ /**
+ * EFFECTS: Validate the form, compose a resulting template, close the dialog.
+ * MODIFIES: this
+ */
+ @Override
+ protected void onOK(ActionEvent ev) {
+ if (textFieldSubject.getText().isEmpty()) {
+ alert(this, getTitle(), "Subject must not be empty.");
+ return;
+ }
+ try {
+ res = new Template(selectedTemplate.getName(),
+ true,
+ Name.parseString(textFieldSubject.getText()),
+ ((SpinnerNumberModel) spinnerValidity.getModel()).getNumber().longValue());
+ dispose();
+ } catch (ParseException e) {
+ alert(this, getTitle(), e);
+ }
+ }
+
+ /**
+ * EFFECTS: Handle template change, rewrite the form with the new template.
+ * MODIFIES: this
+ */
+ private void onTemplateChange(ActionEvent ae) {
+ selectedTemplate = templates.stream()
+ .filter(temp -> temp.getName().equals(componentTemp.getSelectedItem()))
+ .findFirst()
+ .get();
+ textFieldSubject.setText(selectedTemplate.getSubject() != null ? selectedTemplate.getSubject().toString() :
+ csr.getCertificationRequestInfo().getSubject().toString());
+ spinnerValidity.setValue(selectedTemplate.getValidity());
+ }
+}
diff --git a/src/main/ui/Main.java b/src/main/ui/Main.java
index 89a0fe3..e3d8d7d 100644
--- a/src/main/ui/Main.java
+++ b/src/main/ui/Main.java
@@ -7,6 +7,8 @@ import javax.swing.*;
public class Main {
public static void main(String[] args) throws Throwable {
FlatIntelliJLaf.setup();
- new MainUI().createWindow();
+ final JFrame frame = new MainUI();
+ frame.pack();
+ frame.setVisible(true);
}
}
diff --git a/src/main/ui/MainUI.form b/src/main/ui/MainUI.form
deleted file mode 100644
index 9ab1492..0000000
--- a/src/main/ui/MainUI.form
+++ /dev/null
@@ -1,111 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<form xmlns="http://www.intellij.com/uidesigner/form/" version="1" bind-to-class="ui.MainUI">
- <grid id="27dc6" binding="rootPanel" layout-manager="GridLayoutManager" row-count="3" column-count="1" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
- <margin top="0" left="0" bottom="0" right="0"/>
- <constraints>
- <xy x="20" y="20" width="500" height="400"/>
- </constraints>
- <properties/>
- <border type="none"/>
- <children>
- <splitpane id="efbf3">
- <constraints>
- <grid row="1" column="0" row-span="1" col-span="1" vsize-policy="3" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false">
- <preferred-size width="200" height="200"/>
- </grid>
- </constraints>
- <properties>
- <orientation value="0"/>
- </properties>
- <border type="none"/>
- <children>
- <tabbedpane id="4cbb0" binding="tabbedPane">
- <constraints>
- <splitpane position="left"/>
- </constraints>
- <properties/>
- <border type="none"/>
- <children>
- <grid id="e83bf" layout-manager="GridLayoutManager" row-count="1" column-count="1" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
- <margin top="0" left="0" bottom="0" right="0"/>
- <constraints>
- <tabbedpane title="CA"/>
- </constraints>
- <properties/>
- <border type="none"/>
- <children/>
- </grid>
- <grid id="7d4f3" layout-manager="GridLayoutManager" row-count="1" column-count="1" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
- <margin top="0" left="0" bottom="0" right="0"/>
- <constraints>
- <tabbedpane title="Certificates"/>
- </constraints>
- <properties/>
- <border type="none"/>
- <children>
- <component id="d436b" class="javax.swing.JList" binding="listCertificates">
- <constraints>
- <grid row="0" column="0" row-span="1" col-span="1" vsize-policy="6" hsize-policy="2" anchor="0" fill="3" indent="0" use-parent-layout="false">
- <preferred-size width="150" height="50"/>
- </grid>
- </constraints>
- <properties/>
- </component>
- </children>
- </grid>
- <grid id="5a23a" layout-manager="GridLayoutManager" row-count="1" column-count="1" same-size-horizontally="false" same-size-vertically="false" hgap="-1" vgap="-1">
- <margin top="0" left="0" bottom="0" right="0"/>
- <constraints>
- <tabbedpane title="Templates"/>
- </constraints>
- <properties/>
- <border type="none"/>
- <children>
- <component id="1f307" class="javax.swing.JList" binding="listTemplates">
- <constraints>
- <grid row="0" column="0" row-span="1" col-span="1" vsize-policy="6" hsize-policy="2" anchor="0" fill="3" indent="0" use-parent-layout="false">
- <preferred-size width="150" height="50"/>
- </grid>
- </constraints>
- <properties/>
- </component>
- </children>
- </grid>
- </children>
- </tabbedpane>
- <component id="e63cf" class="javax.swing.JList" binding="listLogs">
- <constraints>
- <splitpane position="right"/>
- </constraints>
- <properties/>
- </component>
- </children>
- </splitpane>
- <toolbar id="a2d1e">
- <constraints>
- <grid row="0" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="6" anchor="0" fill="1" indent="0" use-parent-layout="false">
- <preferred-size width="-1" height="20"/>
- </grid>
- </constraints>
- <properties/>
- <border type="none"/>
- <children>
- <component id="5ae86" class="javax.swing.JButton" binding="buttonSave">
- <constraints/>
- <properties>
- <text value="Save"/>
- </properties>
- </component>
- </children>
- </toolbar>
- <component id="54c37" class="javax.swing.JLabel" binding="labelStatus">
- <constraints>
- <grid row="2" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="0" anchor="0" fill="1" indent="0" use-parent-layout="false"/>
- </constraints>
- <properties>
- <text value="Ready"/>
- </properties>
- </component>
- </children>
- </grid>
-</form>
diff --git a/src/main/ui/MainUI.java b/src/main/ui/MainUI.java
index ee35a53..4735c55 100644
--- a/src/main/ui/MainUI.java
+++ b/src/main/ui/MainUI.java
@@ -1,22 +1,680 @@
package ui;
+import model.GroupObserver;
+import model.ObservedData;
+import model.Observer;
+import model.asn1.exceptions.InvalidCAException;
+import model.asn1.exceptions.InvalidDBException;
+import model.asn1.exceptions.ParseException;
+import model.asn1.parsing.BytesReader;
+import model.ca.AuditLogEntry;
+import model.ca.CertificationAuthority;
+import model.ca.Template;
+import model.csr.CertificationRequest;
+import model.pki.cert.Certificate;
+import model.pki.crl.CertificateList;
+import model.pki.crl.RevokedCertificate;
+import persistence.Decoder;
+import persistence.FS;
+import ui.widgets.*;
+
import javax.swing.*;
+import javax.swing.event.ChangeEvent;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SignatureException;
+import java.security.interfaces.RSAPublicKey;
+
+import static java.awt.GridBagConstraints.*;
+import static ui.widgets.UIUtils.*;
+
+/**
+ * The main GUI.
+ * ┌──────────────────────────────┐┌──────────────────────────────┐┌──────────────────────────────┐
+ * │ JCA X ││ JCA X ││ JCA X │
+ * ├──────────────────────────────┤├──────────────────────────────┤├──────────────────────────────┤
+ * │Load Save CSR││Load Save Sign Revoke Export││Load Save New Ena Dis Del│
+ * ├────┬───────┬───────────┬─────┤├────┬───────┬──────────┬──────┤├────┬───────┬─────────────────┤
+ * │ CA │ Certs │ Templates │ ││ CA │ Certs │ Templates│ ││ CA │ Certs │ Templates │
+ * ├────┴───────┴───────────┴─────┤├─┬──┴─────┬─┴────┬─────┼──────┤├─┬──┴───┬───┴─────┬───────────┤
+ * │ Welcome to JCA ││ │ Serial │ Subj │ Bef │ To ││ │ Name │ Subj │ Validity │
+ * │ │├─┴────────┴──────┴─────┴──────┤├─┴──────┴─────────┴───────────┤
+ * │ CA certificate: Install CSR ││ (Issued Certs) ││ (All Templates) │
+ * ├──────────────────────────────┤├──────────────────────────────┤├──────────────────────────────┤
+ * ├─────┼──────────┼─────────────┤├─────┼──────────┼─────────────┤├─────┼──────────┼─────────────┤
+ * │Time │ Operator │ Action ││Time │ Operator │ Action ││Time │ Operator │ Action │
+ * ├─────┴──────────┴─────────────┤├─────┴──────────┴─────────────┤├─────┴──────────┴─────────────┤
+ * │ (Audit Logs) ││ (Audit Logs) ││ (Audit Logs) │
+ * ├──────────────────────────────┤├──────────────────────────────┤├──────────────────────────────┤
+ * │ Ready: (Last operation) ││ Unsaved: (Last operation) ││ Ready: (Last Operation) │
+ * └──────────────────────────────┘└──────────────────────────────┘└──────────────────────────────┘
+ */
+public class MainUI extends JFrame {
+ /**
+ * Default db file (./data/ca.json)
+ */
+ private static final Path PATH_DEFAULT = Path.of("data", "ca.json");
+
+ /**
+ * The root panel (Box layout).
+ */
+ private final JPanel rootPanel = new JPanel(new BoxLayout(rootPane, BoxLayout.PAGE_AXIS));
+
+ /**
+ * Common toolbar buttons
+ */
+ private final JButton buttonToolbarLoad = btn("Load", "open.png", this::onLoad);
+ private final JButton buttonToolbarSave = btn("Save", "saveall.png", this::onSave);
+
+ /**
+ * Toolbar that switches with the tab.
+ */
+ private final JPanel panelContextAwareToolbar = new JPanel(new CardLayout(0, 0));
+
+ /**
+ * Tab root.
+ */
+ private final JTabbedPane tabbedPane = new JTabbedPane();
+
+ /**
+ * CA tab
+ */
+ private final JLabel labelCACertificate = new JLabel();
+ private final JLabel labelPrivateKey = new JLabel();
+ private final JButton buttonGenPrivKey = btn("Generate", 'G', this::onGeneratePrivateKey);
+ private final JButton buttonInstallCA = btn("Install", 'I', this::onInstallCA);
+ private final JButton buttonGenCSR = btn("CSR", this::onSignCSR);
+ private final JToolBar toolbarCA = new JToolBar();
+ private final JButton buttonCAToolbarCRL = btn("CRL", "publisher.png", this::onCRL);
+
+ /**
+ * Certs tab
+ */
+ private JPanel panelCertsTab;
+ private final JTable tableCerts = new JTable();
+ private final CertTableModel modelCerts = new CertTableModel();
+ private final JToolBar toolbarCerts = new JToolBar();
+ private final JButton buttonCertsToolbarNew = btn("Sign", "new.png", this::onIssue);
+ private final JButton buttonCertsToolbarRevoke = btn("Revoke", "deletetest.png", this::onRevokeCert);
+ private final JButton buttonCertsToolbarExport = btn("Export", "export.png", this::onExportCert);
+
+ /**
+ * Templates tab
+ */
+ private JPanel panelTmpTab;
+ private final JTable tableTemplates = new JTable();
+ private final TemplateTableModel modelTemplates = new TemplateTableModel();
+ private final JToolBar toolbarTemplates = new JToolBar();
+ private final JButton buttonTemplatesToolbarNew = btn("New", "new.png", this::onNewTemplate);
+ private final JButton buttonTemplatesToolbarEnable = btn("Enable", "enable.png", this::onEnableTemplate);
+ private final JButton buttonTemplatesToolbarDisable = btn("Disable", "disable.png", this::onDisableTemplate);
+ private final JButton buttonTemplatesToolbarDelete = btn("Delete", "deletetest.png", this::onDeleteTemplate);
+
+ /**
+ * Logs region
+ */
+ private final JPanel panelLogs;
+ private final JTable tableAuditLogs = new JTable();
+ private final LogTableModel modelAuditLogs = new LogTableModel();
+
+ /**
+ * Status region
+ */
+ private final JLabel labelStatus = new JLabel();
+ private ObservedData<Boolean> unsaved = new ObservedData<>(false, this::acceptUnsaved);
+
+ /**
+ * CA and observers
+ */
+ private CertificationAuthority ca;
+
+ private final GroupObserver obs = new GroupObserver();
+
+ /**
+ * EFFECTS: Setup the CA and GUI.
+ */
+ public MainUI() {
+ setTitle("JCA");
+ setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+
+ setContentPane(rootPanel);
+ rootPanel.setLayout(new BoxLayout(rootPanel, BoxLayout.PAGE_AXIS));
+
+ rootPanel.add(setupToolbar());
+ final JSplitPane splitPane = new JSplitPane(0);
+ rootPanel.add(splitPane);
+ splitPane.setLeftComponent(setupTabs());
+ rootPanel.getRootPane().setDefaultButton(buttonGenPrivKey);
+
+ splitPane.setRightComponent(panelLogs = defView(scrTbl(tableAuditLogs), "No audit logs"));
+ panelLogs.setPreferredSize(new Dimension(panelLogs.getPreferredSize().width,
+ panelLogs.getPreferredSize().height / 2));
+ panelLogs.setBackground(new Color(-1));
+
+ rootPanel.add(labelStatus);
+ labelStatus.setBorder(BorderFactory.createEmptyBorder(4, 8, 8, 8));
+
+ ca = new CertificationAuthority();
+
+ tableAuditLogs.setModel(modelAuditLogs);
+ tableCerts.setModel(modelCerts);
+ tableTemplates.setModel(modelTemplates);
+
+ tabbedPane.addChangeListener(this::onChangeTab);
+ setupObservers();
+
+ setCA(ca);
+ }
+
+ // -----BEGIN HELPER METHODS-----
+
+ /**
+ * EFFECTS: Rewind the CA and refresh all pages.
+ * MODIFIES: ca (observer), this
+ */
+ private void setCA(CertificationAuthority ca) {
+ this.ca = ca;
+ ca.registerObserver(obs);
+
+ modelAuditLogs.setPtrData(ca.getLogs());
+ modelCerts.setPtrData(ca.getSigned());
+ modelCerts.setPtrRevokedData(ca.getRevoked());
+ modelTemplates.setPtrData(ca.getTemplates());
+
+ renderRefresh();
+ }
+
+ // -----END HELPER METHODS-----
+
+ // -----BEGIN DATA OBSERVERS-----
+
+ /**
+ * EFFECTS: Setup observers
+ * MODIFIES: this
+ */
+ private void setupObservers() {
+ obs.register(AuditLogEntry.class, this::acceptAuditLog);
+ obs.register(Certificate.class, this::acceptCertificate);
+ obs.register(RSAPublicKey.class, this::acceptPrivateKey);
+ obs.register(Template.class, this::acceptTemplate);
+ }
+
+ /**
+ * EFFECTS: Handle new audit log, hide default text, notify the model
+ * MODIFIES: this
+ * REQUIRES: direction == ADD, i >= 0
+ */
+ private void acceptAuditLog(AuditLogEntry auditLogEntry, int direction, int i) {
+ setContentVisible(panelLogs, true);
+ modelAuditLogs.fireTableRowsInserted(ca.getLogs().size() - 1, ca.getLogs().size() - 1);
+ acceptUnsaved(unsaved.get(), Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
+ }
+
+ /**
+ * EFFECTS: Handle CA cert or new issued cert, notify CA page or model accordingly, hide certs default text
+ * MODIFIES: this
+ * REQUIRES: direction == CHANGE or ADD, i >= 0 or -1
+ */
+ private void acceptCertificate(Certificate cert, int direction, int i) {
+ if (i == Observer.INDEX_NOT_IN_LIST) {
+ renderCAPage();
+ } else {
+ setContentVisible(panelCertsTab, true);
+ modelCerts.fireTableRowsInserted(i, i);
+ }
+ }
+
+ /**
+ * EFFECTS: Handle added, changed, or deleted template; notify model, show / hide default text.
+ * MODIFIES: this
+ * REQUIRES: i >= 0.
+ */
+ private void acceptTemplate(Template template, int direction, int i) {
+ setContentVisible(panelTmpTab, !ca.getTemplates().isEmpty());
+ switch (direction) {
+ case Observer.DIRECTION_ADD:
+ modelTemplates.fireTableRowsInserted(i, i);
+ break;
+ case Observer.DIRECTION_CHANGE:
+ modelTemplates.fireTableRowsUpdated(i, i);
+ break;
+ case Observer.DIRECTION_REMOVE:
+ modelTemplates.fireTableRowsDeleted(i, i);
+ break;
+ }
+ }
+
+ /**
+ * EFFECTS: Handle added private key. Change buttons / labels accordingly.
+ * MODIFIES: this
+ */
+ private void acceptPrivateKey(RSAPublicKey pubKey, int direction, int i) {
+ renderCAPage();
+ }
+
+ /**
+ * EFFECTS: Handle status label change, set status to unsaved / saved + latest action.
+ * MODIFIES: this
+ */
+ private void acceptUnsaved(Boolean unsaved, int direction, int i) {
+ labelStatus.setText(unsaved ? "Unsaved" : "Ready");
+ if (!ca.getLogs().isEmpty()) {
+ labelStatus.setText(labelStatus.getText() + ": " + ca.getLogs().get(ca.getLogs().size() - 1).getAction());
+ }
+ }
+
+ // -----END DATA OBSERVERS----
+
+ // -----BEGIN RENDERERS-----
+
+ /**
+ * EFFECTS: Setup the toolbar.
+ * MODIFIES: this
+ */
+ private JToolBar setupToolbar() {
+ final JToolBar toolBar = new JToolBar();
+ toolBar.setAlignmentX(Component.LEFT_ALIGNMENT);
+ toolBar.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8));
+
+ toolBar.add(buttonToolbarLoad);
+ toolBar.add(buttonToolbarSave);
+
+ toolBar.add(panelContextAwareToolbar);
+
+ panelContextAwareToolbar.add(toolbarTemplates, "CardToolbarTemplates");
+
+ toolbarTemplates.add(Box.createHorizontalGlue());
+ toolbarTemplates.add(buttonTemplatesToolbarNew);
+ toolbarTemplates.add(buttonTemplatesToolbarEnable);
+ toolbarTemplates.add(buttonTemplatesToolbarDisable);
+ toolbarTemplates.add(buttonTemplatesToolbarDelete);
+
+ panelContextAwareToolbar.add(toolbarCerts, "CardToolbarCerts");
+ toolbarCerts.add(Box.createHorizontalGlue());
+ toolbarCerts.add(buttonCertsToolbarNew);
+ toolbarCerts.add(buttonCertsToolbarRevoke);
+ toolbarCerts.add(buttonCertsToolbarExport);
+
+ panelContextAwareToolbar.add(toolbarCA, "CardToolbarCA");
+ toolbarCA.add(Box.createHorizontalGlue());
+ toolbarCA.add(buttonCAToolbarCRL);
+ return toolBar;
+ }
+
+ /**
+ * EFFECTS: Setup the tabs
+ * MODIFIES: this
+ */
+ private JTabbedPane setupTabs() {
+ tabbedPane.setAlignmentX(Component.LEFT_ALIGNMENT);
+ tabbedPane.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8));
+
+ final JPanel panelTabCA = new JPanel();
+ panelTabCA.setLayout(new GridBagLayout());
+ tabbedPane.addTab("CA", panelTabCA);
+
+ panelTabCA.add(new JLabel("Welcome to JCA"), new GCBuilder().anchor(WEST).insectTop(8).build());
+ panelTabCA.add(labelPrivateKey, new GCBuilder().gridY(1).anchor(WEST).build());
+ panelTabCA.add(labelCACertificate, new GCBuilder().gridY(2).anchor(WEST).build());
+ panelTabCA.add(buttonGenPrivKey, new GCBuilder().gridXY(1, 1).fill(HORIZONTAL).build());
+ panelTabCA.add(buttonInstallCA, new GCBuilder().gridXY(1, 2).fill(HORIZONTAL).build());
+ panelTabCA.add(buttonGenCSR, new GCBuilder().gridXY(2, 2).fill(HORIZONTAL).build());
+ panelTabCA.add(new JPanel(), new GCBuilder().gridXY(3, 3).expandXY().fill(BOTH).build());
+ tabbedPane.addTab("Certs", panelCertsTab = defView(scrTbl(tableCerts), "No issued certs"));
+ tabbedPane.addTab("Templates", panelTmpTab = defView(scrTbl(tableTemplates), "No templates"));
+
+ return tabbedPane;
+ }
+
+ /**
+ * EFFECTS: Render public key and CA to the CA page and toolbar
+ * MODIFIES: this
+ */
+ private void renderCAPage() {
+ if (ca.getPublicKey() == null) {
+ labelPrivateKey.setText("Private key not installed");
+ } else {
+ labelPrivateKey.setText(String.format("%s key: %s ...",
+ ca.getPublicKey().getAlgorithm(),
+ ca.getPublicKey().getModulus().toString(16).substring(0, 16)));
+ }
+ if (ca.getCertificate() == null) {
+ labelCACertificate.setText("CA certificate not installed");
+ } else {
+ labelCACertificate.setText(String.format("<html>CA: %s<br>Issued by: %s</html>",
+ ca.getCertificate().getCertificate().getSubject().toString(),
+ ca.getCertificate().getCertificate().getIssuer().toString()));
+ }
+ buttonGenPrivKey.setEnabled(ca.getPublicKey() == null);
+ buttonInstallCA.setEnabled(ca.getPublicKey() != null && ca.getCertificate() == null);
+ buttonGenCSR.setEnabled(ca.getPublicKey() != null && ca.getCertificate() == null);
+ buttonCertsToolbarNew.setEnabled(ca.getPublicKey() != null && ca.getCertificate() != null);
+ buttonCAToolbarCRL.setEnabled(ca.getPublicKey() != null && ca.getCertificate() != null);
+ }
+
+ /**
+ * EFFECTS: Reset all GUI to initial state and render the CA again.
+ * MODIFIES: this
+ */
+ private void renderRefresh() {
+ acceptUnsaved(false, Observer.DIRECTION_CHANGE, Observer.INDEX_NOT_IN_LIST);
+ onChangeTab(null);
+ renderCAPage();
+
+ modelAuditLogs.fireTableDataChanged();
+ setContentVisible(panelLogs, !ca.getLogs().isEmpty());
+
+ modelCerts.fireTableDataChanged();
+ setContentVisible(panelCertsTab, !ca.getSigned().isEmpty());
+
+ modelTemplates.fireTableDataChanged();
+ setContentVisible(panelTmpTab, !ca.getTemplates().isEmpty());
+ }
+
+ // -----END RENDERERS-----
+
+ // -----BEGIN ACTION LISTENERS-----
+
+ /**
+ * EFFECTS: Switch toolbar according to tab change.
+ * MODIFIES: this
+ */
+ private void onChangeTab(ChangeEvent ev) {
+ final CardLayout toolbarCardLayout = (CardLayout) panelContextAwareToolbar.getLayout();
+ switch (tabbedPane.getSelectedIndex()) {
+ case 0: {
+ toolbarCerts.setEnabled(false);
+ toolbarTemplates.setEnabled(false);
+ toolbarCardLayout.show(panelContextAwareToolbar, "CardToolbarCA");
+ break;
+ }
+ case 1: {
+ toolbarCerts.setEnabled(true);
+ toolbarTemplates.setEnabled(false);
+ toolbarCardLayout.show(panelContextAwareToolbar, "CardToolbarCerts");
+ break;
+ }
+ case 2: {
+ toolbarCerts.setEnabled(false);
+ toolbarTemplates.setEnabled(true);
+ toolbarCardLayout.show(panelContextAwareToolbar, "CardToolbarTemplates");
+ break;
+ }
+ }
+ }
+
+ /**
+ * EFFECTS: Generate private key.
+ * MODIFIES: this
+ * REQUIRES: No private key / CA is installed.
+ */
+ private void onGeneratePrivateKey(ActionEvent ev) {
+ try {
+ ca.generateKey();
+ unsaved.set(true);
+ } catch (NoSuchAlgorithmException e) {
+ alert(rootPanel, "Generate private key", e);
+ } finally {
+ renderCAPage();
+ }
+ }
+
+ /**
+ * EFFECTS: Sign a CSR and save to disk (in binary form).
+ * MODIFIES: this
+ * REQUIRES: Proper private key installed, no CA.
+ */
+ private void onSignCSR(ActionEvent ev) {
+ final Path p = chooseFile(rootPanel, "DER binary (*.csr)", "csr");
+ if (p == null) {
+ return;
+ }
+
+ try {
+ final OutputStream fd = Files.newOutputStream(p,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING,
+ StandardOpenOption.CREATE);
+ final CertificationRequest csr = ca.signCSR();
+ fd.write(Utils.byteToByte(csr.encodeDER()));
+ fd.close();
+ unsaved.set(true);
+ } catch (IOException | ParseException | NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
+ alert(rootPanel, "Sign certification request", e);
+ }
+ }
+
+ /**
+ * EFFECTS: Pick a certificate and install.
+ * MODIFIES: this
+ * REQUIRES: Proper private key is installed, no CA.
+ */
+ private void onInstallCA(ActionEvent ev) {
+ final Path p = chooseFile(rootPanel, "DER X.509 certificate (*.crt, *.pem)", "crt", "pem");
+ if (p == null) {
+ return;
+ }
+
+ try {
+ final Certificate crt = new Certificate(new BytesReader(UIUtils.openDERorPEM(p, "CERTIFICATE")),
+ false);
+ ca.installCertificate(crt);
+ unsaved.set(true);
+ } catch (IOException | ParseException | InvalidCAException e) {
+ alert(rootPanel, "Install CA", e);
+ } finally {
+ renderCAPage();
+ }
+ }
+
+ /**
+ * EFFECTS: Load the database and refresh.
+ * MODIFIES: this
+ */
+ private void onLoad(ActionEvent ev) {
+ if (unsaved.get()) {
+ alert(rootPanel, "Load database from filesystem",
+ "Unable to load: current modifications are not saved.");
+ return;
+ }
+
+ try {
+ setCA(Decoder.decodeCA(FS.read(PATH_DEFAULT)));
+ } catch (NoSuchAlgorithmException | InvalidDBException e) {
+ alert(rootPanel, "Load database from filesystem", e);
+ }
+ }
+
+ /**
+ * EFFECTS: Save database.
+ * MODIFIES: this
+ */
+ private void onSave(ActionEvent ev) {
+ try {
+ FS.write(PATH_DEFAULT, Decoder.encodeCA(this.ca));
+ unsaved.set(false);
+ } catch (IOException e) {
+ alert(rootPanel, "Save database to filesystem", e);
+ }
+ }
+
+ /**
+ * EFFECTS: Enable a template.
+ * MODIFIES: this
+ */
+ private void onEnableTemplate(ActionEvent ev) {
+ if (tableTemplates.getSelectedRow() == -1) {
+ return;
+ }
+
+ final Template t = ca.getTemplates().get(tableTemplates.getSelectedRow());
+ if (t.isEnabled()) {
+ return;
+ }
+ ca.setTemplateEnable(t, true);
+ unsaved.set(true);
+ }
+
+ /**
+ * EFFECTS: Disable a template.
+ * MODIFIES: this
+ */
+ private void onDisableTemplate(ActionEvent ev) {
+ if (tableTemplates.getSelectedRow() == -1) {
+ return;
+ }
+
+ final Template t = ca.getTemplates().get(tableTemplates.getSelectedRow());
+ if (!t.isEnabled()) {
+ return;
+ }
+ ca.setTemplateEnable(t, false);
+ unsaved.set(true);
+ }
+
+ /**
+ * EFFECTS: Delete a template.
+ * MODIFIES: this
+ */
+ private void onDeleteTemplate(ActionEvent ev) {
+ if (tableTemplates.getSelectedRow() == -1) {
+ return;
+ }
+
+ final Template t = ca.getTemplates().get(tableTemplates.getSelectedRow());
+ ca.removeTemplate(t);
+ unsaved.set(true);
+ }
+
+ /**
+ * EFFECTS: Save the selected cert as a DER binary.
+ * MODIFIES: this
+ */
+ private void onExportCert(ActionEvent ev) {
+ if (tableCerts.getSelectedRow() == -1) {
+ return;
+ }
+
+ final Certificate c = ca.getSigned().get(tableCerts.getSelectedRow());
+ final Path p = chooseFile(rootPanel, "DER binary (*.crt)", "crt");
+ if (p == null) {
+ return;
+ }
+ try {
+ final OutputStream fd = Files.newOutputStream(p,
+ StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE);
+ fd.write(Utils.byteToByte(c.encodeDER()));
+ fd.close();
+ } catch (IOException e) {
+ alert(rootPanel, "Export certificate", e);
+ }
+ }
+
+ /**
+ * EFFECTS: Show revocation dialog for the selected cert. Save enforced.
+ * MODIFIES: this
+ */
+ private void onRevokeCert(ActionEvent ev) {
+ if (tableCerts.getSelectedRow() == -1) {
+ return;
+ }
+
+ final Certificate c = ca.getSigned().get(tableCerts.getSelectedRow());
+ if (ca.getRevoked().stream().anyMatch(r ->
+ r.getSerialNumber().getLong() == c.getCertificate().getSerialNumber().getLong())) {
+ return;
+ }
+ final RevokeDialog diag = new RevokeDialog(c);
+ diag.pack();
+ diag.setLocationRelativeTo(this);
+ diag.setVisible(true);
+
+ final RevokedCertificate res = diag.getRes();
+ if (res == null) {
+ return;
+ }
+
+ ca.revoke(res);
+ onSave(null);
+ }
+
+ /**
+ * EFFECTS: Sign a CRL and save to disk. Save enforced.
+ * MODIFIES: this
+ * REQUIRES: Proper private key and CA cert installed.
+ */
+ private void onCRL(ActionEvent ev) {
+ final Path p = chooseFile(rootPanel, "DER CRL (*.crl)", "crl");
+ if (p == null) {
+ return;
+ }
+ try {
+ final OutputStream fd = Files.newOutputStream(p,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING,
+ StandardOpenOption.CREATE);
+ final CertificateList crl = ca.signCRL();
+ fd.write(Utils.byteToByte(crl.encodeDER()));
+ fd.close();
+ onSave(null);
+ } catch (IOException | SignatureException | InvalidKeyException | NoSuchAlgorithmException e) {
+ alert(rootPanel, "Sign CRL", e);
+ }
+ }
+
+ /**
+ * EFFECTS: Pick a CSR, show issue dialog, sign cert. Save enforced.
+ * MODIFIES: this
+ * REQUIRES: Proper private key / CA is installed.
+ */
+ private void onIssue(ActionEvent ev) {
+ if (ca.getTemplates().stream().noneMatch(Template::isEnabled)) {
+ alert(rootPanel, "Issue new certificate", "No enabled templates.");
+ return;
+ }
+ final Path p = chooseFile(rootPanel, "DER CSR (*.csr, *.pem)", "csr", "pem");
+ if (p == null) {
+ return;
+ }
+ try {
+ CertificationRequest csr = new CertificationRequest(new BytesReader(UIUtils.openDERorPEM(p,
+ "CERTIFICATE REQUEST")), false);
+ final IssueDialog diag = new IssueDialog(csr, ca.getTemplates());
+ diag.setLocationRelativeTo(this);
+ diag.setVisible(true);
+ final Template res = diag.getRes();
+ if (res == null) {
+ return;
+ }
+ ca.signCert(csr.getCertificationRequestInfo(), res);
+ onSave(null);
+ } catch (IOException | ParseException | NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
+ alert(rootPanel, "Issue new certificate", e);
+ }
+ }
-public class MainUI {
- private JTabbedPane tabbedPane;
- private JPanel rootPanel;
- private JList listTemplates;
- private JList listCertificates;
- private JList listLogs;
- private JLabel labelStatus;
- private JButton buttonSave;
-
- public JFrame createWindow() {
- final JFrame frame = new JFrame("JCA");
- frame.setContentPane(this.rootPanel);
- frame.setVisible(true);
- frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
- frame.pack();
- return frame;
+ /**
+ * EFFECTS: Show the new template dialog and add a template.
+ * MODIFIES: this
+ */
+ private void onNewTemplate(ActionEvent ev) {
+ final TemplateEditDialog diag = new TemplateEditDialog(temp ->
+ ca.getTemplates().stream().anyMatch(t -> t.getName().equals(temp)));
+ diag.pack();
+ diag.setLocationRelativeTo(this);
+ diag.setVisible(true);
+ final Template res = diag.getRes();
+ if (res == null) {
+ return;
+ }
+ ca.addTemplate(res);
+ unsaved.set(true);
}
}
diff --git a/src/main/ui/RevokeDialog.java b/src/main/ui/RevokeDialog.java
new file mode 100644
index 0000000..49c13d1
--- /dev/null
+++ b/src/main/ui/RevokeDialog.java
@@ -0,0 +1,186 @@
+package ui;
+
+import model.asn1.ASN1Object;
+import model.asn1.UtcTime;
+import model.pki.cert.Certificate;
+import model.pki.crl.Reason;
+import model.pki.crl.RevokedCertificate;
+import ui.widgets.GCBuilder;
+import ui.widgets.UIUtils;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+
+import static java.awt.GridBagConstraints.HORIZONTAL;
+import static java.awt.GridBagConstraints.WEST;
+import static ui.widgets.UIUtils.alert;
+
+/**
+ * A dialog that presents user with cert info, revocation reason, and revocation time.
+ * ┌────────────────────────────┐
+ * │ Revoke Certificate │
+ * │ │
+ * │Revoking: CN=xyz (Serial: 1)│
+ * │Reason: (Drop Down) │
+ * │Time: (ISO-8601 text) │
+ * │ │
+ * │ Revoke Cancel│
+ * └────────────────────────────┘
+ */
+public class RevokeDialog extends JDialog {
+ /**
+ * ISO8601
+ */
+ private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+ /**
+ * The incoming certificate.
+ */
+ private final Certificate crt;
+
+ /**
+ * The result.
+ */
+ private RevokedCertificate res;
+
+
+ /**
+ * Root pane
+ */
+ private JPanel contentPane;
+
+ /**
+ * OK button
+ */
+ private JButton buttonOK;
+
+ /**
+ * Cancel button
+ */
+ private JButton buttonCancel;
+
+ /**
+ * Reason
+ */
+ private JComboBox<String> comboBoxReason;
+
+ /**
+ * Subject (not editable)
+ */
+ private JTextField textFieldSubject;
+
+ /**
+ * Time
+ */
+ private JFormattedTextField formattedTextFieldTime;
+
+ /**
+ * EFFECTS: Init GUI with the given cert.
+ */
+ public RevokeDialog(Certificate crt) {
+ this.crt = crt;
+
+ contentPane = new JPanel();
+ contentPane.setLayout(new BoxLayout(contentPane, BoxLayout.PAGE_AXIS));
+ contentPane.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
+ contentPane.add(renderForm());
+
+ contentPane.add(UIUtils.createActionsPane(buttonOK = new JButton("Revoke"),
+ buttonCancel = new JButton("Cancel")));
+ buttonOK.addActionListener(this::onOK);
+ buttonCancel.addActionListener(this::onCancel);
+
+ setContentPane(contentPane);
+ setModal(true);
+ getRootPane().setDefaultButton(buttonOK);
+ setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
+
+ setTitle("Revoke certificate");
+
+ addWindowListener(new WindowAdapter() {
+ public void windowClosing(WindowEvent e) {
+ onCancel(null);
+ }
+ });
+
+ contentPane.registerKeyboardAction(this::onCancel,
+ KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
+ JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
+ }
+
+ /**
+ * EFFECTS: Init GUI.
+ * MODIFIES: this
+ */
+ private JPanel renderForm() {
+ final JPanel panelForm = new JPanel();
+ panelForm.setLayout(new GridBagLayout());
+
+ panelForm.add(new JLabel("Revoking: "), new GCBuilder().anchor(WEST).build());
+ panelForm.add(new JLabel("Reason: "), new GCBuilder().gridY(1).anchor(WEST).build());
+ panelForm.add(new JLabel("Time: "), new GCBuilder().gridY(2).anchor(WEST).build());
+
+ textFieldSubject = new JTextField(String.format("%s (Serial: %d)",
+ crt.getCertificate().getSubject().toString(), crt.getCertificate().getSerialNumber().getLong()));
+ textFieldSubject.setEditable(false);
+ panelForm.add(textFieldSubject, new GCBuilder().gridX(1).anchor(WEST).fill(HORIZONTAL).build());
+
+ comboBoxReason = new JComboBox<>(Arrays.stream(Reason.values()).map(Enum::toString).toArray(String[]::new));
+ panelForm.add(comboBoxReason, new GCBuilder().gridXY(1, 1).anchor(WEST).fill(HORIZONTAL).build());
+
+ formattedTextFieldTime = new JFormattedTextField(DATE_FORMAT.toFormat());
+ formattedTextFieldTime.setText(ZonedDateTime.now().format(DATE_FORMAT));
+ panelForm.add(formattedTextFieldTime, new GCBuilder().gridXY(1, 2).anchor(WEST).fill(HORIZONTAL).build());
+
+ panelForm.add(new JPanel(), new GCBuilder().gridXY(1, 3).expandXY().fill(HORIZONTAL).build());
+ return panelForm;
+ }
+
+ /**
+ * EFFECTS: Validate form, set result, and close dialog.
+ * Time must be a valid ISO-8601 string with offset, and it will be converted into UTC.
+ * Reason must be supported.
+ * MODIFIES: this
+ */
+ private void onOK(ActionEvent ev) {
+ try {
+ res = new RevokedCertificate(ASN1Object.TAG_SEQUENCE, null,
+ crt.getCertificate().getSerialNumber(),
+ new UtcTime(UtcTime.TAG, null,
+ ZonedDateTime.parse(formattedTextFieldTime.getText(), DATE_FORMAT)
+ .withZoneSameInstant(ZoneId.of("UTC"))),
+ Reason.valueOf(comboBoxReason.getSelectedItem().toString()));
+ dispose();
+ } catch (DateTimeParseException e) {
+ alert(rootPane, "Revoke certificate", "Invalid time: " + formattedTextFieldTime.getText()
+ + ". It must be a valid ISO8601 time with offset, like '1919-08-10T11:45:14+09:00'.");
+ } catch (IllegalArgumentException e) {
+ alert(rootPane, "Revoke certificate", "Invalid reason.");
+ }
+ }
+
+ /**
+ * EFFECTS: Clear the result and close dialog.
+ * MODIFIES: this
+ */
+ private void onCancel(ActionEvent ev) {
+ res = null;
+ dispose();
+ }
+
+ /**
+ * EFFECTS: Get result.
+ */
+ public RevokedCertificate getRes() {
+ return res;
+ }
+}
diff --git a/src/main/ui/TemplateEditDialog.java b/src/main/ui/TemplateEditDialog.java
new file mode 100644
index 0000000..7c33af6
--- /dev/null
+++ b/src/main/ui/TemplateEditDialog.java
@@ -0,0 +1,82 @@
+package ui;
+
+import model.asn1.exceptions.ParseException;
+import model.ca.Template;
+import model.x501.Name;
+import ui.widgets.CertEditDialog;
+
+import javax.swing.*;
+import java.awt.event.ActionEvent;
+import java.util.function.Function;
+
+import static ui.widgets.UIUtils.alert;
+
+/**
+ * A dialog that allows users to input template name, subject, and validity.
+ * ┌───────────────────────────┐
+ * │ New Template │
+ * │ │
+ * │Template: _________│
+ * │Subject: _________│
+ * │Validity (Days): (Spinner)│
+ * │ │
+ * │ Add Cancel│
+ * └───────────────────────────┘
+ */
+public class TemplateEditDialog extends CertEditDialog<Template> {
+ /**
+ * Callback function to check for name conflict.
+ */
+ private final Function<String, Boolean> dupDetector;
+
+ /**
+ * Text field for name.
+ */
+ private JTextField templateComponent;
+
+ /**
+ * EFFECTS: Init UI, title = New Template, OK button = Add, set dup detector.
+ */
+ public TemplateEditDialog(Function<String, Boolean> dupDetector) {
+ super();
+ this.dupDetector = dupDetector;
+ setTitle("New template");
+ buttonOK.setText("Add");
+ }
+
+ /**
+ * EFFECTS: Initialize the subject text field with JTextField.
+ * MODIFIES: this
+ */
+ @Override
+ protected JComponent createTemplateComponent() {
+ return templateComponent = new JTextField();
+ }
+
+ /**
+ * EFFECTS: Validate the form, set the result, and close the dialog.
+ * Name must not be null; Name must not conflict; Subject must be valid.
+ * MODIFIES: this
+ */
+ @Override
+ protected void onOK(ActionEvent ev) {
+ if (templateComponent.getText().isEmpty()) {
+ alert(rootPane, getTitle(), "The template name must not be empty.");
+ return;
+ }
+ if (dupDetector.apply(templateComponent.getText())) {
+ alert(rootPane, getTitle(), "The template exists.");
+ return;
+ }
+
+ try {
+ res = new Template(templateComponent.getText(),
+ false,
+ textFieldSubject.getText().isBlank() ? null : Name.parseString(textFieldSubject.getText()),
+ (Integer) spinnerValidity.getValue());
+ dispose();
+ } catch (ParseException e) {
+ alert(rootPane, getTitle(), e);
+ }
+ }
+}
diff --git a/src/main/ui/widgets/CertEditDialog.java b/src/main/ui/widgets/CertEditDialog.java
new file mode 100644
index 0000000..ea16b19
--- /dev/null
+++ b/src/main/ui/widgets/CertEditDialog.java
@@ -0,0 +1,108 @@
+package ui.widgets;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+import static java.awt.GridBagConstraints.HORIZONTAL;
+import static java.awt.GridBagConstraints.WEST;
+import static ui.widgets.UIUtils.btn;
+
+/**
+ * A common dialog for cert / template editing. It will close upon Esc or Cancel.
+ * ┌───────────────────────────┐
+ * │ Dialog X │
+ * │ │
+ * │Template: (TBD) │
+ * │Subject: _________ │
+ * │Validity (Days): (Spinner)│
+ * │ │
+ * │ Button Cancel│
+ * └───────────────────────────┘
+ */
+public abstract class CertEditDialog<T> extends JDialog {
+ /**
+ * The result.
+ */
+ protected T res;
+
+ /**
+ * Root pane.
+ */
+ protected JPanel contentPane = new JPanel();
+ protected JButton buttonOK = btn("", this::onOK);
+ protected JTextField textFieldSubject = new JTextField();
+ protected JSpinner spinnerValidity =
+ new JSpinner(new SpinnerNumberModel(60, 1, null, 0));
+
+ /**
+ * EFFECTS: Render the dialog, leaving title and OK button text blank.
+ */
+ public CertEditDialog() {
+ contentPane.setLayout(new BoxLayout(contentPane, BoxLayout.PAGE_AXIS));
+ contentPane.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
+ contentPane.add(renderForm());
+
+ contentPane.add(UIUtils.createActionsPane(buttonOK, btn("Cancel", this::onCancel)));
+
+ setContentPane(contentPane);
+ setModal(true);
+ getRootPane().setDefaultButton(buttonOK);
+ setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
+
+ addWindowListener(new WindowAdapter() {
+ public void windowClosing(WindowEvent e) {
+ onCancel(null);
+ }
+ });
+
+ contentPane.registerKeyboardAction(this::onCancel,
+ KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
+ JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
+ }
+
+ /**
+ * EFFECTS: Render the form.
+ */
+ private JPanel renderForm() {
+ final JPanel panelForm = new JPanel(new GridBagLayout());
+ panelForm.add(new JLabel("Template: "), new GCBuilder().anchor(WEST).build());
+ panelForm.add(new JLabel("Subject: "), new GCBuilder().gridY(1).anchor(WEST).build());
+ panelForm.add(new JLabel("Validity (Days): "), new GCBuilder().gridY(2).anchor(WEST).build());
+ panelForm.add(createTemplateComponent(), new GCBuilder().gridXY(1, 0).anchor(WEST)
+ .fill(HORIZONTAL).build());
+ panelForm.add(textFieldSubject, new GCBuilder().gridXY(1, 1).anchor(WEST).fill(HORIZONTAL).build());
+ panelForm.add(spinnerValidity, new GCBuilder().gridXY(1, 2).anchor(WEST).fill(HORIZONTAL).build());
+ panelForm.add(new JPanel(), new GCBuilder().gridXY(1, 3).expandXY().fill(HORIZONTAL).build());
+ return panelForm;
+ }
+
+ /**
+ * EFFECTS: Create the component for subject.
+ */
+ protected abstract JComponent createTemplateComponent();
+
+ /**
+ * EFFECTS: Handle OK.
+ */
+ protected abstract void onOK(ActionEvent ev);
+
+ /**
+ * EFFECTS: Handle cancel: clear result and close dialog.
+ * MODIFIES: this
+ */
+ private void onCancel(ActionEvent ev) {
+ res = null;
+ dispose();
+ }
+
+ /**
+ * EFFECTS: Get the result.
+ */
+ public T getRes() {
+ return res;
+ }
+}
diff --git a/src/main/ui/widgets/CertTableModel.java b/src/main/ui/widgets/CertTableModel.java
new file mode 100644
index 0000000..0259e1b
--- /dev/null
+++ b/src/main/ui/widgets/CertTableModel.java
@@ -0,0 +1,129 @@
+package ui.widgets;
+
+import model.ca.CertificationAuthority;
+import model.pki.cert.Certificate;
+import model.pki.crl.RevokedCertificate;
+
+import javax.swing.*;
+import javax.swing.table.AbstractTableModel;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * Table model that displays issued certificates.
+ */
+public class CertTableModel extends AbstractTableModel {
+ /**
+ * Valid certificate icon.
+ */
+ private static final ImageIcon ICON_OK =
+ new ImageIcon(CertTableModel.class.getResource("/verified.png"));
+
+ /**
+ * Revoked certificate icon (same as toolbar revoke icon).
+ */
+ private static final ImageIcon ICON_REVOKED =
+ new ImageIcon(CertTableModel.class.getResource("/deletetest.png"));
+
+ /**
+ * Columns
+ */
+ private static final String[] COLS = new String[] {
+ "", // Icon
+ "Serial",
+ "Subject",
+ "Signed",
+ "Expires"
+ };
+
+ /**
+ * Pointer to {@link CertificationAuthority#getSigned()}
+ */
+ private List<Certificate> ptrData;
+
+ /**
+ * Pointer to {@link CertificationAuthority#getRevoked()}
+ */
+ private List<RevokedCertificate> ptrRevokedData;
+
+ /**
+ * EFFECTS: Set pointer to certs
+ * MODIFIES: this
+ */
+ public void setPtrData(List<Certificate> ptrData) {
+ this.ptrData = ptrData;
+ }
+
+ /**
+ * EFFECTS: Set pointer to revoked
+ * MODIFIES: this
+ */
+ public void setPtrRevokedData(List<RevokedCertificate> ptrRevokedData) {
+ this.ptrRevokedData = ptrRevokedData;
+ }
+
+ /**
+ * EFFECTS: Count rows.
+ */
+ @Override
+ public int getRowCount() {
+ return ptrData == null ? 0 : ptrData.size();
+ }
+
+ /**
+ * EFFECTS: Count columns.
+ */
+ @Override
+ public int getColumnCount() {
+ return COLS.length;
+ }
+
+ /**
+ * EFFECTS: Get column name.
+ * REQUIRES: column in [9, getColumnCount())
+ */
+ @Override
+ public String getColumnName(int column) {
+ return COLS[column];
+ }
+
+ /**
+ * EFFECTS: Return the value for a cell:
+ * ImageIcon (Valid / Revoked)
+ * String (Serial number)
+ * String (Subject)
+ * String (NotBefore)
+ * String (NotAfter)
+ * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 4
+ * REQUIRES: rowIndex must in range.
+ */
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ final Certificate e = ptrData.get(rowIndex);
+ switch (columnIndex) {
+ case 0: return ptrRevokedData.stream().anyMatch(r ->
+ r.getSerialNumber().getLong() == e.getCertificate().getSerialNumber().getLong()
+ ) ? ICON_REVOKED : ICON_OK;
+ case 1: return e.getCertificate().getSerialNumber().getLong();
+ case 2: return e.getCertificate().getSubject().toString();
+ case 3:
+ return e.getCertificate().getValidity().getNotBefore().getTimestamp()
+ .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+ case 4:
+ return e.getCertificate().getValidity().getNotAfter().getTimestamp()
+ .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+ default: throw new IllegalArgumentException();
+ }
+ }
+
+ /**
+ * EFFECTS: Return ImageIcon for 0, String otherwise.
+ */
+ @Override
+ public Class<?> getColumnClass(int columnIndex) {
+ switch (columnIndex) {
+ case 0: return ImageIcon.class;
+ default: return String.class;
+ }
+ }
+}
diff --git a/src/main/ui/widgets/GCBuilder.java b/src/main/ui/widgets/GCBuilder.java
new file mode 100644
index 0000000..0590cc1
--- /dev/null
+++ b/src/main/ui/widgets/GCBuilder.java
@@ -0,0 +1,213 @@
+package ui.widgets;
+
+import java.awt.*;
+
+/**
+ * A builder for {@link GridBagConstraints}.
+ */
+public class GCBuilder {
+ /**
+ * Grix X and Y, defaults to {@link GridBagConstraints#RELATIVE}
+ */
+ private int gridX = GridBagConstraints.RELATIVE;
+
+ private int gridY = GridBagConstraints.RELATIVE;
+
+ /**
+ * Weight X and Y, [0.0, 1.0], defaults to 0.0
+ */
+ private double weightX = 0.0;
+
+ private double weightY = 0.0;
+
+ /**
+ * Anchor, defaults to {@link GridBagConstraints#CENTER}
+ */
+ private int anchor = GridBagConstraints.CENTER;
+
+ /**
+ * How to stretch the component, defaults to {@link GridBagConstraints#NONE}
+ */
+ private int fill = GridBagConstraints.NONE;
+
+ /**
+ * Insects in pixels.
+ */
+ private int insectTop = 0;
+
+ private int insectLeft = 0;
+
+ private int insectRight = 0;
+
+ private int insectBottom = 0;
+
+ /**
+ * EFFECTS: Build the {@link GridBagConstraints} based on parameters.
+ * Grid width, grid height, ipad X, ipad Y will be 1, 1, 0, 0.
+ */
+ public GridBagConstraints build() {
+ return new GridBagConstraints(gridX, gridY,
+ 1, 1,
+ weightX, weightY,
+ anchor, fill,
+ new Insets(insectTop, insectLeft, insectBottom, insectRight),
+ 0, 0);
+ }
+
+ /**
+ * EFFECTS: Set grid X and Y.
+ * REQUIRES: > 0
+ * MODIFIES: this
+ */
+ public GCBuilder gridXY(int gridX, int gridY) {
+ return this.gridX(gridX).gridY(gridY);
+ }
+
+ /**
+ * EFFECTS: Set grid X.
+ * REQUIRES: > 0
+ * MODIFIES: this
+ */
+ public GCBuilder gridX(int gridX) {
+ this.gridX = gridX;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set grid Y.
+ * REQUIRES: > 0
+ * MODIFIES: this
+ */
+ public GCBuilder gridY(int gridY) {
+ this.gridY = gridY;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set weight X and Y.
+ * REQUIRES: [0.0, 1.0]
+ * MODIFIES: this
+ */
+ public GCBuilder weightXY(double weightX, double weightY) {
+ return this.weightX(weightX).weightY(weightY);
+ }
+
+ /**
+ * EFFECTS: Set weight X and Y to 1.0
+ * MODIFIES: this
+ */
+ public GCBuilder expandXY() {
+ return this.expandX().expandY();
+ }
+
+ /**
+ * EFFECTS: Set weight X to 1.0
+ * MODIFIES: this
+ */
+ public GCBuilder expandX() {
+ return this.weightX(1.0);
+ }
+
+ /**
+ * EFFECTS: Set weight X to 1.0
+ * MODIFIES: this
+ */
+ public GCBuilder weightX(double weightX) {
+ this.weightX = weightX;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set weight Y to 1.0
+ * MODIFIES: this
+ */
+ public GCBuilder expandY() {
+ return this.weightY(1.0);
+ }
+
+ /**
+ * EFFECTS: Set weight X to 1.0
+ * MODIFIES: this
+ */
+ public GCBuilder weightY(double weightY) {
+ this.weightY = weightY;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set anchor.
+ * REQUIRES: anchor in {@link GridBagConstraints} constants.
+ * MODIFIES: this
+ */
+ public GCBuilder anchor(int anchor) {
+ this.anchor = anchor;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set fill.
+ * REQUIRES: fill in {@link GridBagConstraints} constants.
+ * MODIFIES: this
+ */
+ public GCBuilder fill(int fill) {
+ this.fill = fill;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set insects to be 8, 8, 8, 8
+ * MODIFIES: this
+ */
+ public GCBuilder marginInsects() {
+ return this.insects(8, 8, 8, 8);
+ }
+
+ /**
+ * EFFECTS: Set insects.
+ * REQUIRES: >= 0
+ * MODIFIES: this
+ */
+ public GCBuilder insects(int top, int left, int bottom, int right) {
+ return this.insectTop(top).insectLeft(left).insectBottom(bottom).insectRight(right);
+ }
+
+ /**
+ * EFFECTS: Set top insect
+ * REQUIRES: >= 0
+ * MODIFIES: this
+ */
+ public GCBuilder insectTop(int insectTop) {
+ this.insectTop = insectTop;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set left insect
+ * REQUIRES: >= 0
+ * MODIFIES: this
+ */
+ public GCBuilder insectLeft(int insectLeft) {
+ this.insectLeft = insectLeft;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set right insect
+ * REQUIRES: >= 0
+ * MODIFIES: this
+ */
+ public GCBuilder insectRight(int insectRight) {
+ this.insectRight = insectRight;
+ return this;
+ }
+
+ /**
+ * EFFECTS: Set bottom insect
+ * REQUIRES: >= 0
+ * MODIFIES: this
+ */
+ public GCBuilder insectBottom(int insectBottom) {
+ this.insectBottom = insectBottom;
+ return this;
+ }
+}
diff --git a/src/main/ui/widgets/LogTableModel.java b/src/main/ui/widgets/LogTableModel.java
new file mode 100644
index 0000000..dc7dcbd
--- /dev/null
+++ b/src/main/ui/widgets/LogTableModel.java
@@ -0,0 +1,79 @@
+package ui.widgets;
+
+import model.ca.AuditLogEntry;
+import model.ca.CertificationAuthority;
+
+import javax.swing.table.AbstractTableModel;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * Table model that displays audit logs.
+ */
+public class LogTableModel extends AbstractTableModel {
+ /**
+ * Columns
+ */
+ private static final String[] COLS = new String[] {
+ "Time",
+ "Operator",
+ "Action"
+ };
+
+ /**
+ * Pointer to the {@link CertificationAuthority#getLogs()}.
+ */
+ private List<AuditLogEntry> ptrData;
+
+ /**
+ * EFFECTS: Set the pointer to templates
+ * MODIFIES: this
+ */
+ public void setPtrData(List<AuditLogEntry> ptrData) {
+ this.ptrData = ptrData;
+ }
+
+ /**
+ * EFFECT: Return number of rows.
+ */
+ @Override
+ public int getRowCount() {
+ return ptrData == null ? 0 : ptrData.size();
+ }
+
+ /**
+ * EFFECT: Return number of columns.
+ */
+ @Override
+ public int getColumnCount() {
+ return COLS.length;
+ }
+
+ /**
+ * EFFECTS: Get column name.
+ * REQUIRES: column in [9, getColumnCount())
+ */
+ @Override
+ public String getColumnName(int column) {
+ return COLS[column];
+ }
+
+ /**
+ * EFFECTS: Return the value for a cell:
+ * String (Time)
+ * String (Operator)
+ * String (Action)
+ * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 2
+ * REQUIRES: rowIndex must in range.
+ */
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ final AuditLogEntry e = ptrData.get(rowIndex);
+ switch (columnIndex) {
+ case 0: return e.getTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+ case 1: return e.getUser();
+ case 2: return e.getAction();
+ default: throw new IllegalArgumentException();
+ }
+ }
+}
diff --git a/src/main/ui/widgets/TemplateTableModel.java b/src/main/ui/widgets/TemplateTableModel.java
new file mode 100644
index 0000000..da4557f
--- /dev/null
+++ b/src/main/ui/widgets/TemplateTableModel.java
@@ -0,0 +1,105 @@
+package ui.widgets;
+
+import model.ca.CertificationAuthority;
+import model.ca.Template;
+
+import javax.swing.*;
+import javax.swing.table.AbstractTableModel;
+import java.util.List;
+
+/**
+ * Table model that displays templates.
+ */
+public class TemplateTableModel extends AbstractTableModel {
+ /**
+ * Template enabled icon, same as toolbar enable icon.
+ */
+ private static final ImageIcon ICON_ENABLED =
+ new ImageIcon(TemplateTableModel.class.getResource("/enable.png"));
+
+ /**
+ * Template disbled icon, same as toolbar enable icon.
+ */
+ private static final ImageIcon ICON_DISABLED =
+ new ImageIcon(TemplateTableModel.class.getResource("/disable.png"));
+
+ /**
+ * Columns
+ */
+ private static final String[] COLS = new String[] {
+ "", // Icon
+ "Name",
+ "Subject",
+ "Validity"
+ };
+
+ /**
+ * Pointer to the {@link CertificationAuthority#getTemplates()}.
+ */
+ private List<Template> ptrData;
+
+ /**
+ * EFFECTS: Set the pointer to templates
+ * MODIFIES: this
+ */
+ public void setPtrData(List<Template> ptrData) {
+ this.ptrData = ptrData;
+ }
+
+ /**
+ * EFFECT: Return number of rows.
+ * REQUIRES: column in [9, getColumnCount())
+ */
+ @Override
+ public int getRowCount() {
+ return ptrData == null ? 0 : ptrData.size();
+ }
+
+ /**
+ * EFFECT: Return number of rows.
+ */
+ @Override
+ public int getColumnCount() {
+ return COLS.length;
+ }
+
+ /**
+ * EFFECTS: Get column name.
+ */
+ @Override
+ public String getColumnName(int column) {
+ return COLS[column];
+ }
+
+ /**
+ * EFFECTS: Return the value for a cell:
+ * ImageIcon (Enabled / Disabled)
+ * String (Name)
+ * String (Subject or Not Set)
+ * String (xx days)
+ * Throws {@link IllegalArgumentException} if columnIndex is not in 0 ~ 3
+ * REQUIRES: rowIndex must in range.
+ */
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ final Template e = ptrData.get(rowIndex);
+ switch (columnIndex) {
+ case 0: return e.isEnabled() ? ICON_ENABLED : ICON_DISABLED;
+ case 1: return e.getName();
+ case 2: return e.getSubject() == null ? "<Not Set>" : e.getSubject().toString();
+ case 3: return String.format("%d days", e.getValidity());
+ default: throw new IllegalArgumentException();
+ }
+ }
+
+ /**
+ * EFFECTS: Return ImageIcon for 0, String otherwise.
+ */
+ @Override
+ public Class<?> getColumnClass(int columnIndex) {
+ switch (columnIndex) {
+ case 0: return ImageIcon.class;
+ default: return String.class;
+ }
+ }
+}
diff --git a/src/main/ui/widgets/UIUtils.java b/src/main/ui/widgets/UIUtils.java
new file mode 100644
index 0000000..4442be3
--- /dev/null
+++ b/src/main/ui/widgets/UIUtils.java
@@ -0,0 +1,168 @@
+package ui.widgets;
+
+import model.asn1.exceptions.ParseException;
+import ui.Utils;
+
+import javax.swing.*;
+import javax.swing.filechooser.FileNameExtensionFilter;
+import java.awt.*;
+import java.awt.event.ActionListener;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.stream.IntStream;
+
+import static java.awt.GridBagConstraints.BOTH;
+import static java.awt.GridBagConstraints.HORIZONTAL;
+import static javax.swing.JOptionPane.*;
+
+/**
+ * Useful utilities for building GUI.
+ */
+public class UIUtils {
+ /**
+ * EFFECTS: Create a horizontal actions pane:
+ * -----------------------------------------------------------
+ * | | Button1 | Button2 | Button3 | ButtonN |
+ * -----------------------------------------------------------
+ * REQUIRES: buttons != null
+ */
+ public static JPanel createActionsPane(JButton... buttons) {
+ final JPanel panelAct = new JPanel();
+ panelAct.setLayout(new GridBagLayout());
+ IntStream.range(0, buttons.length)
+ .forEach(i -> panelAct.add(buttons[i],
+ new GCBuilder().gridXY(i + 1, 1).fill(HORIZONTAL).build()));
+ panelAct.add(new JPanel(), new GCBuilder().expandXY().fill(BOTH).build());
+ return panelAct;
+ }
+
+ /**
+ * EFFECTS: Show / hide default text for a card layout container.
+ * MODIFIES: cardLayoutPanel
+ * REQUIRES: cardLayoutPanel must have a card layout; it must have CardContent and CardDefault cards.
+ */
+ public static void setContentVisible(Container cardLayoutPanel, boolean showContent) {
+ switchTo(cardLayoutPanel, showContent ? "CardContent" : "CardDefault");
+ }
+
+ /**
+ * EFFECTS: Switch to the card for a card layout panel.
+ * MODIFIES: cardLayoutPanel
+ * REQUIRES: cardLayoutPanel must have a card layout; it must have "card" card.
+ */
+ public static void switchTo(Container cardLayoutPanel, String card) {
+ ((CardLayout) cardLayoutPanel.getLayout()).show(cardLayoutPanel, card);
+ }
+
+ /**
+ * EFFECTS: Show an error message based on {@link Throwable#getMessage()}
+ * REQUIRES: component must have a frame.
+ */
+ public static void alert(Component component, String title, Throwable e) {
+ alert(component, title, e.getMessage());
+ }
+
+ /**
+ * EFFECTS: Show an error message.
+ * REQUIRES: component must have a frame.
+ */
+ public static void alert(Component component, String title, String message) {
+ showMessageDialog(getFrameForComponent(component),
+ message,
+ title,
+ ERROR_MESSAGE);
+ }
+
+ /**
+ * EFFECTS: Show a file chooser with the given filter title and list of extensions. Starting from cwd.
+ * Return null if cancelled.
+ */
+ public static Path chooseFile(Component component, String filterTitle, String... extensions) {
+ final JFileChooser fc = new JFileChooser();
+ fc.setFileFilter(new FileNameExtensionFilter(filterTitle, extensions));
+ fc.setCurrentDirectory(Paths.get("").toAbsolutePath().toFile());
+ if (fc.showOpenDialog(getFrameForComponent(component)) == JFileChooser.APPROVE_OPTION) {
+ return fc.getSelectedFile().toPath();
+ }
+ return null;
+ }
+
+ /**
+ * EFFECTS: Create a JPanel with CardLayout that has CardDefault set to component and CardContent set to a JLabel
+ * with the default text.
+ */
+ public static JPanel defView(Component component, String defaultText) {
+ final JPanel panel = new JPanel();
+ panel.setLayout(new CardLayout(0, 0));
+
+ JLabel labelDefault = new JLabel(defaultText);
+ labelDefault.setHorizontalAlignment(0);
+ panel.add(labelDefault, "CardDefault");
+ panel.add(component, "CardContent");
+
+ return panel;
+ }
+
+ /**
+ * EFFECTS: Create a JScrollPane-wrapped JTable.
+ * MODIFIES: table
+ */
+ public static JScrollPane scrTbl(JTable table) {
+ final JScrollPane scrollPane = new JScrollPane();
+ table.setFillsViewportHeight(true);
+ scrollPane.setViewportView(table);
+ return scrollPane;
+ }
+
+ /**
+ * EFFECTS: Parse the given path and automatically determine if it is a DER binary or a PEM. Automatically decode
+ * PEM.
+ * Throws {@link IOException} if it cannot be read.
+ * Throws {@link ParseException} if the PEM is invalid.
+ */
+ public static Byte[] openDERorPEM(Path path, String tag) throws IOException, ParseException {
+ final InputStream fd = Files.newInputStream(path, StandardOpenOption.READ);
+ Byte[] bs = Utils.byteToByte(fd.readAllBytes());
+ fd.close();
+ if (bs.length < 1) {
+ throw new ParseException("Invalid file: too short");
+ }
+ if (bs[0] == '-') {
+ bs = Utils.parsePEM(bs, tag);
+ }
+ return bs;
+ }
+
+ /**
+ * EFFECTS: Create a button with the given label and click listener.
+ */
+ public static JButton btn(String string, ActionListener onClick) {
+ final JButton btn = new JButton(string);
+ btn.addActionListener(onClick);
+ return btn;
+ }
+
+ /**
+ * EFFECTS: Create a button with the given label, mnemonic, and click listener.
+ */
+ public static JButton btn(String string, char m, ActionListener onClick) {
+ final JButton btn = new JButton(string);
+ btn.setMnemonic(m);
+ btn.setDisplayedMnemonicIndex(0);
+ btn.addActionListener(onClick);
+ return btn;
+ }
+
+ /**
+ * EFFECTS: Create a button with the given label, mnemonic, icon, and click listener.
+ */
+ public static JButton btn(String string, String icon, ActionListener onClick) {
+ final JButton btn = new JButton(string, new ImageIcon(UIUtils.class.getResource("/" + icon)));
+ btn.addActionListener(onClick);
+ return btn;
+ }
+}
diff --git a/src/resources/deletetest.png b/src/resources/deletetest.png
new file mode 100644
index 0000000..fa0347c
--- /dev/null
+++ b/src/resources/deletetest.png
Binary files differ
diff --git a/src/resources/disable.png b/src/resources/disable.png
new file mode 100644
index 0000000..e526641
--- /dev/null
+++ b/src/resources/disable.png
Binary files differ
diff --git a/src/resources/enable.png b/src/resources/enable.png
new file mode 100644
index 0000000..0d67741
--- /dev/null
+++ b/src/resources/enable.png
Binary files differ
diff --git a/src/resources/export.png b/src/resources/export.png
new file mode 100644
index 0000000..e33ecd5
--- /dev/null
+++ b/src/resources/export.png
Binary files differ
diff --git a/src/resources/new.png b/src/resources/new.png
new file mode 100644
index 0000000..1e91eec
--- /dev/null
+++ b/src/resources/new.png
Binary files differ
diff --git a/src/resources/open.png b/src/resources/open.png
new file mode 100644
index 0000000..eb0af28
--- /dev/null
+++ b/src/resources/open.png
Binary files differ
diff --git a/src/resources/publisher.png b/src/resources/publisher.png
new file mode 100644
index 0000000..a15fa4f
--- /dev/null
+++ b/src/resources/publisher.png
Binary files differ
diff --git a/src/resources/saveall.png b/src/resources/saveall.png
new file mode 100644
index 0000000..4ad800d
--- /dev/null
+++ b/src/resources/saveall.png
Binary files differ
diff --git a/src/resources/verified.png b/src/resources/verified.png
new file mode 100644
index 0000000..5230d8a
--- /dev/null
+++ b/src/resources/verified.png
Binary files differ
diff --git a/src/test/model/GroupObserverTest.java b/src/test/model/GroupObserverTest.java
new file mode 100644
index 0000000..618f0eb
--- /dev/null
+++ b/src/test/model/GroupObserverTest.java
@@ -0,0 +1,65 @@
+package model;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class GroupObserverTest {
+ private GroupObserver target;
+
+ private MockObserver<Integer> obsInt;
+ private MockObserver<String> obsStr;
+
+ private MockObserver<Observer> obsObs;
+
+ private MockObserver<ArrayList> obsArrList;
+
+ private MockObserver<List> obsList;
+
+ @BeforeEach
+ void setup() {
+ target = new GroupObserver();
+ obsInt = new MockObserver<>();
+ obsStr = new MockObserver<>();
+ obsObs = new MockObserver<>();
+ obsArrList = new MockObserver<>();
+ obsList = new MockObserver<>();
+ target.register(Integer.class, obsInt);
+ target.register(String.class, obsStr);
+ target.register(Observer.class, obsObs);
+ target.register(List.class, obsList);
+ target.register(ArrayList.class, obsArrList);
+ }
+
+ @Test
+ void testRegister() {
+ assertEquals(5, target.getRegisteredObserverCount());
+ }
+
+ @Test
+ void testAccept() {
+ target.accept(this, Observer.DIRECTION_CHANGE, 100);
+ assertEquals(0, obsInt.getCount());
+ assertEquals(0, obsStr.getCount());
+
+ target.accept("123", Observer.DIRECTION_CHANGE, 1000);
+ obsStr.assertCalled(Observer.DIRECTION_CHANGE);
+ assertEquals(0, obsInt.getCount());
+ assertEquals(1000, obsStr.getIndex());
+
+ target.accept(114514, Observer.DIRECTION_ADD, 1919810);
+ obsInt.assertCalled(Observer.DIRECTION_ADD);
+ assertEquals(1919810, obsInt.getIndex());
+
+ target.accept(obsInt, Observer.DIRECTION_CHANGE, 1);
+ obsObs.assertCalled(Observer.DIRECTION_CHANGE);
+
+ target.accept(new ArrayList<>(), Observer.DIRECTION_CHANGE, 2);
+ obsArrList.assertCalled(Observer.DIRECTION_CHANGE);
+ assertEquals(0, obsList.getCount());
+ }
+}
diff --git a/src/test/model/MockObserver.java b/src/test/model/MockObserver.java
new file mode 100644
index 0000000..bd3e79f
--- /dev/null
+++ b/src/test/model/MockObserver.java
@@ -0,0 +1,41 @@
+package model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class MockObserver<T> implements Observer<T> {
+ private int countPrev;
+ private int count;
+ private T data;
+ private int dir;
+ private int index;
+
+ @Override
+ public void accept(T data, int direction, int index) {
+ this.count++;
+ this.data = data;
+ this.dir = direction;
+ this.index = index;
+ }
+
+ public void assertCalled(int dir) {
+ assertEquals(countPrev + 1, count);
+ assertEquals(dir, this.dir);
+ countPrev = count;
+ }
+
+ public int getCount() {
+ return count;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public int getDir() {
+ return dir;
+ }
+
+ public int getIndex() {
+ return index;
+ }
+}
diff --git a/src/test/model/ObservedDataTest.java b/src/test/model/ObservedDataTest.java
new file mode 100644
index 0000000..60ed2ed
--- /dev/null
+++ b/src/test/model/ObservedDataTest.java
@@ -0,0 +1,44 @@
+package model;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class ObservedDataTest {
+ private ObservedData<Integer> target;
+
+ private int callbackCount = 0;
+ private int callbackDirection = Integer.MIN_VALUE;
+ private int callbackIndex = Integer.MIN_VALUE;
+
+ private final Observer<Integer> obs = ((data, direction, index) -> {
+ callbackCount++;
+ callbackDirection = direction;
+ callbackIndex = index;
+ });
+
+ @BeforeEach
+ void setup() {
+ target = new ObservedData<>(0, obs);
+ callbackCount = 0;
+ callbackDirection = Integer.MIN_VALUE;
+ callbackIndex = Integer.MIN_VALUE;
+ }
+
+ @Test
+ void testConstructor() {
+ assertEquals(0, target.get());
+ assertNotNull(target.getAcceptor());
+ }
+
+ @Test
+ void testSet() {
+ target.set(114514);
+ assertEquals(114514, target.get());
+ assertEquals(1, callbackCount);
+ assertEquals(Observer.DIRECTION_CHANGE, callbackDirection);
+ assertEquals(Observer.INDEX_NOT_IN_LIST, callbackIndex);
+ }
+}
diff --git a/src/test/model/asn1/ObjectIdentifierTest.java b/src/test/model/asn1/ObjectIdentifierTest.java
index 367e349..5d49821 100644
--- a/src/test/model/asn1/ObjectIdentifierTest.java
+++ b/src/test/model/asn1/ObjectIdentifierTest.java
@@ -86,4 +86,21 @@ public class ObjectIdentifierTest {
new ObjectIdentifier(new BytesReader(new Byte[]{0x6, 0x9, 0x2A, -122, 0x48, -122, -9, 0x0D,
0x01, 0x01, -117}), false));
}
+
+ @Test
+ void testGetKnown() throws ParseException {
+ assertNotNull(ObjectIdentifier.getKnown("C"));
+ assertNotNull(ObjectIdentifier.getKnown("CN"));
+ assertNotNull(ObjectIdentifier.getKnown("O"));
+ assertNotNull(ObjectIdentifier.getKnown("OU"));
+ assertNotNull(ObjectIdentifier.getKnown("DC"));
+ assertNotNull(ObjectIdentifier.getKnown("L"));
+
+ assertNotNull(ObjectIdentifier.getKnown("oU"));
+ assertNotNull(ObjectIdentifier.getKnown("Dc"));
+ assertNotNull(ObjectIdentifier.getKnown("cn"));
+
+ assertThrows(ParseException.class, () -> ObjectIdentifier.getKnown("114514"));
+ assertThrows(ParseException.class, () -> ObjectIdentifier.getKnown("SN"));
+ }
}
diff --git a/src/test/model/ca/CertificationAuthorityTest.java b/src/test/model/ca/CertificationAuthorityTest.java
index 0a9a3eb..7a2e50c 100644
--- a/src/test/model/ca/CertificationAuthorityTest.java
+++ b/src/test/model/ca/CertificationAuthorityTest.java
@@ -1,5 +1,8 @@
package model.ca;
+import model.GroupObserver;
+import model.MockObserver;
+import model.Observer;
import model.asn1.ASN1Object;
import model.asn1.ObjectIdentifier;
import model.asn1.UtcTime;
@@ -17,6 +20,11 @@ import ui.Utils;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SignatureException;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.InvalidKeySpecException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@@ -302,6 +310,14 @@ public class CertificationAuthorityTest {
private CertificationAuthority caWithPrivateKey;
+ private MockObserver<RSAPublicKey> obsPkey;
+ private MockObserver<Certificate> obsCrt;
+ private MockObserver<RevokedCertificate> obsRev;
+ private MockObserver<Template> obsTmp;
+ private MockObserver<AuditLogEntry> obsLog;
+
+ private GroupObserver obs = new GroupObserver();
+
private static Certificate getCert(String pem) throws ParseException {
return new Certificate(new BytesReader(Utils.parsePEM(Utils.byteToByte(pem.getBytes(StandardCharsets.UTF_8)),
"CERTIFICATE")), false);
@@ -418,17 +434,15 @@ public class CertificationAuthorityTest {
new Template(template.getName(), true, (Name) null, template.getValidity()));
assertEquals(csr.getCertificationRequestInfo().getSubject().toString(),
cert.getCertificate().getSubject().toString());
- assertEquals(60,
- cert.getCertificate().getValidity().getNotAfter().getTimestamp().getDayOfYear()
- - cert.getCertificate().getValidity().getNotBefore().getTimestamp().getDayOfYear());
+ assertEquals(cert.getCertificate().getValidity().getNotBefore().getTimestamp(),
+ cert.getCertificate().getValidity().getNotAfter().getTimestamp().minusDays(60));
assertEquals(1, caWithPrivateKey.getSigned().size());
assertEquals(logCount + 1, caWithPrivateKey.getLogs().size());
Template tmp = new Template(template.getName(), true, "ABCC", template.getValidity());
cert = caWithPrivateKey.signCert(csr.getCertificationRequestInfo(), tmp);
- assertEquals(60,
- cert.getCertificate().getValidity().getNotAfter().getTimestamp().getDayOfYear()
- - cert.getCertificate().getValidity().getNotBefore().getTimestamp().getDayOfYear());
+ assertEquals(cert.getCertificate().getValidity().getNotBefore().getTimestamp(),
+ cert.getCertificate().getValidity().getNotAfter().getTimestamp().minusDays(60));
assertEquals(tmp.getSubject().toString(), cert.getCertificate().getSubject().toString());
assertEquals(2, caWithPrivateKey.getSigned().size());
assertEquals(logCount + 2, caWithPrivateKey.getLogs().size());
@@ -489,4 +503,57 @@ public class CertificationAuthorityTest {
assertEquals(++logCount, ca.getLogs().size());
assertNull(ca.findTemplate(template.getName(), false));
}
+
+ @Test
+ void testObservers() throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidCAException, ParseException,
+ SignatureException, InvalidKeyException {
+ obs.register(RSAPublicKey.class, obsPkey = new MockObserver<>());
+ obs.register(Certificate.class, obsCrt = new MockObserver<>());
+ obs.register(RevokedCertificate.class, obsRev = new MockObserver<>());
+ obs.register(Template.class, obsTmp = new MockObserver<>());
+ obs.register(AuditLogEntry.class, obsLog = new MockObserver<>());
+ ca.registerObserver(obs);
+
+ ca.loadKey(KEY_N, KEY_P, KEY_E);
+ obsPkey.assertCalled(Observer.DIRECTION_CHANGE);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.signCSR();
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.installCertificate(crtNormal);
+ obsCrt.assertCalled(Observer.DIRECTION_CHANGE);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.addTemplate(template);
+ obsTmp.assertCalled(Observer.DIRECTION_ADD);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.removeTemplate(template);
+ obsTmp.assertCalled(Observer.DIRECTION_REMOVE);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.addTemplate(template);
+ obsTmp.assertCalled(Observer.DIRECTION_ADD);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.setTemplateEnable(template, true);
+ obsTmp.assertCalled(Observer.DIRECTION_CHANGE);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ final Certificate cert = ca.signCert(csr.getCertificationRequestInfo(),
+ new Template(template.getName(), true, (Name) null, template.getValidity()));
+ obsCrt.assertCalled(Observer.DIRECTION_ADD);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.revoke(new RevokedCertificate(ASN1Object.TAG_SEQUENCE, null,
+ cert.getCertificate().getSerialNumber(),
+ new UtcTime(UtcTime.TAG, null, ZonedDateTime.now(ZoneId.of("UTC"))),
+ Reason.KEY_COMPROMISE));
+ obsRev.assertCalled(Observer.DIRECTION_ADD);
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+
+ ca.signCRL();
+ obsLog.assertCalled(Observer.DIRECTION_ADD);
+ }
}
diff --git a/src/test/model/ca/TemplateTest.java b/src/test/model/ca/TemplateTest.java
index 1926078..774d38c 100644
--- a/src/test/model/ca/TemplateTest.java
+++ b/src/test/model/ca/TemplateTest.java
@@ -34,6 +34,9 @@ public class TemplateTest {
new PrintableString(PrintableString.TAG, null, "CA"))})}),
123);
assertEquals("CN=Test,C=CA", template.getSubject().toString());
+
+ template = new Template("123", true, (String) null, 123);
+ assertNull(template.getSubject());
}
@Test
diff --git a/src/test/model/x501/AttributeTypeAndValueTest.java b/src/test/model/x501/AttributeTypeAndValueTest.java
index 7739e55..b3948fe 100644
--- a/src/test/model/x501/AttributeTypeAndValueTest.java
+++ b/src/test/model/x501/AttributeTypeAndValueTest.java
@@ -86,5 +86,9 @@ public class AttributeTypeAndValueTest {
assertEquals("C=IT", new AttributeTypeAndValue(new BytesReader(new Byte[]{
0x30, 0x9, 0x6, 0x3, 0x55, 0x4, 0x6, 0x13, 0x2, 0x49, 0x54
}), false).toString());
+ assertEquals("CN=IT\\,\\+\\=", new AttributeTypeAndValue(ASN1Object.TAG_SEQUENCE, null,
+ new ObjectIdentifier(TAG, null, OID_CN),
+ new PrintableString(PrintableString.TAG, null, "IT,+="))
+ .toString());
}
}
diff --git a/src/test/model/x501/NameTest.java b/src/test/model/x501/NameTest.java
index 22307f2..435ed71 100644
--- a/src/test/model/x501/NameTest.java
+++ b/src/test/model/x501/NameTest.java
@@ -164,4 +164,38 @@ public class NameTest {
assertEquals("CN=yuuta,OU=users,C=CA", TestConstants.NAME_1.toString());
assertEquals("SN=Qwq,O=IT,C=CN", TestConstants.NAME_2.toString());
}
+
+ private static void assertParseString(String dn) throws ParseException {
+ assertEquals(dn, Name.parseString(dn).toString());
+ }
+
+ @Test
+ void testParseStringSuccess() throws ParseException {
+ assertParseString("CN=a");
+ assertParseString("CN=a+L=b");
+ assertParseString("CN=a+L=b,OU=c");
+ assertParseString("CN=a+L=b,OU=c+CN=d");
+ assertParseString("CN=a+L=b,OU=c+CN=d,C=e");
+ assertParseString("CN=Web,OU=Computers,O=IT");
+ assertParseString("CN=Web,OU=Computers,O=IT\\,Meow\\+qwq,C=CN");
+ }
+
+ @Test
+ void testParseStringFail() {
+ assertThrows(ParseException.class, () -> Name.parseString(""));
+ assertThrows(ParseException.class, () -> Name.parseString("C"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN="));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a+"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=+"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=,"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C="));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,="));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=,"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C+"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=b,OOO"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=b,OOO="));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=b,OU=+"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=b,OU,"));
+ assertThrows(ParseException.class, () -> Name.parseString("CN=a,C=b=c"));
+ }
}
diff --git a/src/test/persistence/DecoderTest.java b/src/test/persistence/DecoderTest.java
index 96f94e7..418087b 100644
--- a/src/test/persistence/DecoderTest.java
+++ b/src/test/persistence/DecoderTest.java
@@ -26,6 +26,9 @@ public class DecoderTest {
@BeforeEach
void setup() {
+ // Dr. Paul: You can make dummy constructor calls to make the coverage happy since they are util classes.
+ new FS();
+ new Decoder();
invalidKey1 = FS.read(Path.of("data", "invalid_key_1.json"));
invalidKey2 = FS.read(Path.of("data", "invalid_key_2.json"));
invalidCert = FS.read(Path.of("data", "invalid_cert.json"));
diff --git a/tests/Makefile b/tests/Makefile
index 545114f..e39775c 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -28,9 +28,10 @@ sub.crt: sub.csr
openssl ca \
-verbose \
-config ca.cnf \
- -extensions extensions_sub \
+ -extensions extensions_sub_normal \
-notext \
-rand_serial \
+ -inform DER \
-in sub.csr \
-out sub.crt