From 4f8a85cbc42ceee5b7d0fd95cf597356be7fc3ac Mon Sep 17 00:00:00 2001
From: Wiktor Kwapisiewicz <wiktor@metacode.biz>
Date: Mon, 17 Mar 2025 11:35:52 +0100
Subject: [PATCH 1/2] feat: make `openpgp_sign_state` always return an armored
 OpenPGP signature

Signed-off-by: Wiktor Kwapisiewicz <wiktor@metacode.biz>
---
 nethsm-cli/README.md   |  2 ++
 nethsm-cli/src/main.rs |  2 +-
 nethsm/src/lib.rs      |  4 ++--
 nethsm/src/openpgp.rs  | 17 ++++++++---------
 4 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/nethsm-cli/README.md b/nethsm-cli/README.md
index 205accaf..fe38a822 100644
--- a/nethsm-cli/README.md
+++ b/nethsm-cli/README.md
@@ -471,6 +471,8 @@ done
 
 signstar-request-signature "$NETHSM_OPENPGP_SIGNATURE_MESSAGE" | tee "${NETHSM_OPENPGP_SIGNATURE_MESSAGE}.json"
 nethsm openpgp sign-state --force "signing1" "${NETHSM_OPENPGP_SIGNATURE_MESSAGE}.json"
+# the signature is always armored
+grep -- "-----BEGIN PGP SIGNATURE-----" "$NETHSM_OPENPGP_SIGNATURE_OUTPUT_FILE"
 gpg --verify "$NETHSM_OPENPGP_SIGNATURE_OUTPUT_FILE" "$NETHSM_OPENPGP_SIGNATURE_MESSAGE"
 rpacket dump "$NETHSM_OPENPGP_SIGNATURE_OUTPUT_FILE"
 sha512sum "$NETHSM_OPENPGP_SIGNATURE_MESSAGE"
diff --git a/nethsm-cli/src/main.rs b/nethsm-cli/src/main.rs
index 83971709..3c59258a 100644
--- a/nethsm-cli/src/main.rs
+++ b/nethsm-cli/src/main.rs
@@ -893,7 +893,7 @@ fn main() -> Result<(), Error> {
                 output.output().write_all(
                     nethsm
                         .openpgp_sign_state(&command.key_id, hasher)?
-                        .as_slice(),
+                        .as_bytes(),
                 )?;
             }
         },
diff --git a/nethsm/src/lib.rs b/nethsm/src/lib.rs
index e423d292..4ee4c1b0 100644
--- a/nethsm/src/lib.rs
+++ b/nethsm/src/lib.rs
@@ -6030,7 +6030,7 @@ impl NetHsm {
         openpgp::sign(self, key_id, message)
     }
 
-    /// Generates an OpenPGP signature based on provided hasher state.
+    /// Generates an armored OpenPGP signature based on provided hasher state.
     ///
     /// Signs the hasher `state` using the key identified by `key_id`
     /// and returns a binary [OpenPGP data signature].
@@ -6139,7 +6139,7 @@ impl NetHsm {
         &self,
         key_id: &KeyId,
         state: impl sha2::Digest + Clone + std::io::Write,
-    ) -> Result<Vec<u8>, crate::Error> {
+    ) -> Result<String, crate::Error> {
         openpgp::sign_hasher_state(self, key_id, state)
     }
 }
diff --git a/nethsm/src/openpgp.rs b/nethsm/src/openpgp.rs
index 5bfeba97..ffd003ef 100644
--- a/nethsm/src/openpgp.rs
+++ b/nethsm/src/openpgp.rs
@@ -11,10 +11,12 @@ use base64ct::{Base64, Encoding as _};
 use chrono::{DateTime, Utc};
 use email_address::{EmailAddress, Options};
 use pgp::{
+    ArmorOptions,
     Deserializable,
     KeyDetails,
     SignedPublicKey,
     SignedSecretKey,
+    StandaloneSignature,
     crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, public_key::PublicKeyAlgorithm},
     packet::{
         KeyFlags,
@@ -813,7 +815,7 @@ pub fn sign(
     Ok(out)
 }
 
-/// Generates an OpenPGP signature based on provided hasher state.
+/// Generates an armored OpenPGP signature based on provided hasher state.
 ///
 /// Signs the hasher `state` using the key identified by `key_id`
 /// and returns a binary [OpenPGP data signature].
@@ -851,7 +853,7 @@ pub fn sign_hasher_state(
     nethsm: &NetHsm,
     key_id: &crate::KeyId,
     state: impl sha2::Digest + Clone + std::io::Write,
-) -> Result<Vec<u8>, crate::Error> {
+) -> Result<String, crate::Error> {
     let public_key = nethsm.get_key_certificate(key_id)?;
 
     let signer = HsmKey::new(
@@ -903,13 +905,10 @@ pub fn sign_hasher_state(
 
     let signature = pgp::Signature::from_config(sig_config, signed_hash_value, raw_sig);
 
-    let out = {
-        let mut out = vec![];
-        pgp::packet::write_packet(&mut out, &signature).map_err(Error::Pgp)?;
-        out
-    };
-
-    Ok(out)
+    let signature = StandaloneSignature { signature };
+    Ok(signature
+        .to_armored_string(ArmorOptions::default())
+        .map_err(Error::Pgp)?)
 }
 
 /// Converts NetHSM public key to OpenPGP public key.
-- 
GitLab


From 4b16cf539282f6952f471542c327eae35488ceb1 Mon Sep 17 00:00:00 2001
From: Wiktor Kwapisiewicz <wiktor@metacode.biz>
Date: Fri, 7 Mar 2025 12:30:42 +0100
Subject: [PATCH 2/2] feat: Add `signstar-sign`

Fixes: https://gitlab.archlinux.org/archlinux/signstar/-/issues/34
Signed-off-by: Wiktor Kwapisiewicz <wiktor@metacode.biz>
---
 Cargo.lock                |  15 +++-
 Cargo.toml                |   1 +
 justfile                  |   4 ++
 signstar-sign/Cargo.toml  |  16 +++++
 signstar-sign/README.md   |  86 +++++++++++++++++++++++
 signstar-sign/src/main.rs | 143 ++++++++++++++++++++++++++++++++++++++
 6 files changed, 263 insertions(+), 2 deletions(-)
 create mode 100644 signstar-sign/Cargo.toml
 create mode 100644 signstar-sign/README.md
 create mode 100644 signstar-sign/src/main.rs

diff --git a/Cargo.lock b/Cargo.lock
index 8a00f64c..04700c6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3023,9 +3023,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.133"
+version = "1.0.140"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
 dependencies = [
  "itoa",
  "memchr",
@@ -3169,6 +3169,17 @@ dependencies = [
  "thiserror 2.0.11",
 ]
 
+[[package]]
+name = "signstar-sign"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "nethsm",
+ "nethsm-config",
+ "serde_json",
+ "signstar-request-signature",
+]
+
 [[package]]
 name = "slab"
 version = "0.4.9"
diff --git a/Cargo.toml b/Cargo.toml
index 6b970a36..edc77e30 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ members = [
   "signstar-configure-build",
   "signstar-common",
   "signstar-request-signature",
+  "signstar-sign",
 ]
 
 [workspace.dependencies]
diff --git a/justfile b/justfile
index 2ee731f6..4cbdf194 100755
--- a/justfile
+++ b/justfile
@@ -310,6 +310,9 @@ test-readme project:
             nethsm-cli)
                 cargo install --locked --path signstar-request-signature
             ;;
+            signstar-sign)
+                cargo install --locked --path nethsm-cli
+            ;;
         esac
         cargo install --locked --path {{ project }}
     }
@@ -356,6 +359,7 @@ test-readme project:
 test-readmes:
     just test-readme nethsm-cli
     just test-readme signstar-configure-build
+    just test-readme signstar-sign
 
 # Adds pre-commit and pre-push git hooks
 add-hooks:
diff --git a/signstar-sign/Cargo.toml b/signstar-sign/Cargo.toml
new file mode 100644
index 00000000..4bf0e79a
--- /dev/null
+++ b/signstar-sign/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "signstar-sign"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+clap.workspace = true
+nethsm.workspace = true
+nethsm-config.workspace = true
+serde_json = "1.0.140"
+signstar-request-signature.workspace = true
diff --git a/signstar-sign/README.md b/signstar-sign/README.md
new file mode 100644
index 00000000..43c6cb9c
--- /dev/null
+++ b/signstar-sign/README.md
@@ -0,0 +1,86 @@
+# Signstar Sign
+
+This crate offers an executable for processing signing requests.
+Signing requests are created using the [`signstar-request-signature`] and specify everything that is needed for creating an artifact signature.
+The signing response returned by this executable contains a raw, protocol-specific framing.
+Currently, `signstar-sign` can created only OpenPGP signatures but the format is extensible and more could be implemented in the future.
+
+<!--
+
+The following sets up a dummy NetHSM device, which serves as a backend for storing private parts of the signing key.
+
+```bash
+# use a custom, temporary directory for all generated files
+nethsm_tmpdir="$(mktemp --directory --suffix '.nethsm-test')"
+# set a custom, temporary configuration file location
+export NETHSM_CONFIG="$(mktemp --tmpdir="$nethsm_tmpdir" --suffix '-nethsm.toml' --dry-run)"
+# add the container using unsafe TLS connection handling for testing
+nethsm env add device --label test https://localhost:8443/api/v1 Unsafe
+# prepare a temporary passphrase file for the initial admin user passphrase
+nethsm_admin_passphrase_file="$(mktemp --tmpdir="$nethsm_tmpdir" --dry-run --suffix '-nethsm.admin-passphrase.txt')"
+export NETHSM_PASSPHRASE_FILE="$nethsm_admin_passphrase_file"
+printf 'my-very-unsafe-admin-passphrase' > "$NETHSM_PASSPHRASE_FILE"
+# add the default admin user credentials
+nethsm env add credentials admin Administrator
+set +x
+counter=0
+
+while ! nethsm health state; do
+  printf "NetHSM is not ready, waiting (try %d)...\n" "$counter"
+  sleep 1
+  counter=$(( counter + 1 ))
+  if (( counter > 30 )); then
+    printf "NetHSM is not up even after 30 tries. Aborting."
+    set -x
+    exit 2
+  fi
+done
+
+printf "NetHSM is ready for provisioning after %d seconds.\n" "$counter"
+set -x
+# prepare a temporary passphrase file for the initial unlock passphrase
+export NETHSM_UNLOCK_PASSPHRASE_FILE="$(mktemp --tmpdir="$nethsm_tmpdir" --dry-run --suffix '-nethsm.unlock-passphrase.txt')"
+printf 'my-very-unsafe-unlock-passphrase' > "$NETHSM_UNLOCK_PASSPHRASE_FILE"
+# reuse the initial admin passphrase
+export NETHSM_ADMIN_PASSPHRASE_FILE="$nethsm_admin_passphrase_file"
+nethsm provision
+
+nethsm_admin1_passphrase_file="$(mktemp --tmpdir="$nethsm_tmpdir" --dry-run --suffix '-nethsm.admin1-passphrase.txt')"
+printf 'my-very-unsafe-admin1-passphrase' > "$nethsm_admin1_passphrase_file"
+nethsm_operator1_passphrase_file="$(mktemp --tmpdir="$nethsm_tmpdir" --dry-run --suffix '-nethsm.operator1-passphrase.txt')"
+printf 'my-very-unsafe-operator1-passphrase' > "$nethsm_operator1_passphrase_file"
+
+# add users
+export NETHSM_PASSPHRASE_FILE="$nethsm_admin1_passphrase_file"
+nethsm user add "Some Admin1" Administrator admin1
+nethsm env add credentials admin1 Administrator
+export NETHSM_PASSPHRASE_FILE="$nethsm_operator1_passphrase_file"
+nethsm user add "Some Operator1" Operator operator1
+nethsm env add credentials operator1 Operator
+
+export NETHSM_KEY_CERT_OUTPUT_FILE="$(mktemp --tmpdir="$nethsm_tmpdir" --dry-run --suffix '-nethsm.openpgp-cert.pgp')"
+
+# create a signing key
+nethsm --user admin1 key generate --key-id signing1 --tags tag1 Curve25519 EdDsaSignature
+
+# an R-Administrator can only modify system-wide users
+nethsm --user admin1 user tag operator1 tag1
+
+# add an openpgp certificate to the key
+nethsm --user admin1 --user operator1 openpgp add --can-sign signing1 "Test signing1 key <test@example.org>"
+nethsm --user operator1 key cert get --force signing1
+rpacket dump "$NETHSM_KEY_CERT_OUTPUT_FILE" | grep "Test signing1 key"
+
+# signing
+export NETHSM_KEY_ID=signing1
+```
+-->
+
+## `signstar-sign`
+
+The following command takes a signing request, encoded in JSON, and produces a JSON response.
+The JSON response contains a `signature` field, which is an armored OpenPGP signature.
+
+```bash
+signstar-sign < ../signstar-request-signature/tests/sample-request.json | jq --raw-output .signature | rsop dearmor | rpacket dump
+```
diff --git a/signstar-sign/src/main.rs b/signstar-sign/src/main.rs
new file mode 100644
index 00000000..ac606844
--- /dev/null
+++ b/signstar-sign/src/main.rs
@@ -0,0 +1,143 @@
+use std::{
+    collections::HashMap,
+    fs::read_to_string,
+    path::{Path, PathBuf},
+    str::FromStr,
+};
+
+use clap::Parser;
+use nethsm::{KeyId, Passphrase, UserId, UserRole};
+use nethsm_config::{Config, ConfigInteractivity, ConfigSettings};
+use signstar_request_signature::{Error, Request, Sha512};
+
+#[derive(Debug, Parser)]
+struct Cli {
+    #[arg(
+        env = "NETHSM_AUTH_PASSPHRASE_FILE",
+        global = true,
+        help = "The path to a file containing a passphrase for authentication",
+        long_help = "The path to a file containing a passphrase for authentication
+
+The passphrase provided in the file must be the one for the user chosen for the command.
+
+This option can be provided multiple times, which is needed for commands that require multiple roles at once.
+With multiple passphrase files ordering matters, as the files are assigned to the respective user provided by the \"--user\" option.",
+        long,
+        short
+    )]
+    pub auth_passphrase_file: Vec<PassphraseFile>,
+
+    #[arg(
+        env = "NETHSM_CONFIG",
+        global = true,
+        help = "The path to a custom configuration file",
+        long_help = "The path to a custom configuration file
+
+If specified, the custom configuration file is used instead of the default configuration file location.",
+        long,
+        short
+    )]
+    pub config: Option<PathBuf>,
+
+    #[arg(
+        env = "NETHSM_USER",
+        global = true,
+        help = "A user name which is used for the command",
+        long_help = "A user name which is used for a command
+
+Can be provided, if no user name is setup in the configuration file for a device.
+Must be provided, if several user names of the same target role are setup in the configuration file for a device.
+
+This option can be provided multiple times, which is needed for commands that require multiple roles at once.
+",
+        long,
+        short
+    )]
+    pub user: Vec<UserId>,
+
+    #[arg(
+        env = "NETHSM_LABEL",
+        global = true,
+        help = "A label uniquely identifying a device in the configuration file",
+        long_help = "A label uniquely identifying a device in the configuration file
+
+Must be provided if more than one device is setup in the configuration file.",
+        long,
+        short
+    )]
+    pub label: Option<String>,
+
+    #[arg(env = "NETHSM_KEY_ID", help = "The ID of the key to use")]
+    pub key_id: KeyId,
+}
+
+#[derive(Clone, Debug)]
+pub struct PassphraseFile {
+    pub passphrase: Passphrase,
+}
+
+impl PassphraseFile {
+    pub fn new(path: &Path) -> Result<Self, Error> {
+        Ok(Self {
+            passphrase: Passphrase::new(read_to_string(path).unwrap()),
+        })
+    }
+}
+
+impl FromStr for PassphraseFile {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        PassphraseFile::new(&PathBuf::from_str(s).unwrap())
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let cli = Cli::parse();
+
+    let config = Config::new(
+        ConfigSettings::new(
+            "nethsm".to_string(),
+            ConfigInteractivity::NonInteractive,
+            None,
+        ),
+        cli.config.as_deref(),
+    )?;
+
+    let auth_passphrases: Vec<Passphrase> = cli
+        .auth_passphrase_file
+        .iter()
+        .map(|x| x.passphrase.clone())
+        .collect();
+
+    let nethsm = config
+        .get_device(cli.label.as_deref())?
+        .nethsm_with_matching_creds(&[UserRole::Operator], &cli.user, &auth_passphrases)?;
+
+    let req = Request::from_reader(std::io::stdin())?;
+
+    if !req.required.output.is_openpgp_v4() {
+        Err(Error::InvalidContentSize)?; // FIXME: fix error variant
+    }
+
+    if req.version.major != 1 {
+        Err(Error::InvalidContentSize)?; // FIXME: fix error variant
+    }
+
+    let hasher: Sha512 = req.required.input.try_into()?;
+
+    let signature = nethsm.openpgp_sign_state(&cli.key_id, hasher)?;
+
+    // FIXME: use Response from !148 when it's merged
+    let response = [
+        ("version".into(), "0.0.0".into()),
+        ("signature".into(), signature),
+    ]
+    .iter()
+    .cloned()
+    .collect::<HashMap<String, String>>();
+
+    serde_json::to_writer(std::io::stdout(), &response)?;
+
+    Ok(())
+}
-- 
GitLab