Compare commits

...

9 commits

12 changed files with 280 additions and 42 deletions

View file

@ -1,29 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: SOP-Java
Upstream-Contact: Paul Schaub <info@pgpainless.org>
Source: https://pgpainless.org
# Sample paragraph, commented out:
#
# Files: src/*
# Copyright: $YEAR $NAME <$CONTACT>
# License: ...
# Gradle build tool
Files: gradle*
Copyright: 2015 the original author or authors.
License: Apache-2.0
# Woodpecker build files
Files: .woodpecker/*
Copyright: 2022 the original author or authors.
License: Apache-2.0
Files: external-sop/src/main/resources/sop/testsuite/external/*
Copyright: 2023 the original author or authors
License: Apache-2.0
# Github Issue Templates
Files: .github/ISSUE_TEMPLATE/*
Copyright: 2024 the original author or authors
License: Apache-2.0

32
REUSE.toml Normal file
View file

@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: 2025 Paul Schaub <info@pgpainless.org>
#
# SPDX-License-Identifier: CC0-1.0
version = 1
SPDX-PackageName = "SOP-Java"
SPDX-PackageSupplier = "Paul Schaub <info@pgpainless.org>"
SPDX-PackageDownloadLocation = "https://pgpainless.org"
[[annotations]]
path = "gradle**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2015 the original author or authors."
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ".woodpecker/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2022 the original author or authors."
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = "external-sop/src/main/resources/sop/testsuite/external/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2023 the original author or authors"
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = ".github/ISSUE_TEMPLATE/**"
precedence = "aggregate"
SPDX-FileCopyrightText = "2024 the original author or authors"
SPDX-License-Identifier = "Apache-2.0"

View file

@ -21,7 +21,7 @@ class UpdateKeyExternal(binary: String, environment: Properties) : UpdateKey {
override fun signingOnly(): UpdateKey = apply { commandList.add("--signing-only") }
override fun noNewMechanisms(): UpdateKey = apply { commandList.add("--no-new-mechanisms") }
override fun noAddedCapabilities(): UpdateKey = apply { commandList.add("--no-added-capabilities") }
override fun withKeyPassword(password: ByteArray): UpdateKey = apply {
commandList.add("--with-key-password=@ENV:KEY_PASSWORD_$argCount")

View file

@ -88,7 +88,10 @@ class SopCLI {
// Hide generate-completion command
subcommands["generate-completion"]?.commandSpec?.usageMessage()?.hidden(true)
// render Input/Output sections in help command
subcommands.values.filter { (it.getCommand() as Any) is AbstractSopCmd } // Only for AbstractSopCmd objects
subcommands.values
.filter {
(it.getCommand() as Any) is AbstractSopCmd
} // Only for AbstractSopCmd objects
.forEach { (it.getCommand() as AbstractSopCmd).installIORenderer(it) }
// overwrite executable name
commandName = EXECUTABLE_NAME
@ -96,7 +99,8 @@ class SopCLI {
executionExceptionHandler = SOPExecutionExceptionHandler()
exitCodeExceptionMapper = SOPExceptionExitCodeMapper()
isCaseInsensitiveEnumValuesAllowed = true
}.execute(*args)
}
.execute(*args)
}
}

View file

@ -16,7 +16,7 @@ import sop.exception.SOPGPException
exitCodeOnInvalidInput = SOPGPException.UnsupportedOption.EXIT_CODE)
class MergeCertsCmd : AbstractSopCmd() {
@CommandLine.Option(names = ["--no-armor"], negatable = true) var armor = false
@CommandLine.Option(names = ["--no-armor"], negatable = true) var armor = true
@CommandLine.Parameters(paramLabel = "CERTS") var updates: List<String> = listOf()

View file

@ -20,7 +20,7 @@ class UpdateKeyCmd : AbstractSopCmd() {
@Option(names = ["--signing-only"]) var signingOnly = false
@Option(names = ["--no-new-mechanisms"]) var noNewMechanisms = false
@Option(names = ["--no-added-capabilities"]) var noAddedCapabilities = false
@Option(names = ["--with-key-password"], paramLabel = "PASSWORD")
var withKeyPassword: List<String> = listOf()
@ -38,8 +38,8 @@ class UpdateKeyCmd : AbstractSopCmd() {
updateKey.signingOnly()
}
if (noNewMechanisms) {
updateKey.noNewMechanisms()
if (noAddedCapabilities) {
updateKey.noAddedCapabilities()
}
for (passwordFileName in withKeyPassword) {

View file

@ -5,13 +5,13 @@
package sop.cli.picocli.commands
import java.io.IOException
import java.util.*
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.Parameters
import sop.cli.picocli.SopCLI
import sop.exception.SOPGPException
import sop.util.HexUtil.Companion.bytesToHex
import java.util.*
@Command(
name = "validate-userid",

View file

@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: 2025 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package sop.testsuite.operation;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import sop.SOP;
import sop.exception.SOPGPException;
import java.io.IOException;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@EnabledIf("sop.testsuite.operation.AbstractSOPTest#hasBackends")
public class CertifyValidateUserIdTest {
static Stream<Arguments> provideInstances() {
return AbstractSOPTest.provideBackends();
}
@ParameterizedTest
@MethodSource("provideInstances")
public void certifyUserId(SOP sop) throws IOException {
byte[] aliceKey = sop.generateKey()
.withKeyPassword("sw0rdf1sh")
.userId("Alice <alice@pgpainless.org>")
.generate()
.getBytes();
byte[] aliceCert = sop.extractCert()
.key(aliceKey)
.getBytes();
byte[] bobKey = sop.generateKey()
.userId("Bob <bob@pgpainless.org>")
.generate()
.getBytes();
byte[] bobCert = sop.extractCert()
.key(bobKey)
.getBytes();
// Alice has her own user-id self-certified
assertTrue(sop.validateUserId()
.authorities(aliceCert)
.userId("Alice <alice@pgpainless.org>")
.subjects(aliceCert),
"Alice accepts her own self-certified user-id");
// Alice has not yet certified Bobs user-id
assertFalse(sop.validateUserId()
.authorities(aliceCert)
.userId("Bob <bob@pgpainless.org>")
.subjects(bobCert),
"Alice has not yet certified Bobs user-id");
byte[] bobCertifiedByAlice = sop.certifyUserId()
.userId("Bob <bob@pgpainless.org>")
.withKeyPassword("sw0rdf1sh")
.keys(aliceKey)
.certs(bobCert)
.getBytes();
assertTrue(sop.validateUserId()
.userId("Bob <bob@pgpainless.org>")
.authorities(aliceCert)
.subjects(bobCertifiedByAlice),
"Alice accepts Bobs user-id after she certified it");
}
@ParameterizedTest
@MethodSource("provideInstances")
public void addPetName(SOP sop) throws IOException {
byte[] aliceKey = sop.generateKey()
.userId("Alice <alice@pgpainless.org>")
.generate()
.getBytes();
byte[] aliceCert = sop.extractCert()
.key(aliceKey)
.getBytes();
byte[] bobKey = sop.generateKey()
.userId("Bob <bob@pgpainless.org>")
.generate()
.getBytes();
byte[] bobCert = sop.extractCert()
.key(bobKey)
.getBytes();
assertThrows(SOPGPException.CertUserIdNoMatch.class, () ->
sop.certifyUserId()
.userId("Bobby")
.keys(aliceKey)
.certs(bobCert)
.getBytes(),
"Alice cannot create a pet-name for Bob without the --no-require-self-sig flag");
byte[] bobWithPetName = sop.certifyUserId()
.userId("Bobby")
.noRequireSelfSig()
.keys(aliceKey)
.certs(bobCert)
.getBytes();
assertTrue(sop.validateUserId()
.userId("Bobby")
.authorities(aliceCert)
.subjects(bobWithPetName),
"Alice accepts the pet-name she gave to Bob");
assertFalse(sop.validateUserId()
.userId("Bobby")
.authorities(bobWithPetName)
.subjects(bobWithPetName),
"Bob does not accept the pet-name Alice gave him");
}
@ParameterizedTest
@MethodSource("provideInstances")
public void certifyWithRevokedKey(SOP sop) throws IOException {
byte[] aliceKey = sop.generateKey()
.userId("Alice <alice@pgpainless.org>")
.generate()
.getBytes();
byte[] aliceRevokedCert = sop.revokeKey()
.keys(aliceKey)
.getBytes();
byte[] aliceRevokedKey = sop.updateKey()
.mergeCerts(aliceRevokedCert)
.key(aliceKey)
.getBytes();
byte[] bobKey = sop.generateKey()
.userId("Bob <bob@pgpainless.org>")
.generate()
.getBytes();
byte[] bobCert = sop.extractCert()
.key(bobKey)
.getBytes();
assertThrows(SOPGPException.KeyCannotCertify.class, () ->
sop.certifyUserId()
.userId("Bob <bob@pgpainless.org>")
.keys(aliceRevokedKey)
.certs(bobCert)
.getBytes());
}
}

View file

@ -9,9 +9,10 @@ package sop
*
* @param micAlg string identifying the digest mechanism used to create the signed message. This is
* useful for setting the `micalg=` parameter for the multipart/signed content-type of a PGP/MIME
* object as described in section 5 of [RFC3156](https://www.rfc-editor.org/rfc/rfc3156#section-5).
* If more than one signature was generated and different digest mechanisms were used, the value
* of the micalg object is an empty string.
* object as described in section 5 of
* [RFC3156](https://www.rfc-editor.org/rfc/rfc3156#section-5). If more than one signature was
* generated and different digest mechanisms were used, the value of the micalg object is an empty
* string.
*/
data class SigningResult(val micAlg: MicAlg) {

View file

@ -16,6 +16,22 @@ abstract class SOPGPException : RuntimeException {
abstract fun getExitCode(): Int
/** An otherwise unspecified failure occurred */
class UnspecificFailure : SOPGPException {
constructor(message: String) : super(message)
constructor(message: String, e: Throwable) : super(message, e)
constructor(e: Throwable) : super(e)
override fun getExitCode(): Int = EXIT_CODE
companion object {
const val EXIT_CODE = 1
}
}
/** No acceptable signatures found (sop verify, inline-verify). */
class NoSignature : SOPGPException {
@JvmOverloads
@ -378,4 +394,23 @@ abstract class SOPGPException : RuntimeException {
const val EXIT_CODE = 107
}
}
/**
* Key not certification-capable (e.g., expired, revoked, unacceptable usage flags) (sop
* certify-userid)
*/
class KeyCannotCertify : SOPGPException {
constructor(message: String) : super(message)
constructor(message: String, e: Throwable) : super(message, e)
constructor(e: Throwable) : super(e)
override fun getExitCode(): Int = EXIT_CODE
companion object {
const val EXIT_CODE = 109
}
}
}

View file

@ -19,25 +19,61 @@ interface UpdateKey {
*/
fun noArmor(): UpdateKey
/**
* Allow key to be used for signing only.
* If this option is not present, the operation may add a new, encryption-capable component key.
*/
@Throws(SOPGPException.UnsupportedOption::class) fun signingOnly(): UpdateKey
@Throws(SOPGPException.UnsupportedOption::class) fun noNewMechanisms(): UpdateKey
/**
* Do not allow adding new capabilities to the key.
* If this option is not present, the operation may add support for new capabilities to the key.
*/
@Throws(SOPGPException.UnsupportedOption::class) fun noAddedCapabilities(): UpdateKey
/**
* Provide a passphrase for unlocking the secret key.
*
* @param password password
*/
@Throws(SOPGPException.PasswordNotHumanReadable::class, SOPGPException.UnsupportedOption::class)
fun withKeyPassword(password: String): UpdateKey =
withKeyPassword(password.toByteArray(UTF8Util.UTF8))
/**
* Provide a passphrase for unlocking the secret key.
*
* @param password password
*/
@Throws(SOPGPException.PasswordNotHumanReadable::class, SOPGPException.UnsupportedOption::class)
fun withKeyPassword(password: ByteArray): UpdateKey
/**
* Provide certificates that might contain updated signatures or third-party certifications.
* These certificates will be merged into the key.
*
* @param certs input stream of certificates
*/
@Throws(
SOPGPException.UnsupportedOption::class, SOPGPException.BadData::class, IOException::class)
fun mergeCerts(certs: InputStream): UpdateKey
/**
* Provide certificates that might contain updated signatures or third-party certifications.
* These certificates will be merged into the key.
*
* @param certs binary certificates
*/
@Throws(
SOPGPException.UnsupportedOption::class, SOPGPException.BadData::class, IOException::class)
fun mergeCerts(certs: ByteArray): UpdateKey = mergeCerts(certs.inputStream())
/**
* Provide the OpenPGP key to update.
*
* @param key input stream containing the key
* @return handle to acquire the updated OpenPGP key from
*/
@Throws(
SOPGPException.BadData::class,
IOException::class,
@ -45,6 +81,12 @@ interface UpdateKey {
SOPGPException.PrimaryKeyBad::class)
fun key(key: InputStream): Ready
/**
* Provide the OpenPGP key to update.
*
* @param key binary OpenPGP key
* @return handle to acquire the updated OpenPGP key from
*/
@Throws(
SOPGPException.BadData::class,
IOException::class,

View file

@ -6,8 +6,8 @@ package sop.operation
import java.io.IOException
import java.io.InputStream
import sop.exception.SOPGPException
import java.util.*
import sop.exception.SOPGPException
/** Subcommand to validate UserIDs on certificates. */
interface ValidateUserId {