diff options
author | Yuuta Liang <yuutaw@student.cs.ubc.ca> | 2023-11-23 08:09:01 +0800 |
---|---|---|
committer | Yuuta Liang <yuutaw@student.cs.ubc.ca> | 2023-11-23 08:09:01 +0800 |
commit | 65ea6c17a0c1348aa9ef4e158102ddf173936882 (patch) | |
tree | 7615366f76b6c94f46d8039aa20091f9ccd5609a /src/main | |
parent | b94b18c133f06cb176d8aa8bb40a8e24918d9ed6 (diff) | |
download | jca-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>
Diffstat (limited to 'src/main')
-rw-r--r-- | src/main/model/GroupObserver.java | 48 | ||||
-rw-r--r-- | src/main/model/ObservedData.java | 47 | ||||
-rw-r--r-- | src/main/model/Observer.java | 33 | ||||
-rw-r--r-- | src/main/model/asn1/ObjectIdentifier.java | 16 | ||||
-rw-r--r-- | src/main/model/ca/CertificationAuthority.java | 94 | ||||
-rw-r--r-- | src/main/model/ca/Template.java | 2 | ||||
-rw-r--r-- | src/main/model/x501/AttributeTypeAndValue.java | 9 | ||||
-rw-r--r-- | src/main/model/x501/Name.java | 107 | ||||
-rw-r--r-- | src/main/ui/IssueDialog.java | 110 | ||||
-rw-r--r-- | src/main/ui/Main.java | 4 | ||||
-rw-r--r-- | src/main/ui/MainUI.form | 111 | ||||
-rw-r--r-- | src/main/ui/MainUI.java | 690 | ||||
-rw-r--r-- | src/main/ui/RevokeDialog.java | 186 | ||||
-rw-r--r-- | src/main/ui/TemplateEditDialog.java | 82 | ||||
-rw-r--r-- | src/main/ui/widgets/CertEditDialog.java | 108 | ||||
-rw-r--r-- | src/main/ui/widgets/CertTableModel.java | 129 | ||||
-rw-r--r-- | src/main/ui/widgets/GCBuilder.java | 213 | ||||
-rw-r--r-- | src/main/ui/widgets/LogTableModel.java | 79 | ||||
-rw-r--r-- | src/main/ui/widgets/TemplateTableModel.java | 105 | ||||
-rw-r--r-- | src/main/ui/widgets/UIUtils.java | 168 |
20 files changed, 2182 insertions, 159 deletions
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; + } +} |