diff --git a/sop-java-picocli/src/main/kotlin/sop/cli/picocli/SopVCLI.kt b/sop-java-picocli/src/main/kotlin/sop/cli/picocli/SopVCLI.kt index fa9683f..311a446 100644 --- a/sop-java-picocli/src/main/kotlin/sop/cli/picocli/SopVCLI.kt +++ b/sop-java-picocli/src/main/kotlin/sop/cli/picocli/SopVCLI.kt @@ -45,7 +45,8 @@ class SopVCLI { @JvmField var EXECUTABLE_NAME = "sopv" @JvmField - @CommandLine.Option(names = ["--stacktrace", "--debug"], scope = CommandLine.ScopeType.INHERIT) + @CommandLine.Option( + names = ["--stacktrace", "--debug"], scope = CommandLine.ScopeType.INHERIT) var stacktrace = false @JvmStatic diff --git a/sop-java/src/main/kotlin/sop/Verification.kt b/sop-java/src/main/kotlin/sop/Verification.kt index a8db800..982e691 100644 --- a/sop-java/src/main/kotlin/sop/Verification.kt +++ b/sop-java/src/main/kotlin/sop/Verification.kt @@ -10,6 +10,15 @@ import sop.enums.SignatureMode import sop.util.Optional import sop.util.UTCUtil +/** + * Metadata about a verified signature. + * + * @param creationTime creation time of the signature + * @param signingKeyFingerprint fingerprint of the (sub-)key that issued the signature + * @param signingCertFingerprint fingerprint of the certificate that contains the signing key + * @param signatureMode optional signature mode (text/binary) + * @param jsonOrDescription arbitrary text or JSON data + */ data class Verification( val creationTime: Date, val signingKeyFingerprint: String, @@ -47,27 +56,28 @@ data class Verification( Optional.ofNullable(signatureMode), Optional.of(jsonSerializer.serialize(json))) - @Deprecated("Replaced by jsonOrDescription", - replaceWith = ReplaceWith("jsonOrDescription") - ) + @Deprecated("Replaced by jsonOrDescription", replaceWith = ReplaceWith("jsonOrDescription")) val description = jsonOrDescription + /** This value is `true` if the [Verification] contains extension JSON. */ + val containsJson: Boolean = + jsonOrDescription.get()?.trim()?.let { it.startsWith("{") && it.endsWith("}") } ?: false + /** - * Attempt to parse the [jsonOrDescription] field using the provided [JSONParser] and return the result. - * This method returns `null` if parsing fails. + * Attempt to parse the [jsonOrDescription] field using the provided [JSONParser] and return the + * result. This method returns `null` if parsing fails. * * @param parser [JSONParser] implementation * @return successfully parsed [JSON] POJO or `null`. */ fun getJson(parser: JSONParser): JSON? { - return jsonOrDescription.get() - ?.let { - try { - parser.parse(it) - } catch (e: ParseException) { - null - } + return jsonOrDescription.get()?.let { + try { + parser.parse(it) + } catch (e: ParseException) { + null } + } } override fun toString(): String = @@ -116,35 +126,37 @@ data class Verification( /** * POJO data class representing JSON metadata. * - * @param signers list of supplied CERTS objects that could have issued the signature, identified by - * the name given on the command line. + * @param signers list of supplied CERTS objects that could have issued the signature, + * identified by the name given on the command line. * @param comment a freeform UTF-8 encoded text describing the verification * @param ext an extension object containing arbitrary, implementation-specific data */ - data class JSON( - val signers: List, - val comment: String?, - val ext: Any?) + data class JSON(val signers: List, val comment: String?, val ext: Any?) { - /** - * Interface abstracting a JSON parser that parses [JSON] POJOs from single-line strings. - */ + /** Create a JSON object with only a list of signers. */ + constructor(signers: List) : this(signers, null, null) + + /** Create a JSON object with only a single signer. */ + constructor(signer: String) : this(listOf(signer)) + } + + /** Interface abstracting a JSON parser that parses [JSON] POJOs from single-line strings. */ fun interface JSONParser { /** - * Parse a [JSON] POJO from the given single-line [string]. - * If the string does not represent a JSON object matching the [JSON] definition, - * this method throws a [ParseException]. + * Parse a [JSON] POJO from the given single-line [string]. If the string does not represent + * a JSON object matching the [JSON] definition, this method throws a [ParseException]. * * @param string [String] representation of the [JSON] object. * @return parsed [JSON] POJO - * @throws ParseException if the [string] is not a JSON string representing the [JSON] object. + * @throws ParseException if the [string] is not a JSON string representing the [JSON] + * object. */ - @Throws(ParseException::class) - fun parse(string: String): JSON + @Throws(ParseException::class) fun parse(string: String): JSON } /** - * Interface abstracting a JSON serializer that converts [JSON] POJOs into single-line JSON strings. + * Interface abstracting a JSON serializer that converts [JSON] POJOs into single-line JSON + * strings. */ fun interface JSONSerializer { diff --git a/sop-java/src/test/java/sop/VerificationJSONTest.java b/sop-java/src/test/java/sop/VerificationJSONTest.java new file mode 100644 index 0000000..10253d8 --- /dev/null +++ b/sop-java/src/test/java/sop/VerificationJSONTest.java @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2025 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import sop.enums.SignatureMode; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class VerificationJSONTest { + + // A hacky self-made "JSON parser" stand-in. + // Only used for testing, do not use in production! + private Verification.JSONParser dummyParser = new Verification.JSONParser() { + @NotNull + @Override + public Verification.JSON parse(@NotNull String string) throws ParseException { + if (!string.startsWith("{")) { + throw new ParseException("Alleged JSON String does not begin with '{'", 0); + } + if (!string.endsWith("}")) { + throw new ParseException("Alleged JSON String does not end with '}'", string.length() - 1); + } + + List signersList = new ArrayList<>(); + Matcher signersMat = Pattern.compile("\"signers\": \\[(.*?)\\]").matcher(string); + if (signersMat.find()) { + String signersCat = signersMat.group(1); + String[] split = signersCat.split(","); + for (String s : split) { + s = s.trim(); + signersList.add(s.substring(1, s.length() - 1)); + } + } + + String comment = null; + Matcher commentMat = Pattern.compile("\"comment\": \"(.*?)\"").matcher(string); + if (commentMat.find()) { + comment = commentMat.group(1); + } + + String ext = null; + Matcher extMat = Pattern.compile("\"ext\": (.*?})}").matcher(string); + if (extMat.find()) { + ext = extMat.group(1); + } + + return new Verification.JSON(signersList, comment, ext); + } + }; + + // A just as hacky "JSON Serializer" lookalike. + // Also don't use in production, for testing only! + private Verification.JSONSerializer dummySerializer = new Verification.JSONSerializer() { + @NotNull + @Override + public String serialize(@NotNull Verification.JSON json) { + if (json.getSigners().isEmpty() && json.getComment() == null && json.getExt() == null) { + return ""; + } + StringBuilder sb = new StringBuilder("{"); + boolean comma = false; + + if (!json.getSigners().isEmpty()) { + comma = true; + sb.append("\"signers\": ["); + for (Iterator iterator = json.getSigners().iterator(); iterator.hasNext(); ) { + String signer = iterator.next(); + sb.append("\"").append(signer).append("\""); + if (iterator.hasNext()) { + sb.append(", "); + } + } + sb.append("]"); + } + + if (json.getComment() != null) { + if (comma) { + sb.append(", "); + } + comma = true; + sb.append("\"comment\": \"").append(json.getComment()).append("\""); + } + + if (json.getExt() != null) { + if (comma) { + sb.append(", "); + } + comma = true; + sb.append("\"ext\": ").append(json.getExt().toString()); + } + return sb.append("}").toString(); + } + }; + + @Test + public void testSimpleSerializeParse() throws ParseException { + String signer = "alice.pub"; + Verification.JSON json = new Verification.JSON(signer); + + String string = dummySerializer.serialize(json); + assertEquals("{\"signers\": [\"alice.pub\"]}", string); + + Verification.JSON parsed = dummyParser.parse(string); + assertEquals(signer, parsed.getSigners().get(0)); + assertEquals(1, parsed.getSigners().size()); + assertNull(parsed.getComment()); + assertNull(parsed.getExt()); + } + + @Test + public void testAdvancedSerializeParse() throws ParseException { + Verification.JSON json = new Verification.JSON( + Arrays.asList("../certs/alice.pub", "/etc/pgp/certs.pgp"), + "This is a comment", + "{\"Foo\": \"Bar\"}"); + + String serialized = dummySerializer.serialize(json); + assertEquals("{\"signers\": [\"../certs/alice.pub\", \"/etc/pgp/certs.pgp\"], \"comment\": \"This is a comment\", \"ext\": {\"Foo\": \"Bar\"}}", + serialized); + + Verification.JSON parsed = dummyParser.parse(serialized); + assertEquals(json.getSigners(), parsed.getSigners()); + assertEquals(json.getComment(), parsed.getComment()); + assertEquals(json.getExt(), parsed.getExt()); + } + + @Test + public void testVerificationWithSimpleJson() { + String string = "2019-10-29T18:36:45Z EB85BB5FA33A75E15E944E63F231550C4F47E38E EB85BB5FA33A75E15E944E63F231550C4F47E38E mode:text {\"signers\": [\"alice.pgp\"]}"; + Verification verification = Verification.fromString(string); + + assertTrue(verification.getContainsJson()); + assertEquals("EB85BB5FA33A75E15E944E63F231550C4F47E38E", verification.getSigningKeyFingerprint()); + assertEquals("EB85BB5FA33A75E15E944E63F231550C4F47E38E", verification.getSigningCertFingerprint()); + assertEquals(SignatureMode.text, verification.getSignatureMode().get()); + + Verification.JSON json = verification.getJson(dummyParser); + assertNotNull(json, "The verification string MUST contain valid extension json"); + + assertEquals(Collections.singletonList("alice.pgp"), json.getSigners()); + assertNull(json.getComment()); + assertNull(json.getExt()); + + verification = new Verification(verification.getCreationTime(), verification.getSigningKeyFingerprint(), verification.getSigningCertFingerprint(), verification.getSignatureMode().get(), json, dummySerializer); + assertEquals(string, verification.toString()); + } +} diff --git a/sop-java/src/test/java/sop/VerificationTest.java b/sop-java/src/test/java/sop/VerificationTest.java index e956435..1e10f61 100644 --- a/sop-java/src/test/java/sop/VerificationTest.java +++ b/sop-java/src/test/java/sop/VerificationTest.java @@ -13,6 +13,7 @@ import java.text.ParseException; import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; public class VerificationTest { @@ -25,6 +26,8 @@ public class VerificationTest { Verification verification = new Verification(signDate, keyFP, certFP); assertEquals("2022-11-07T15:01:24Z F9E6F53F7201C60A87064EAB0B27F2B0760A1209 4E2C78519512C2AE9A8BFE7EB3298EB2FBE5F51B", verification.toString()); + assertFalse(verification.getContainsJson()); + VerificationAssert.assertThatVerification(verification) .issuedBy(certFP) .isBySigningKey(keyFP)