From f99126d86c0f83363487d0a9603978a7ac513a2c Mon Sep 17 00:00:00 2001 From: Alexandre Archambault Date: Mon, 28 Feb 2022 12:18:39 +0100 Subject: [PATCH] Add signing support in publish command Using both gpg and bouncycastle --- build.sc | 36 +- .../scala/build/options/ConfigMonoid.scala | 6 + .../scala/build/options/PublishOptions.scala | 20 +- .../scala/scala/build/options/Secret.scala | 22 + .../UsingPublishDirectiveHandler.scala | 7 +- .../cli/internal/BouncyCastleFeature.java | 22 + .../scala-cli-core/native-image.properties | 1 + .../scala-cli-core/reflect-config.json | 1001 +++++++++++++++++ .../src/main/scala/scala/cli/ScalaCli.scala | 4 + .../scala/scala/cli/ScalaCliCommands.scala | 3 + .../scala/scala/cli/commands/Publish.scala | 71 +- .../scala/cli/commands/PublishOptions.scala | 46 +- .../scala/cli/commands/pgp/PgpCreate.scala | 52 + .../cli/commands/pgp/PgpCreateOptions.scala | 35 + .../scala/cli/commands/pgp/PgpHelper.scala | 126 +++ .../scala/cli/commands/pgp/PgpSign.scala | 55 + .../cli/commands/pgp/PgpSignOptions.scala | 22 + .../scala/cli/commands/pgp/PgpVerify.scala | 68 ++ .../cli/commands/pgp/PgpVerifyOptions.scala | 15 + .../scala/cli/internal/PasswordOption.scala | 60 + .../cli/publish/BouncycastleSigner.scala | 193 ++++ .../src/test/resources/test-keys/key.asc | 30 + .../src/test/resources/test-keys/key.skr | Bin 0 -> 2553 bytes .../scala/cli/integration/PublishTests.scala | 47 +- project/deps.sc | 51 +- website/docs/reference/cli-options.md | 56 + website/docs/reference/commands.md | 14 + 27 files changed, 2019 insertions(+), 44 deletions(-) create mode 100644 modules/build/src/main/scala/scala/build/options/Secret.scala create mode 100644 modules/cli/src/main/java/scala/cli/internal/BouncyCastleFeature.java create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreate.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreateOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpHelper.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSign.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSignOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerify.scala create mode 100644 modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerifyOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/internal/PasswordOption.scala create mode 100644 modules/cli/src/main/scala/scala/cli/publish/BouncycastleSigner.scala create mode 100644 modules/integration/src/test/resources/test-keys/key.asc create mode 100644 modules/integration/src/test/resources/test-keys/key.skr diff --git a/build.sc b/build.sc index 0282c7d3c5..43ba84cf8b 100644 --- a/build.sc +++ b/build.sc @@ -351,12 +351,14 @@ trait Cli extends SbtModule with CliLaunchers with ScalaCliPublishModule with Fo def repositories = super.repositories ++ customRepositories def ivyDeps = super.ivyDeps() ++ Agg( + Deps.bouncycastle, Deps.caseApp, Deps.coursierLauncher, Deps.coursierPublish, Deps.dataClass, Deps.jimfs, // scalaJsEnvNodeJs pulls jimfs:1.1, whose class path seems borked (bin compat issue with the guava version it depends on) Deps.jniUtils, + Deps.jsoniterScala, Deps.scalaJsLinker, Deps.scalaPackager, Deps.svmSubs, @@ -364,6 +366,7 @@ trait Cli extends SbtModule with CliLaunchers with ScalaCliPublishModule with Fo Deps.metaconfigTypesafe ) def compileIvyDeps = super.compileIvyDeps() ++ Agg( + Deps.jsoniterScalaMacros, Deps.svm ) def mainClass = Some("scala.cli.ScalaCli") @@ -394,15 +397,22 @@ trait CliIntegrationBase extends SbtModule with ScalaCliPublishModule with HasTe super.scalacOptions() ++ Seq("-Xasync", "-Ywarn-unused", "-deprecation") } - def sources = T.sources { + def modulesPath = T { val name = mainArtifactName().stripPrefix(prefix) val baseIntegrationPath = os.Path(millSourcePath.toString.stripSuffix(name)) - val modulesPath = os.Path( + val p = os.Path( baseIntegrationPath.toString.stripSuffix(baseIntegrationPath.baseName) ) - val mainPath = PathRef(modulesPath / "integration" / "src" / "main" / "scala") + PathRef(p) + } + def sources = T.sources { + val mainPath = PathRef(modulesPath().path / "integration" / "src" / "main" / "scala") super.sources() ++ Seq(mainPath) } + def resources = T.sources { + val mainPath = PathRef(modulesPath().path / "integration" / "src" / "main" / "resources") + super.resources() ++ Seq(mainPath) + } def ivyDeps = super.ivyDeps() ++ Agg( Deps.osLib @@ -422,15 +432,23 @@ trait CliIntegrationBase extends SbtModule with ScalaCliPublishModule with HasTe "SCALA_CLI_TMP" -> tmpDirBase().path.toString, "CI" -> "1" ) + private def updateRef(name: String, ref: PathRef): PathRef = { + val rawPath = ref.path.toString.replace( + File.separator + name + File.separator, + File.separator + ) + PathRef(os.Path(rawPath)) + } def sources = T.sources { val name = mainArtifactName().stripPrefix(prefix) super.sources().flatMap { ref => - val rawPath = ref.path.toString.replace( - File.separator + name + File.separator, - File.separator - ) - val base = PathRef(os.Path(rawPath)) - Seq(base, ref) + Seq(updateRef(name, ref), ref) + } + } + def resources = T.sources { + val name = mainArtifactName().stripPrefix(prefix) + super.resources().flatMap { ref => + Seq(updateRef(name, ref), ref) } } diff --git a/modules/build/src/main/scala/scala/build/options/ConfigMonoid.scala b/modules/build/src/main/scala/scala/build/options/ConfigMonoid.scala index 0bc9d7c970..51bf76d2fd 100644 --- a/modules/build/src/main/scala/scala/build/options/ConfigMonoid.scala +++ b/modules/build/src/main/scala/scala/build/options/ConfigMonoid.scala @@ -5,6 +5,9 @@ import shapeless._ trait ConfigMonoid[T] { def zero: T def orElse(main: T, defaults: T): T + + def sum(values: Seq[T]): T = + values.foldLeft(zero)(orElse(_, _)) } object ConfigMonoid { @@ -16,6 +19,9 @@ object ConfigMonoid { def orElse(main: T, defaults: T) = orElseFn(main, defaults) } + def sum[T](values: Seq[T])(implicit monoid: ConfigMonoid[T]): T = + monoid.sum(values) + trait HListConfigMonoid[T <: HList] { def zero: T def orElse(main: T, defaults: T): T diff --git a/modules/build/src/main/scala/scala/build/options/PublishOptions.scala b/modules/build/src/main/scala/scala/build/options/PublishOptions.scala index d1aa91ac60..2f964b6d36 100644 --- a/modules/build/src/main/scala/scala/build/options/PublishOptions.scala +++ b/modules/build/src/main/scala/scala/build/options/PublishOptions.scala @@ -16,7 +16,12 @@ final case class PublishOptions( scalaVersionSuffix: Option[String] = None, scalaPlatformSuffix: Option[String] = None, repository: Option[String] = None, - sourceJar: Option[Boolean] = None + sourceJar: Option[Boolean] = None, + gpgSignatureId: Option[String] = None, + gpgOptions: List[String] = Nil, + signer: Option[PublishOptions.Signer] = None, + secretKey: Option[os.Path] = None, + secretKeyPassword: Option[Secret[String]] = None ) object PublishOptions { @@ -26,6 +31,19 @@ object PublishOptions { final case class Developer(id: String, name: String, url: String, mail: Option[String] = None) final case class Vcs(url: String, connection: String, developerConnection: String) + sealed abstract class Signer extends Product with Serializable + object Signer { + case object Gpg extends Signer + case object BouncyCastle extends Signer + } + + def parseSigner(input: Positioned[String]): Either[MalformedInputError, Signer] = + input.value match { + case "gpg" => Right(Signer.Gpg) + case "bc" | "bouncycastle" => Right(Signer.BouncyCastle) + case _ => Left(new MalformedInputError("signer", input.value, "gpg|bc", input.positions)) + } + def parseLicense(input: Positioned[String]): Either[BuildException, Positioned[License]] = input.value.split(":", 2) match { case Array(name) => diff --git a/modules/build/src/main/scala/scala/build/options/Secret.scala b/modules/build/src/main/scala/scala/build/options/Secret.scala new file mode 100644 index 0000000000..8c71338ecb --- /dev/null +++ b/modules/build/src/main/scala/scala/build/options/Secret.scala @@ -0,0 +1,22 @@ +package scala.build.options + +final class Secret[+T]( + value0: T +) { + def value: T = value0 + + override def equals(obj: Any): Boolean = + obj match { + case other: Secret[_] => value == other.value + case _ => false + } + + // not leaking details about the secret here + override def hashCode(): Int = 0 + override def toString: String = "****" +} + +object Secret { + def apply[T](value: T): Secret[T] = + new Secret(value) +} diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala index 755d03d3b2..275f178291 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/UsingPublishDirectiveHandler.scala @@ -56,7 +56,8 @@ case object UsingPublishDirectiveHandler extends UsingDirectiveHandler { cwd: ScopePath, logger: Logger ): Either[BuildException, ProcessedUsingDirective] = either { - val singleValue = DirectiveUtil.singleStringValue(directive, path, cwd) + def singleValue = DirectiveUtil.singleStringValue(directive, path, cwd) + def severalValues = DirectiveUtil.stringValues(directive.values, path, cwd) if (!directive.key.startsWith(prefix)) value(Left(new UnexpectedDirectiveError(directive))) @@ -85,6 +86,10 @@ case object UsingPublishDirectiveHandler extends UsingDirectiveHandler { PublishOptions(scalaPlatformSuffix = Some(value(singleValue).value)) case "repository" => PublishOptions(repository = Some(value(singleValue).value)) + case "gpgKey" | "gpg-key" => + PublishOptions(gpgSignatureId = Some(value(singleValue).value)) + case "gpgOptions" | "gpg-options" | "gpgOption" | "gpg-option" => + PublishOptions(gpgOptions = severalValues.map(_._1.value).toList) case _ => value(Left(new UnexpectedDirectiveError(directive))) } diff --git a/modules/cli/src/main/java/scala/cli/internal/BouncyCastleFeature.java b/modules/cli/src/main/java/scala/cli/internal/BouncyCastleFeature.java new file mode 100644 index 0000000000..e0150e1cae --- /dev/null +++ b/modules/cli/src/main/java/scala/cli/internal/BouncyCastleFeature.java @@ -0,0 +1,22 @@ +package scala.cli.internal; + +// from https://github.com/micronaut-projects/micronaut-oracle-cloud/pull/17 +// see also https://github.com/oracle/graal/issues/2800#issuecomment-702480444 + +import com.oracle.svm.core.annotate.AutomaticFeature; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.graalvm.nativeimage.hosted.Feature; +import org.graalvm.nativeimage.hosted.RuntimeClassInitialization; + +import java.security.Security; + +@AutomaticFeature +public class BouncyCastleFeature implements Feature { + + @Override + public void afterRegistration(AfterRegistrationAccess access) { + RuntimeClassInitialization.initializeAtBuildTime("org.bouncycastle"); + Security.addProvider(new BouncyCastleProvider()); + } + +} diff --git a/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/native-image.properties b/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/native-image.properties index 7874acbd6c..25855523d2 100644 --- a/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/native-image.properties +++ b/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/native-image.properties @@ -10,6 +10,7 @@ Args = --no-fallback \ --initialize-at-build-time=scala.collection.immutable.VM \ --initialize-at-build-time=com.google.common.jimfs.SystemJimfsFileSystemProvider \ --initialize-at-build-time=com.google.common.base.Preconditions \ + --rerun-class-initialization-at-runtime=org.bouncycastle.jcajce.provider.drbg.DRBG$Default,org.bouncycastle.jcajce.provider.drbg.DRBG$NonceAndIV \ -H:IncludeResources=bootstrap.*.jar \ -H:IncludeResources=coursier/coursier.properties \ -H:IncludeResources=coursier/launcher/coursier.properties \ diff --git a/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/reflect-config.json b/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/reflect-config.json index 1a97015133..13f3ae6099 100644 --- a/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/reflect-config.json +++ b/modules/cli/src/main/resources/META-INF/native-image/org.virtuslab/scala-cli-core/reflect-config.json @@ -862,6 +862,1007 @@ } ] }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.COMPOSITE$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.DH$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.DSTU4145$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.EC$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.ECGOST$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.EdEC$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.ElGamal$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.GM$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.GOST$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.IES$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.X509$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA1", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.asymmetric.rsa.KeyFactorySpi", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Blake2b$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Blake2s$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.DSTU7564$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.GOST3411$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Haraka$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Keccak$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.MD2$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.MD4$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.MD5$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.RIPEMD128$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.RIPEMD160$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.RIPEMD256$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.RIPEMD320$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA1$Digest", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA1$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA224$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA256$Digest", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA256$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA3$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA384$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SHA512$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.SM3$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Skein$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Tiger$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.digest.Whirlpool$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.drbg.DRBG$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.keystore.BC$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.keystore.BCFKS$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.keystore.PKCS12$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.AES$ECB", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.AES$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.ARC4$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.ARIA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Blowfish$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.CAST5$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.CAST6$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Camellia$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.ChaCha$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.DES$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.DESede$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.DSTU7624$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.GOST28147$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.GOST3412_2015$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Grain128$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Grainv1$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.HC128$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.HC256$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.IDEA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Noekeon$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.OpenSSLPBKDF$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF1$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.PBEPBKDF2$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.PBEPKCS12$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Poly1305$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.RC2$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.RC5$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.RC6$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Rijndael$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.SCRYPT$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.SEED$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.SM4$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Salsa20$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Serpent$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Shacal2$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.SipHash$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.SipHash128$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Skipjack$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.TEA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.TLSKDF$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Threefish$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Twofish$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.VMPC$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.VMPCKSA3$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.XSalsa20$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.XTEA$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, + { + "name": "org.bouncycastle.jcajce.provider.symmetric.Zuc$Mappings", + "methods": [ + { + "name": "", + "parameterTypes": [ + + ] + } + ] + }, { "name": "org.eclipse.lsp4j.jsonrpc.json.adapters.JsonElementTypeAdapter$Factory", "allDeclaredConstructors": true, diff --git a/modules/cli/src/main/scala/scala/cli/ScalaCli.scala b/modules/cli/src/main/scala/scala/cli/ScalaCli.scala index 4efe385bbc..957f95c431 100644 --- a/modules/cli/src/main/scala/scala/cli/ScalaCli.scala +++ b/modules/cli/src/main/scala/scala/cli/ScalaCli.scala @@ -1,9 +1,11 @@ package scala.cli +import org.bouncycastle.jce.provider.BouncyCastleProvider import sun.misc.{Signal, SignalHandler} import java.io.{ByteArrayOutputStream, File, PrintStream} import java.nio.charset.StandardCharsets +import java.security.Security import scala.build.internal.Constants import scala.cli.internal.Argv0 @@ -128,6 +130,8 @@ object ScalaCli { val (systemProps, scalaCliArgs) = partitionArgs(remainingArgs) setSystemProps(systemProps) + Security.addProvider(new BouncyCastleProvider) + // Getting killed by SIGPIPE quite often when on musl (in the "static" native // image), but also sometimes on glibc, or even on macOS, when we use domain // sockets to exchange with Bloop. So let's just ignore those (which should diff --git a/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala b/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala index 9fcf41e623..e5540d68d4 100644 --- a/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala +++ b/modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala @@ -6,6 +6,7 @@ import caseapp.core.help.{Help, RuntimeCommandsHelp} import java.nio.file.InvalidPathException import scala.cli.commands._ +import scala.cli.commands.pgp.{PgpCreate, PgpVerify} class ScalaCliCommands( val progName: String, @@ -31,6 +32,8 @@ class ScalaCliCommands( Metabrowse, Repl, Package, + PgpCreate, + PgpVerify, Publish, Run, SetupIde, diff --git a/modules/cli/src/main/scala/scala/cli/commands/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/Publish.scala index c03364380c..86bb6421e2 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/Publish.scala @@ -6,6 +6,8 @@ import coursier.maven.MavenRepository import coursier.publish.checksum.logger.InteractiveChecksumLogger import coursier.publish.checksum.{ChecksumType, Checksums} import coursier.publish.fileset.{FileSet, Path} +import coursier.publish.signing.logger.InteractiveSignerLogger +import coursier.publish.signing.{GpgSigner, NopSigner, Signer} import coursier.publish.upload.logger.InteractiveUploadLogger import coursier.publish.upload.{FileUpload, HttpURLConnectionUpload} import coursier.publish.{Content, Pom} @@ -20,10 +22,12 @@ import scala.build.EitherCps.{either, value} import scala.build.Ops._ import scala.build.errors.{BuildException, CompositeBuildException, NoMainClassFoundError} import scala.build.internal.Util.ScalaDependencyOps -import scala.build.options.Scope +import scala.build.options.PublishOptions.{Signer => PSigner} +import scala.build.options.{ConfigMonoid, Scope, Secret} import scala.build.{Build, Builds, Logger, Os} import scala.cli.CurrentParams -import scala.cli.errors.{MissingRepositoryError, UploadError} +import scala.cli.errors.{FailedToSignFileError, MissingRepositoryError, UploadError} +import scala.cli.publish.BouncycastleSigner object Publish extends ScalaCommand[PublishOptions] { @@ -250,18 +254,75 @@ object Publish extends ScalaCommand[PublishOptions] { val ec = builds.head.options.finalCache.ec + val signerOpt = ConfigMonoid.sum( + builds.map(_.options.notForBloopOptions.publishOptions.signer) + ) + val signer: Signer = signerOpt match { + case Some(PSigner.Gpg) => + val gpgSignatureIdOpt = ConfigMonoid.sum( + builds.map(_.options.notForBloopOptions.publishOptions.gpgSignatureId) + ) + gpgSignatureIdOpt match { + case Some(gpgSignatureId) => + val gpgOptions = + builds.toList.flatMap(_.options.notForBloopOptions.publishOptions.gpgOptions) + GpgSigner( + GpgSigner.Key.Id(gpgSignatureId), + extraOptions = gpgOptions + ) + case None => NopSigner + } + case Some(PSigner.BouncyCastle) => + val secretKeyOpt = ConfigMonoid.sum( + builds.map(_.options.notForBloopOptions.publishOptions.secretKey) + ) + secretKeyOpt match { + case Some(secretKey) => + val passwordOpt = ConfigMonoid.sum( + builds.map(_.options.notForBloopOptions.publishOptions.secretKeyPassword) + ) + val privateKey = BouncycastleSigner.readSecretKey(os.read.inputStream(secretKey)) + BouncycastleSigner(privateKey, passwordOpt.getOrElse(Secret(""))) + case None => NopSigner + } + case None => NopSigner + } + val signerLogger = + new InteractiveSignerLogger(new OutputStreamWriter(System.err), verbosity = 1) + val signRes = signer.signatures( + fileSet0, + now, + ChecksumType.all.map(_.extension).toSet, + Set("maven-metadata.xml"), + signerLogger + ) + + val fileSet1 = value { + signRes + .left.map { + case (path, content, err) => + val path0 = content.pathOpt + .map(os.Path(_, Os.pwd)) + .toRight(path.repr) + new FailedToSignFileError(path0, err) + } + .map { signatures => + fileSet0 ++ signatures + } + } + val checksumLogger = new InteractiveChecksumLogger(new OutputStreamWriter(System.err), verbosity = 1) val checksums = Checksums( Seq(ChecksumType.MD5, ChecksumType.SHA1), - fileSet0, + fileSet1, now, ec, checksumLogger ).unsafeRun()(ec) - val fileSet1 = fileSet0 ++ checksums + val fileSet2 = fileSet1 ++ checksums - val finalFileSet = fileSet1.order(ec).unsafeRun()(ec) + val finalFileSet = fileSet2.order(ec).unsafeRun()(ec) val repoUrl = builds.head.options.notForBloopOptions.publishOptions.repository match { case None => diff --git a/modules/cli/src/main/scala/scala/cli/commands/PublishOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/PublishOptions.scala index 06cfd57843..16d7cf9def 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/PublishOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/PublishOptions.scala @@ -4,9 +4,10 @@ import caseapp._ import scala.build.EitherCps.{either, value} import scala.build.Ops._ -import scala.build.Positioned import scala.build.errors.{BuildException, CompositeBuildException} import scala.build.options.{BuildOptions, PublishOptions => BPublishOptions} +import scala.build.{Os, Positioned} +import scala.cli.internal.PasswordOption // format: off final case class PublishOptions( @@ -71,7 +72,36 @@ final case class PublishOptions( @Group("Publishing") @HelpMessage("Whether to build and publish source JARs") - sources: Option[Boolean] = None + sources: Option[Boolean] = None, + + @Group("Publishing") + @HelpMessage("ID of the GPG key to use to sign artifacts") + @ValueDescription("key-id") + @ExtraName("K") + gpgKey: Option[String] = None, + + @Group("Publishing") + @HelpMessage("Secret key to use to sign artifacts with BouncyCastle") + @ValueDescription("path") + secretKey: Option[String] = None, + + @Group("Publishing") + @HelpMessage("Password of secret key to use to sign artifacts with BouncyCastle") + @ValueDescription("value:…") + @ExtraName("secretKeyPass") + secretKeyPassword: Option[PasswordOption] = None, + + @Group("Publishing") + @HelpMessage("Method to use to sign artifacts") + @ValueDescription("gpg|bc") + signer: Option[String] = None, + + @Group("Publishing") + @HelpMessage("gpg command-line options") + @ValueDescription("argument") + @ExtraName("G") + @ExtraName("gpgOpt") + gpgOption: List[String] = Nil ) { // format: on @@ -109,7 +139,17 @@ final case class PublishOptions( scalaVersionSuffix = scalaVersionSuffix.map(_.trim), scalaPlatformSuffix = scalaPlatformSuffix.map(_.trim), repository = publishRepository.filter(_.trim.nonEmpty), - sourceJar = sources + sourceJar = sources, + gpgSignatureId = gpgKey.map(_.trim).filter(_.nonEmpty), + gpgOptions = gpgOption, + secretKey = secretKey.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), + secretKeyPassword = secretKeyPassword.map(_.get()), + signer = value { + signer + .map(Positioned.commandLine(_)) + .map(BPublishOptions.parseSigner(_)) + .sequence + } ) ) ) diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreate.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreate.scala new file mode 100644 index 0000000000..4ae5b8ef00 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreate.scala @@ -0,0 +1,52 @@ +package scala.cli.commands.pgp + +import caseapp.core.RemainingArgs +import org.bouncycastle.bcpg.ArmoredOutputStream + +import java.io.{BufferedOutputStream, ByteArrayOutputStream, File} + +import scala.cli.commands.ScalaCommand + +object PgpCreate extends ScalaCommand[PgpCreateOptions] { + + override def inSipScala = false + override def hidden = true + override def names = List( + List("pgp", "create") + ) + + private def printable(p: os.Path): String = + if (p.startsWith(os.pwd)) p.relativeTo(os.pwd).segments.mkString(File.separator) + else p.toString + + def run(options: PgpCreateOptions, args: RemainingArgs): Unit = { + + val pass = options.password.get().value.toCharArray + val keyRingGen = PgpHelper.generateKeyRingGenerator(options.email, pass) + val pubKeyRing = keyRingGen.generatePublicKeyRing() + + val pubKeyContent = { + val baos = new ByteArrayOutputStream + val out = new ArmoredOutputStream(baos) + pubKeyRing.encode(out) + out.close() + baos.toByteArray + } + val secretKeyContent = { + val baos = new ByteArrayOutputStream + val skr = keyRingGen.generateSecretKeyRing() + val secout = new BufferedOutputStream(baos) + skr.encode(secout) + secout.close() + baos.toByteArray + } + + val publicKeyPath = options.publicKeyPath + val secretKeyPath = options.secretKeyPath + + os.write(publicKeyPath, pubKeyContent) + System.err.println(s"Wrote public key to ${printable(publicKeyPath)}") + os.write(secretKeyPath, secretKeyContent) + System.err.println(s"Wrote secret key to ${printable(secretKeyPath)}") + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreateOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreateOptions.scala new file mode 100644 index 0000000000..54f54bb501 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpCreateOptions.scala @@ -0,0 +1,35 @@ +package scala.cli.commands.pgp + +import caseapp._ + +import scala.build.Os +import scala.cli.internal.PasswordOption + +@HelpMessage("Create PGP key pair") +final case class PgpCreateOptions( + email: String, + password: PasswordOption, + dest: Option[String] = None, + pubDest: Option[String] = None, + secretDest: Option[String] = None +) { + def publicKeyPath: os.Path = { + val str = pubDest.filter(_.trim.nonEmpty) + .orElse(secretDest.filter(_.trim.nonEmpty).map(_.stripSuffix(".skr") + ".pub")) + .orElse(dest.filter(_.trim.nonEmpty).map(_ + ".pub")) + .getOrElse("key.pub") + os.Path(str, Os.pwd) + } + def secretKeyPath: os.Path = { + val str = secretDest.filter(_.trim.nonEmpty) + .orElse(pubDest.filter(_.trim.nonEmpty).map(_.stripSuffix(".pub") + ".skr")) + .orElse(dest.filter(_.trim.nonEmpty).map(_ + ".skr")) + .getOrElse("key.skr") + os.Path(str, Os.pwd) + } +} + +object PgpCreateOptions { + implicit lazy val parser: Parser[PgpCreateOptions] = Parser.derive + implicit lazy val help: Help[PgpCreateOptions] = Help.derive +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpHelper.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpHelper.scala new file mode 100644 index 0000000000..7004a26721 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpHelper.scala @@ -0,0 +1,126 @@ +package scala.cli.commands.pgp + +// https://stackoverflow.com/questions/28245669/using-bouncy-castle-to-create-public-pgp-key-usable-by-thunderbird + +import org.bouncycastle.bcpg.sig.{Features, KeyFlags} +import org.bouncycastle.bcpg.{HashAlgorithmTags, PublicKeyAlgorithmTags, SymmetricKeyAlgorithmTags} +import org.bouncycastle.crypto.generators.RSAKeyPairGenerator +import org.bouncycastle.crypto.params.RSAKeyGenerationParameters +import org.bouncycastle.openpgp.operator.bc.{ + BcPBESecretKeyEncryptorBuilder, + BcPGPContentSignerBuilder, + BcPGPDigestCalculatorProvider, + BcPGPKeyPair +} +import org.bouncycastle.openpgp.{PGPKeyRingGenerator, PGPSignature, PGPSignatureSubpacketGenerator} + +import java.math.BigInteger +import java.security.SecureRandom +import java.util.Date + +object PgpHelper { + def generateKeyRingGenerator( + id: String, + pass: Array[Char] + ): PGPKeyRingGenerator = + generateKeyRingGenerator(id, pass, 0xc0) + + // Note: s2kcount is a number between 0 and 0xff that controls the number of times to iterate the password hash before use. More + // iterations are useful against offline attacks, as it takes more time to check each password. The actual number of iterations is + // rather complex, and also depends on the hash function in use. Refer to Section 3.7.1.3 in rfc4880.txt. Bigger numbers give + // you more iterations. As a rough rule of thumb, when using SHA256 as the hashing function, 0x10 gives you about 64 + // iterations, 0x20 about 128, 0x30 about 256 and so on till 0xf0, or about 1 million iterations. The maximum you can go to is + // 0xff, or about 2 million iterations. I'll use 0xc0 as a default -- about 130,000 iterations. + def generateKeyRingGenerator( + id: String, + pass: Array[Char], + s2kcount: Int + ): PGPKeyRingGenerator = { + // This object generates individual key-pairs. + val kpg = new RSAKeyPairGenerator + // Boilerplate RSA parameters, no need to change anything + // except for the RSA key-size (2048). You can use whatever key-size makes sense for you -- 4096, etc. + kpg.init( + new RSAKeyGenerationParameters( + BigInteger.valueOf(0x10001), + new SecureRandom, + 2048, + 12 + ) + ) + + // First create the master (signing) key with the generator. + val rsakpSign = new BcPGPKeyPair( + PublicKeyAlgorithmTags.RSA_SIGN, + kpg.generateKeyPair(), + new Date + ) + // Then an encryption subkey. + val rsakpEnc = new BcPGPKeyPair( + PublicKeyAlgorithmTags.RSA_ENCRYPT, + kpg.generateKeyPair(), + new Date + ) + // Add a self-signature on the id + val signHashGen = new PGPSignatureSubpacketGenerator + // Add signed metadata on the signature. + // 1) Declare its purpose + signHashGen.setKeyFlags(false, KeyFlags.SIGN_DATA | KeyFlags.CERTIFY_OTHER) + // 2) Set preferences for secondary crypto algorithms to use when sending messages to this key. + signHashGen.setPreferredSymmetricAlgorithms( + false, + Array( + SymmetricKeyAlgorithmTags.AES_256, + SymmetricKeyAlgorithmTags.AES_192, + SymmetricKeyAlgorithmTags.AES_128 + ) + ) + signHashGen.setPreferredHashAlgorithms( + false, + Array( + HashAlgorithmTags.SHA256, + HashAlgorithmTags.SHA1, + HashAlgorithmTags.SHA384, + HashAlgorithmTags.SHA512, + HashAlgorithmTags.SHA224 + ) + ) + // 3) Request senders add additional checksums to the message (useful when verifying unsigned messages.) + signHashGen.setFeature(false, Features.FEATURE_MODIFICATION_DETECTION) + // Create a signature on the encryption subkey. + val encHashGen = new PGPSignatureSubpacketGenerator + // Add metadata to declare its purpose + encHashGen.setKeyFlags( + false, + KeyFlags.ENCRYPT_COMMS | KeyFlags.ENCRYPT_STORAGE + ) + // Objects used to encrypt the secret key. + val sha1Calc = + (new BcPGPDigestCalculatorProvider).get(HashAlgorithmTags.SHA1) + val sha256Calc = + (new BcPGPDigestCalculatorProvider).get(HashAlgorithmTags.SHA256) + // bcpg 1.48 exposes this API that includes s2kcount. Earlier versions use a default of 0x60. + val pske = new BcPBESecretKeyEncryptorBuilder( + SymmetricKeyAlgorithmTags.AES_256, + sha256Calc, + s2kcount + ).build(pass) + // Finally, create the keyring itself. The constructor takes parameters that allow it to generate the self signature. + val keyRingGen = + new PGPKeyRingGenerator( + PGPSignature.POSITIVE_CERTIFICATION, + rsakpSign, + id, + sha1Calc, + signHashGen.generate(), + null, + new BcPGPContentSignerBuilder( + rsakpSign.getPublicKey.getAlgorithm, + HashAlgorithmTags.SHA1 + ), + pske + ) + keyRingGen.addSubKey(rsakpEnc, encHashGen.generate(), null) + keyRingGen + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSign.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSign.scala new file mode 100644 index 0000000000..bda0819be1 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSign.scala @@ -0,0 +1,55 @@ +package scala.cli.commands.pgp + +import caseapp.core.RemainingArgs + +import java.io.InputStream + +import scala.build.Os +import scala.cli.commands.ScalaCommand +import scala.cli.publish.BouncycastleSigner + +object PgpSign extends ScalaCommand[PgpSignOptions] { + def run(options: PgpSignOptions, args: RemainingArgs): Unit = { + + val privateKey = BouncycastleSigner.readSecretKey(os.read.inputStream(options.secretKeyPath)) + val signer = BouncycastleSigner(privateKey, options.password.get()) + + for (arg <- args.all) { + val path = os.Path(arg, Os.pwd) + val dest = (path / os.up) / s"${path.last}.asc" + + val res = signer.sign { f => + var is: InputStream = null + try { + is = os.read.inputStream(path) + val b = Array.ofDim[Byte](16 * 1024) + var read = 0 + while ({ + read = is.read(b) + read >= 0 + }) + if (read > 0) + f(b, 0, read) + } + finally is.close() + } + + res match { + case Left(err) => + System.err.println(err) + sys.exit(1) + case Right(value) => + if (options.force) + os.write.over(dest, value) + else if (os.exists(dest)) { + System.err.println( + s"Error: ${arg + ".asc"} already exists. Pass --force to force overwriting it." + ) + sys.exit(1) + } + else + os.write(dest, value) + } + } + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSignOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSignOptions.scala new file mode 100644 index 0000000000..76ead34897 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpSignOptions.scala @@ -0,0 +1,22 @@ +package scala.cli.commands.pgp + +import caseapp._ + +import scala.build.Os +import scala.cli.internal.PasswordOption + +@HelpMessage("Sign files with PGP") +final case class PgpSignOptions( + password: PasswordOption, + secretKey: String, + @ExtraName("f") + force: Boolean = false +) { + def secretKeyPath: os.Path = + os.Path(secretKey, Os.pwd) +} + +object PgpSignOptions { + implicit lazy val parser: Parser[PgpSignOptions] = Parser.derive + implicit lazy val help: Help[PgpSignOptions] = Help.derive +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerify.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerify.scala new file mode 100644 index 0000000000..27c60be3e2 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerify.scala @@ -0,0 +1,68 @@ +package scala.cli.commands.pgp + +import caseapp._ +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.{PGPPublicKeyRingCollection, PGPUtil} + +import java.io.{ByteArrayInputStream, InputStream} + +import scala.build.Os +import scala.cli.commands.ScalaCommand +import scala.cli.publish.BouncycastleSigner + +object PgpVerify extends ScalaCommand[PgpVerifyOptions] { + + override def inSipScala = false + override def hidden = true + override def names = List( + List("pgp", "verify") + ) + + def run(options: PgpVerifyOptions, args: RemainingArgs): Unit = { + + val keyContent = os.read.bytes(options.keyPath) + + // originally based on https://github.com/bcgit/bc-java/blob/58fe01df5bd8f839a6f474c16c6a3b7448b0f472/pg/src/main/java/org/bouncycastle/openpgp/examples/DetachedSignatureProcessor.java + + val pgpPubRingCollection = new PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(new ByteArrayInputStream(keyContent)), + new JcaKeyFingerprintCalculator + ) + + val invalidPaths = args.all.filter(!_.endsWith(".asc")) + if (invalidPaths.nonEmpty) { + System.err.println(s"Invalid signature paths: ${invalidPaths.mkString(", ")}") + sys.exit(1) + } + + val results = + for (path0 <- args.all) yield { + val filePath = os.Path(path0.stripSuffix(".asc"), Os.pwd) + val signatureContent = os.read.bytes(os.Path(path0, Os.pwd)) + + val sig = BouncycastleSigner.readSignature(new ByteArrayInputStream(signatureContent)) + val key = pgpPubRingCollection.getPublicKey(sig.getKeyID) + + var is: InputStream = null + val verified = + try { + is = os.read.inputStream(filePath) + BouncycastleSigner.verifySignature(sig, key, is) + } + finally if (is != null) + is.close() + + path0 -> verified + } + + for ((path, verified) <- results) { + val msg = + if (verified) "valid signature" + else "invalid signature" + System.err.println(s"$path: $msg") + } + + if (results.exists(!_._2)) + sys.exit(1) + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerifyOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerifyOptions.scala new file mode 100644 index 0000000000..c09a1c65ba --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/pgp/PgpVerifyOptions.scala @@ -0,0 +1,15 @@ +package scala.cli.commands.pgp + +import caseapp._ + +@HelpMessage("Verify PGP signatures") +final case class PgpVerifyOptions( + key: String +) { + def keyPath = os.Path(key, os.pwd) +} + +object PgpVerifyOptions { + implicit lazy val parser: Parser[PgpVerifyOptions] = Parser.derive + implicit lazy val help: Help[PgpVerifyOptions] = Help.derive +} diff --git a/modules/cli/src/main/scala/scala/cli/internal/PasswordOption.scala b/modules/cli/src/main/scala/scala/cli/internal/PasswordOption.scala new file mode 100644 index 0000000000..f823276c07 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/internal/PasswordOption.scala @@ -0,0 +1,60 @@ +package scala.cli.internal + +import caseapp.core.argparser.{ArgParser, SimpleArgParser} +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ + +import scala.build.options.Secret +import scala.io.Codec + +sealed abstract class PasswordOption extends Product with Serializable { + def get(): Secret[String] +} + +abstract class LowPriorityPasswordOption { + + private lazy val commandCodec: JsonValueCodec[List[String]] = + JsonCodecMaker.make + + implicit lazy val argParser: ArgParser[PasswordOption] = + SimpleArgParser.from("password") { str => + if (str.startsWith("value:")) + Right(PasswordOption.Value(Secret(str.stripPrefix("value:")))) + else if (str.startsWith("command:[")) + try { + val command = readFromString(str.stripPrefix("command:"))(commandCodec) + Right(PasswordOption.Command(command)) + } + catch { + case e: JsonReaderException => + Left(caseapp.core.Error.Other(s"Error decoding password command: ${e.getMessage}")) + } + else if (str.startsWith("command:")) { + val command = str.stripPrefix("command:").split("\\s+").toSeq + Right(PasswordOption.Command(command)) + } + else + Left(caseapp.core.Error.Other("Malformed password value (expected \"value:...\")")) + } + +} + +object PasswordOption extends LowPriorityPasswordOption { + + final case class Value(value: Secret[String]) extends PasswordOption { + def get(): Secret[String] = value + } + final case class Command(command: Seq[String]) extends PasswordOption { + def get(): Secret[String] = { + // should we add a timeout? + val res = os.proc(command).call(stdin = os.Inherit) + Secret(res.out.text(Codec.default)) // should we trim that? + } + } + + implicit lazy val optionArgParser: ArgParser[Option[PasswordOption]] = + SimpleArgParser.from("password") { str => + if (str.trim.isEmpty) Right(None) + else argParser(None, -1, -1, str).map(Some(_)) + } +} diff --git a/modules/cli/src/main/scala/scala/cli/publish/BouncycastleSigner.scala b/modules/cli/src/main/scala/scala/cli/publish/BouncycastleSigner.scala new file mode 100644 index 0000000000..0881cd2cab --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/publish/BouncycastleSigner.scala @@ -0,0 +1,193 @@ +package scala.cli.publish + +import coursier.publish.Content +import coursier.publish.signing.Signer +import org.bouncycastle.bcpg.sig.KeyFlags +import org.bouncycastle.bcpg.{ + ArmoredOutputStream, + BCPGOutputStream, + CompressionAlgorithmTags, + HashAlgorithmTags +} +import org.bouncycastle.openpgp._ +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator +import org.bouncycastle.openpgp.operator.jcajce.{ + JcaKeyFingerprintCalculator, + JcaPGPContentSignerBuilder, + JcaPGPContentVerifierBuilderProvider, + JcePBESecretKeyDecryptorBuilder +} + +import java.io.{ByteArrayOutputStream, InputStream} + +import scala.build.options.Secret +import scala.jdk.CollectionConverters._ + +final case class BouncycastleSigner( + pgpSecretKey: PGPSecretKey, + password: Secret[String] +) extends Signer { + def sign(content: Content): Either[String, String] = + sign { f => + val b = content.content() + f(b, 0, b.length) + } + def sign(withContent: ((Array[Byte], Int, Int) => Unit) => Unit): Either[String, String] = { + + // originally adapted from https://github.com/jordanbaucke/PGP-Sign-and-Encrypt/blob/472d8932df303d6861ec494a3e942ea268eaf25f/src/SignAndEncrypt.java#L144-L199 + + val encOut = new ByteArrayOutputStream + val out = new ArmoredOutputStream(encOut) + + val pgpPrivKey = pgpSecretKey.extractPrivateKey( + new JcePBESecretKeyDecryptorBuilder() + .setProvider("BC") + .build(password.value.toCharArray) + ) + + val sGen = new PGPSignatureGenerator( + new JcaPGPContentSignerBuilder( + pgpSecretKey.getPublicKey + .getAlgorithm, + HashAlgorithmTags.SHA1 + ).setProvider("BC") + ) + + sGen.init(PGPSignature.BINARY_DOCUMENT, pgpPrivKey) + + val it = pgpSecretKey.getPublicKey.getUserIDs + if (it.hasNext()) { + val spGen = new PGPSignatureSubpacketGenerator + spGen.setSignerUserID(false, it.next().asInstanceOf[String]) + sGen.setHashedSubpackets(spGen.generate()) + } + + val comData = new PGPCompressedDataGenerator( + CompressionAlgorithmTags.ZLIB + ) + + val bOut = new BCPGOutputStream(comData.open(out)) + + withContent { (b, off, len) => + sGen.update(b, off, len) + } + + sGen.generate().encode(bOut) + + comData.close() + + out.close() + + Right(encOut.toString()) + } +} + +object BouncycastleSigner { + + // many things here originally adapted from http://sloanseaman.com/wordpress/2012/05/13/revisited-pgp-encryptiondecryption-in-java/ + + private val MASTER_KEY_CERTIFICATION_TYPES = Seq( + PGPSignature.POSITIVE_CERTIFICATION, + PGPSignature.CASUAL_CERTIFICATION, + PGPSignature.NO_CERTIFICATION, + PGPSignature.DEFAULT_CERTIFICATION + ) + private def KEY_FLAGS = 27 + + private def hasKeyFlags(encKey: PGPPublicKey, keyUsage: Int): Boolean = + if (encKey.isMasterKey) + MASTER_KEY_CERTIFICATION_TYPES + .iterator + .flatMap(encKey.getSignaturesOfType(_).asScala) + .forall { sig => + isMatchingUsage(sig.asInstanceOf[PGPSignature], keyUsage) + } + else + encKey.getSignaturesOfType(PGPSignature.SUBKEY_BINDING).asScala + .forall { sig => + isMatchingUsage(sig.asInstanceOf[PGPSignature], keyUsage) + } + + private def isMatchingUsage(sig: PGPSignature, keyUsage: Int): Boolean = + !sig.hasSubpackets || { + val sv = sig.getHashedSubPackets + !sv.hasSubpacket(KEY_FLAGS) || (sv.getKeyFlags & keyUsage) != 0 + } + + private def fingerprintCalculator: KeyFingerPrintCalculator = + new JcaKeyFingerprintCalculator + + def readSecretKey(in: InputStream): PGPSecretKey = { + val keyRingCollection = new PGPSecretKeyRingCollection( + PGPUtil.getDecoderStream(in), + fingerprintCalculator + ) + // + // We just loop through the collection till we find a key suitable for signing. + // In the real world you would probably want to be a bit smarter about this. + // + var secretKey: PGPSecretKey = null + val rIt = keyRingCollection.getKeyRings + while (secretKey == null && rIt.hasNext) { + val keyRing = rIt.next().asInstanceOf[PGPSecretKeyRing] + val kIt = keyRing.getSecretKeys + while (secretKey == null && kIt.hasNext) { + val key = kIt.next().asInstanceOf[PGPSecretKey] + if (key.isSigningKey) + secretKey = key + } + } + // Validate secret key + if (secretKey == null) + throw new IllegalArgumentException("Can't find private key in the key ring.") + if (!secretKey.isSigningKey) + throw new IllegalArgumentException("Private key does not allow signing.") + if (secretKey.getPublicKey.isRevoked) + throw new IllegalArgumentException("Private key has been revoked.") + if (!hasKeyFlags(secretKey.getPublicKey, KeyFlags.SIGN_DATA)) + throw new IllegalArgumentException("Key cannot be used for signing.") + secretKey + } + + def readSignature(in: InputStream): PGPSignature = { + + val factory = new JcaPGPObjectFactory(PGPUtil.getDecoderStream(in)) + + val obj = factory.nextObject() + val sigList = obj match { + case data: PGPCompressedData => + val factory0 = new JcaPGPObjectFactory(data.getDataStream()) + factory0.nextObject() match { + case l: PGPSignatureList => l + case other => + System.err.println(s"Unrecognized PGP object type: $other") + sys.exit(1) + } + case sigList0: PGPSignatureList => + sigList0 + case _ => + System.err.println(s"Unrecognized PGP object type: $obj") + sys.exit(1) + } + + sigList.get(0) + } + + def verifySignature(sig: PGPSignature, key: PGPPublicKey, content: InputStream): Boolean = { + + sig.init(new JcaPGPContentVerifierBuilderProvider().setProvider("BC"), key) + + val buf = Array.ofDim[Byte](16 * 1024) + var read = -1 + while ({ + read = content.read(buf) + read >= 0 + }) + if (read > 0) + sig.update(buf, 0, read) + + sig.verify() + } + +} diff --git a/modules/integration/src/test/resources/test-keys/key.asc b/modules/integration/src/test/resources/test-keys/key.asc new file mode 100644 index 0000000000..dde30254e5 --- /dev/null +++ b/modules/integration/src/test/resources/test-keys/key.asc @@ -0,0 +1,30 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BCPG v1.68 + +mQENBGIXxr8DCAC8aS9gAPsdaZukOb9Q83+5+U8IeDdWhOkwPjbIM534BO/LRYVv +vKcXcv2X6r5eOnA2NBnAuyZ0GBwiAMVm6agZT2HGY6LjnFlIxn6L/Miz5vlplAXt +Q72IybRJUTVEUrV1a4mAxZsM5+mXHmQI7BZ97v9//3uJOlUtXLyjCPA7PwmzQCws +npdXb+oOodHTUptsG+8r8Y7XYuFXiuvGq6NRY7aESE9pRupfCAwERRAp7qEddyTo +V5xiVSDAn0vxphKd1ZlKGWsK0SH2Rm+QnZZAM5FW0gaGNzp2n6vXtCVSeMs2ZzlE +LJCt+ZuxQANuICL9X0YL6hDlq3/co5qSbuxXABEBAAG0Em5vcmVwbHlAZ2l0aHVi +LmNvbYkBLgQTAwIAGAUCYhfGvwIbAwQLCQgHBhUIAgkKCwIeAQAKCRA7ulNUJg/z +pwFIB/96ntiTdjfr3xzsvCn+iuq3SJBdGALVbIA8htIEfQeafFtFq9fOY26b8efg +xO5Foe0gtLkCycviR/ok6xMrTsE2Qcq1clUanAkH5RffAz/jea8fYN150FpANvlN +1Ru3fjX5+FxmPLZa5gj1nPYU+t9uvRphAQxAFaDxbUOgaCfPD03pdLWBhhlG+wrE +IvR2aY1JdATgsHqFcpJal6Qs0nNQAX/298OLegcKKEBAllVZ+rwbZ9NEn23X6YP3 +wBerflBiV7KDf70R49d99i64V7A2Jh+LiAmyBqRn2E+dUluTX+d1Cv3dia6/dDxL +/MQCI5BBKoTFiunGRpolcvu+MKnZuQENBGIXxr8CCADScBUwWHEH8JK2LvPfJRn0 +oKpz1xYr1e4Uhh+LangmoESHXfvr+NMtKpMJzhfzKqSREVsQ3iMkk0IqZIKEf2Ay +MzBVnbbHQDVUd2c3fu6H0RIJI9zZlaUVAkBL/8c2BvAjqe94wPJkSUjHYalI177X +uMmgfpq42gWs3F+W4TmvXkXynOWnNezZWXcJyfODJsraKgBcHJW2Gn86w4EO5jGF +KR7zNZfSMU+WZuM4XajDpO8iwRNsu/eYlB8jBPLOlV2jqaOJVgjGwzOKyjfD+9j3 +2duOB/da1NWIJi9jzI3tp0PyIAP6fhBuh4Ou5NErasLvU1e/mUAsrYrnLicdBv7x +ABEBAAGJAR8EGAMCAAkFAmIXxr8CGwwACgkQO7pTVCYP86f1dQf8DMEG2LkVH6fB +qktyVl7cfcHUPa4uqlB2NiHYwrwAr45ftU5/kDBXf9AxoZvoq8pF+y3RMboYu1CU +FoxoVh7GB+LtpHk1SyWxZcPo48RoANHkpp4y4XndZcJ4XDbaMkrF65E/CGK7etEX +/P+xMyovli64iaXHpIJ9ejKyhSKpFuD7w5U1UzArIfR8xjwrGeonruyiHTB/ULq5 +Tp7dU/066ZxlHddXh3WNYhkaD7b3PJP+hCZzn0+LSiA2cKiQDpLX5xm/iAh8CWyq +N2Ix77qdIgS7lWd0CEomQ3QV4SYff4u13aUhAer2YZcmFhsejHp2hJD0pvUW41f5 +eiL+IwMGMQ== +=TGbn +-----END PGP PUBLIC KEY BLOCK----- diff --git a/modules/integration/src/test/resources/test-keys/key.skr b/modules/integration/src/test/resources/test-keys/key.skr new file mode 100644 index 0000000000000000000000000000000000000000..657080e87605399327732085b5cd70f7b3accac0 GIT binary patch literal 2553 zcmV2mrikFJJ)s9ci1SIloZzf4TWj2zWPEgy}FoHpnxb_yq6E zMTKv?rx$Ylm+HP=I&d~L8NjW9%KmY7JcsjfB$=lI#n%PyrT&4J3k4tKrAetmsfA< z4x!Q0Qk!fW?icO>XnoMKfVz@JO; zrV^dinMxUJ3eh3{kF00RRF12?GcNWfFkH(S^{!00@2ebC~RUqYgP}L4KT~5)Qb; zWHA6b5;b-W+pQ_MHheq263BqnpfafPc;nF#lq6nTpf50aq}W4P%Yf1`Y+-g_SUQC$ zHI#8^^_x@AQVcL*tby!~8natL>O$M?kiEmdwT=Yj)8`djbE4c_(k^yFb88;fs4i%4 zK>+s~?u1lPhV|%=ZE2$TOkp)8CH1b9`5+4wmcParwLw_2ogf&SEW|(gxXP6BO)Hty z<(7c2^Q0+x7N}gRoo4AMwv8K?d16dZ>~mlJ+uBsa>!IVp8}OSVl%&PDiJ%4Gt)1nq ziX(5S%=Iiy{>-R*GJi2@QhF3*8ISF(llNfjcBw9(c)}NA>r=yZq6%FRaW6;3IF1jr z5xny3Z70nWt!5ij`5KvkltrTG-cZ?8c(F!!zPM!#%7AHKqgj#Culqsq#gU5PBjNB| zh!OI$?tv@4R!_heOhF_&Lji%WrGI0~EukGDya`@ItOh%Vfl+dfe^NijxF6u2b19iV zA7}lLgkcJw?T!+flu9@iIIIHyM?%`lxRuE(^1%#aWcG^=a8Vthft*^#cBJ0li^%%M ztCsTvUOL8}dY)_Ny7xwQx&0v%Lrk?Tod;POe232%I@9VZG?P>Po3`tg{2pkHOCCR2 zu%-Bkz_vnG^#u!EBFesY6JPQBGM4pqKyXO1K`#AqF!;dUkk{14ywS`#UTQ-pEJ!u_ zor~Y7VO)ij?lt&4BI@ALce}Jy_o)x9qNxl^+kS7p{DT9qllTht3_gXb+ZoAKx1#O_6* z?I5(d0?EtbNBSh|6Dv-^HbKg@a#b3f2?ymD-vdA6d9NQ}-FeVjKsNbJ)f=~dHTn2l zW<0i9<_Pti_7wWxZoL{|0SrJDpz&=(plB!04^8QGwSk5iM*9lHBJ_4?jY)I_;IMjy za*|q?q%6{NPyv7T_rr^N2MQ=aK$cZm`n(%w(?p+b*Xe`zz!$52P-0iIgMYmd-f_x zDw7G$7xOBlkr7)E-XkQFLMmi}gnwW%GcZ-1w#Ps~Gr#0-^S$7G^^MfYJ z+A08C9F?{je>%f~4(2h1DIW7Rm(np$mS*EPU8uvP?;^nyY`gcElpiAm^3IiAqp72b zRtUz!Gm6SL!~59x+1ri>_gd7|h$b&%%#H1*L-HU4`hE~@hl8%<(JN}g?^9R5nLsS9 zisvpT9R~jK01*KI0saXC2m@sjfWy&+(7^6i4D$-Uu*f2RJ4(>rbgSQyG$fk+NhO>8 zR+sFbU)GGE!bjNaaFD=(=g`QhLO_gZdR!vfnv!D#`+ixOsTS-ZfEbE8lgnxhqrl!L zJBS-5p8n;tA3GvP0G@nT*;qG73%EBty-bRwmWJK*U#QIz;5`PZKy{8MsF67=N;giE z82}ZbM5vqmHl9`g<)F%B$E&0=611ej&CHeu-91|LJQp3!&P*Jt@sRxo!})b?!-)gc zI_$CZygpqJ_8a3(qAQZ{J#<6&F<>KoEMBH&QCKUsc% z`Y1c{&OkjAvj`P}AN`(pS!KX|Vp!l*e0ct6%yy#&bef!AS!XmLY)5;C(=%L7QY&x? znb_kb>Bf=rZ^b4QIzWD=zWAnlUz>z;jm+mo2>p>g0uYHy*IqAY=S!-jq?q~G2+~sB zl>n@a>ErZT8w z1QoOVoX3E2mHO`5S(0*$1vF|2`v+ZlX#FjFzB3T4&`L(A&FbiC)u6WZfCAjqgmF+r zOX+!tac-W2yG3ieGYmj}HXhwab>)vbBmPw0-sI4)qjQ48aE2xfLI$!KzDgR$knF!PGsjE~-#=HX+!;ya2C`U$stu zkT6$&&@rK#=&Q;_`z_Hix){4qlopI=RvyL&;_ak)HA^M2Wy9#>#ApD~PT>ybYQV!L|L7ySRRGb%5ZF1U%M$E1RNdNQ(wBB>VO`@@wrQ!pzb^nAuV zD;er1uI!>6Fn>_GxlW$lQ~f&WoMj!?SBG_tVi_6_w)Z@f{)8rTpHGWQAU1HQkPed9 z=NZ3<2z&`_syAXW@4B5L1iO`IbO=f&Lv$74CLe!`wcVv50qXW)mnIe)9*lZ+gpl;6 P^%mn-`FbM$BLfC8oT}9d literal 0 HcmV?d00001 diff --git a/modules/integration/src/test/scala/scala/cli/integration/PublishTests.scala b/modules/integration/src/test/scala/scala/cli/integration/PublishTests.scala index c9915aa535..34cb474373 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PublishTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PublishTests.scala @@ -2,6 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect +import java.nio.file.Paths import java.util.zip.ZipFile import scala.jdk.CollectionConverters._ @@ -48,6 +49,9 @@ class PublishTests extends munit.FunSuite { "simple_3-0.2.0-SNAPSHOT-sources.jar" ) val expectedArtifacts = baseExpectedArtifacts + .flatMap { n => + Seq(n, n + ".asc") + } .flatMap { n => Seq("", ".md5", ".sha1").map(n + _) } @@ -59,8 +63,37 @@ class PublishTests extends munit.FunSuite { "foo/Messages.scala" ) + val publicKey = { + val uri = Thread.currentThread().getContextClassLoader + .getResource("test-keys/key.asc") + .toURI + os.Path(Paths.get(uri)) + } + val secretKey = { + val uri = Thread.currentThread().getContextClassLoader + .getResource("test-keys/key.skr") + .toURI + os.Path(Paths.get(uri)) + } + + // format: off + val signingOptions = Seq( + "--secret-key", secretKey.toString, + "--secret-key-password", "value:1234", + "--signer", "bc" + ) + // format: on + inputs.fromRoot { root => - os.proc(TestUtil.cli, "publish", extraOptions, "project", "-R", "test-repo").call( + os.proc( + TestUtil.cli, + "publish", + extraOptions, + signingOptions, + "project", + "-R", + "test-repo" + ).call( cwd = root, stdin = os.Inherit, stdout = os.Inherit @@ -93,6 +126,18 @@ class PublishTests extends munit.FunSuite { val zf = new ZipFile(sourceJarViaCs.toIO) val entries = zf.entries().asScala.toVector.map(_.getName).toSet expect(entries == expectedSourceEntries) + + val signatures = expectedArtifacts.filter(_.last.endsWith(".asc")) + assert(signatures.nonEmpty) + os.proc( + TestUtil.cli, + "pgp", + "verify", + "--key", + publicKey, + signatures.map(os.rel / "test-repo" / expectedArtifactsDir / _) + ) + .call(cwd = root) } } } diff --git a/project/deps.sc b/project/deps.sc index b357cf77e0..eb6665ca78 100644 --- a/project/deps.sc +++ b/project/deps.sc @@ -58,30 +58,33 @@ object Deps { def scalaNative = "0.4.3" def scalaPackager = "0.1.26" } - def ammonite = ivy"com.lihaoyi:::ammonite:2.5.1-6-5fce97fb" - def asm = ivy"org.ow2.asm:asm:9.2" - def bloopConfig = ivy"io.github.alexarchambault.bleep::bloop-config:1.4.19" - def bsp4j = ivy"ch.epfl.scala:bsp4j:2.0.0" - def caseApp = ivy"com.github.alexarchambault::case-app:2.1.0-M12" - def collectionCompat = ivy"org.scala-lang.modules::scala-collection-compat:2.6.0" - def coursierJvm = ivy"io.get-coursier::coursier-jvm:${Versions.coursier}" - def coursierLauncher = ivy"io.get-coursier::coursier-launcher:${Versions.coursier}" - def coursierPublish = ivy"io.get-coursier.publish::publish:0.1.0" - def dataClass = ivy"io.github.alexarchambault::data-class:0.2.5" - def dependency = ivy"io.get-coursier::dependency:0.2.0" - def expecty = ivy"com.eed3si9n.expecty::expecty:0.15.4" - def guava = ivy"com.google.guava:guava:31.0.1-jre" - def jimfs = ivy"com.google.jimfs:jimfs:1.2" - def jniUtils = ivy"io.get-coursier.jniutils:windows-jni-utils:0.3.3" - def libdaemonjvm = ivy"io.github.alexarchambault.libdaemon::libdaemon:0.0.9" - def macroParadise = ivy"org.scalamacros:::paradise:2.1.1" - def munit = ivy"org.scalameta::munit:0.7.29" - def nativeTestRunner = ivy"org.scala-native::test-runner:${Versions.scalaNative}" - def nativeTools = ivy"org.scala-native::tools:${Versions.scalaNative}" - def organizeImports = ivy"com.github.liancheng::organize-imports:0.5.0" - def osLib = ivy"com.lihaoyi::os-lib:0.8.0" - def pprint = ivy"com.lihaoyi::pprint:0.6.6" - def prettyStacktraces = ivy"org.virtuslab::pretty-stacktraces:0.0.1-M1" + def ammonite = ivy"com.lihaoyi:::ammonite:2.5.1-6-5fce97fb" + def asm = ivy"org.ow2.asm:asm:9.2" + def bloopConfig = ivy"io.github.alexarchambault.bleep::bloop-config:1.4.19" + def bouncycastle = ivy"org.bouncycastle:bcpg-jdk15on:1.68" + def bsp4j = ivy"ch.epfl.scala:bsp4j:2.0.0" + def caseApp = ivy"com.github.alexarchambault::case-app:2.1.0-M12" + def collectionCompat = ivy"org.scala-lang.modules::scala-collection-compat:2.6.0" + def coursierJvm = ivy"io.get-coursier::coursier-jvm:${Versions.coursier}" + def coursierLauncher = ivy"io.get-coursier::coursier-launcher:${Versions.coursier}" + def coursierPublish = ivy"io.get-coursier.publish::publish:0.1.0" + def dataClass = ivy"io.github.alexarchambault::data-class:0.2.5" + def dependency = ivy"io.get-coursier::dependency:0.2.0" + def expecty = ivy"com.eed3si9n.expecty::expecty:0.15.4" + def guava = ivy"com.google.guava:guava:31.0.1-jre" + def jimfs = ivy"com.google.jimfs:jimfs:1.2" + def jniUtils = ivy"io.get-coursier.jniutils:windows-jni-utils:0.3.3" + def jsoniterScala = ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core:2.13.5" + def jsoniterScalaMacros = ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.13.5" + def libdaemonjvm = ivy"io.github.alexarchambault.libdaemon::libdaemon:0.0.9" + def macroParadise = ivy"org.scalamacros:::paradise:2.1.1" + def munit = ivy"org.scalameta::munit:0.7.29" + def nativeTestRunner = ivy"org.scala-native::test-runner:${Versions.scalaNative}" + def nativeTools = ivy"org.scala-native::tools:${Versions.scalaNative}" + def organizeImports = ivy"com.github.liancheng::organize-imports:0.5.0" + def osLib = ivy"com.lihaoyi::os-lib:0.8.0" + def pprint = ivy"com.lihaoyi::pprint:0.6.6" + def prettyStacktraces = ivy"org.virtuslab::pretty-stacktraces:0.0.1-M1" def scala3Compiler(sv: String) = ivy"org.scala-lang::scala3-compiler:$sv" def scalaAsync = ivy"org.scala-lang.modules::scala-async:1.0.1".exclude("*" -> "*") def scalac(sv: String) = ivy"org.scala-lang:scala-compiler:$sv" diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 2c48840ee8..b33614b9e2 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -363,6 +363,8 @@ Available in commands: - [`install-home`](./commands.md#install-home) - [`browse` / `metabrowse`](./commands.md#browse) - [`package`](./commands.md#package) +- [`pgp create`](./commands.md#pgp-create) +- [`pgp verify`](./commands.md#pgp-verify) - [`publish`](./commands.md#publish) - [`console` / `repl`](./commands.md#console) - [`run`](./commands.md#run) @@ -804,6 +806,34 @@ The image repository The image tag; the default tag is `latest` +## Pgp create options + +Available in commands: +- [`pgp create`](./commands.md#pgp-create) + + + + +#### `--email` + +#### `--password` + +#### `--dest` + +#### `--pub-dest` + +#### `--priv-dest` + +## Pgp verify options + +Available in commands: +- [`pgp verify`](./commands.md#pgp-verify) + + + + +#### `--key` + ## Publish options Available in commands: @@ -866,6 +896,32 @@ Repository to publish to Whether to build and publish source JARs +#### `--gpg-key` + +Aliases: `-K` + +ID of the GPG key to use to sign artifacts + +#### `--secret-key` + +Secret key to use to sign artifacts with BouncyCastle + +#### `--secret-key-password` + +Aliases: `--secret-key-pass` + +Password of secret key to use to sign artifacts with BouncyCastle + +#### `--signer` + +Method to use to sign artifacts + +#### `--gpg-option` + +Aliases: `-G`, `--gpg-opt` + +gpg command-line options + ## Repl options Available in commands: diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 3934a1bda5..11404f21b5 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -400,3 +400,17 @@ Accepts options: - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) +### `pgp create` + +Create PGP key pair + +Accepts options: +- [pgp create](./cli-options.md#pgp-create-options) + +### `pgp verify` + +Verify PGP signatures + +Accepts options: +- [pgp verify](./cli-options.md#pgp-verify-options) +