Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/rust-sdk-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Check standalone Rust application SDK
on:
push:
branches: [staging, main, master]
paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', '.github/workflows/rust-sdk-check.yml']
paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', 'crates/kome/**', 'crates/msign/**', '.github/workflows/rust-sdk-check.yml']
pull_request:
paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', '.github/workflows/rust-sdk-check.yml']
paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', 'crates/kome/**', 'crates/msign/**', '.github/workflows/rust-sdk-check.yml']
permissions:
contents: read
jobs:
Expand Down Expand Up @@ -33,3 +33,8 @@ jobs:
SDK: ${{ steps.sdk.outputs.sdk }}
HELPER: ${{ steps.sdk.outputs.helper }}
run: python3 "$(dirname "$HELPER")/host-tool.py" --sdk "$SDK" --tool msign --output "$RUNNER_TEMP/host-tools" --test
- name: Test and build Kome without production credentials
env:
SDK: ${{ steps.sdk.outputs.sdk }}
HELPER: ${{ steps.sdk.outputs.helper }}
run: python3 "$(dirname "$HELPER")/host-tool.py" --sdk "$SDK" --tool kome --output "$RUNNER_TEMP/host-tools" --test
3 changes: 2 additions & 1 deletion crates/kome/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ pub struct PackArgs {

#[derive(Debug, Args)]
pub struct SignArgs {
/// Unsigned MPKG input; defaults to dist/<name>-unsigned.mpkg.
/// Sign an existing MPKG without building/repacking or requiring Kome.toml.
/// Omit this input to build and sign the current Kome project instead.
pub package: Option<PathBuf>,

#[arg(long, default_value = ".")]
Expand Down
98 changes: 93 additions & 5 deletions crates/kome/src/commands/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,24 @@ use crate::{

pub fn run(args: SignArgs) -> Result<()> {
let project_dir = absolute_project_path(&args.project)?;
let manifest = project::read_manifest(&project_dir)?;
let paths = SignPaths::new(&project_dir, &manifest, &args);

ensure_built(&project_dir, &manifest, &paths, args.release)?;
ensure_packed(&project_dir, &manifest, &paths, args.release)?;
let (manifest, paths) = if let Some(package) = &args.package {
let package = project_path(&project_dir, package);
let manifest = artifact_requirements(&package)?;
let mut paths = SignPaths::new(&project_dir, &manifest, &args);
if args.output.is_none() {
paths.signed_package = default_signed_path(&package)?;
}
(manifest, paths)
} else {
let manifest = project::read_manifest(&project_dir)?;
let paths = SignPaths::new(&project_dir, &manifest, &args);
ensure_built(&project_dir, &manifest, &paths, args.release)?;
ensure_packed(&project_dir, &manifest, &paths, args.release)?;
(manifest, paths)
};
if paths.unsigned_package == paths.signed_package {
bail!("Signed output must differ from the unsigned input");
}
keygen::run_in_project(
KeygenArgs {
private_key: paths.private_key.clone(),
Expand Down Expand Up @@ -94,6 +107,53 @@ pub fn run(args: SignArgs) -> Result<()> {
Ok(())
}

#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ArtifactRequirements {
package_id: String,
capabilities: Vec<String>,
}

fn artifact_requirements(package: &Path) -> Result<KomeManifest> {
let result = Command::new("msign")
.arg("package")
.arg("requirements")
.arg(package)
.output()
.context("failed to inspect MPKG signing requirements; install the matching msign")?;
if !result.status.success() {
bail!(
"MPKG requirement inspection failed: {}",
String::from_utf8_lossy(&result.stderr)
);
}
parse_artifact_requirements(&result.stdout)
}

fn parse_artifact_requirements(bytes: &[u8]) -> Result<KomeManifest> {
if bytes.len() > 1024 * 1024 {
bail!("MPKG signing requirements exceed the size limit");
}
let requirements: ArtifactRequirements = serde_json::from_slice(bytes)?;
if !mochios_certificate::is_valid_package_id(&requirements.package_id) {
bail!("Invalid MPKG package ID");
}
// This value carries certificate selection inputs only. It is never used to
// build, repack, or replace the artifact's actual application metadata.
let mut manifest =
KomeManifest::new_app("Package".into(), requirements.package_id, String::new());
manifest.capabilities.required = requirements.capabilities;
Ok(manifest)
}

fn default_signed_path(package: &Path) -> Result<PathBuf> {
let stem = package
.file_stem()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow::anyhow!("MPKG input needs a UTF-8 file name"))?;
Ok(package.with_file_name(format!("{stem}-signed.mpkg")))
}

struct SignPaths {
unsigned_package: PathBuf,
signed_package: PathBuf,
Expand Down Expand Up @@ -354,6 +414,34 @@ mod tests {

use super::*;

#[test]
fn artifact_requirements_use_actual_identity_and_capabilities() {
let manifest = parse_artifact_requirements(
br#"{"package_id":"org.mochios.edit","capabilities":["fs.read.user","window.create"]}"#,
)
.unwrap();
assert_eq!(manifest.package.id, "org.mochios.edit");
assert_eq!(
required_capabilities(&manifest),
vec!["fs.read.user", "window.create"]
);
for invalid in [
br#"{"package_id":"../bad","capabilities":[]}"#.as_slice(),
br#"{"package_id":"org.mochios.edit","capabilities":[],"developer_id":"override"}"#,
] {
assert!(parse_artifact_requirements(invalid).is_err());
}
assert!(parse_artifact_requirements(&vec![b' '; 1024 * 1024 + 1]).is_err());
}

#[test]
fn existing_artifact_default_output_preserves_its_input() {
assert_eq!(
default_signed_path(Path::new("/tmp/Edit.mpkg")).unwrap(),
Path::new("/tmp/Edit-signed.mpkg")
);
}

#[test]
fn required_capabilities_are_a_sorted_union() {
let mut manifest = KomeManifest::new_app(
Expand Down
7 changes: 7 additions & 0 deletions crates/msign/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,20 @@ pub enum CertificateCommand {

#[derive(Debug, Subcommand)]
pub enum PackageCommand {
/// Print the package ID and requested capabilities as JSON; does not authenticate the package.
Requirements(PackageRequirementsArgs),
Sign(PackageSignArgs),
SignAuto(PackageSignAutoArgs),
Verify(PackageVerifyArgs),
BindBuiltIn(BindBuiltInArgs),
BuiltInRecord(BuiltInRecordArgs),
}

#[derive(Debug, Args)]
pub struct PackageRequirementsArgs {
pub package: PathBuf,
}

#[derive(Debug, Args)]
pub struct BindBuiltInArgs {
pub manifest: PathBuf,
Expand Down
28 changes: 23 additions & 5 deletions crates/msign/src/commands/mpkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@ struct MpkgEntry {
mode: u32,
}

#[derive(serde::Serialize)]
pub(crate) struct PackageRequirements {
pub package_id: String,
pub capabilities: Vec<String>,
}

/// Inspect declared signing requirements with the canonical bounded MPKG parser.
/// This does not verify developer identity, signatures, or payload integrity.
pub fn requirements(args: crate::cli::PackageRequirementsArgs) -> Result<()> {
println!(
"{}",
serde_json::to_string(&package_requirements(&args.package)?)?
);
Ok(())
}

pub(crate) fn package_requirements(package: &Path) -> Result<PackageRequirements> {
let entries = read_mpkg(package)?;
reject_chain_and_unknown_signatures(&entries)?;
Expand Down Expand Up @@ -412,8 +423,8 @@ fn write_mpkg(path: &Path, entries: &[MpkgEntry]) -> Result<()> {
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let directory = PrivateDirectory::new_in(parent, "msign")
.context("failed to create temporary MPKG")?;
let directory =
PrivateDirectory::new_in(parent, "msign").context("failed to create temporary MPKG")?;
let mut temporary = directory.create_file("package.mpkg")?;
temporary
.write_all(&header)
Expand Down Expand Up @@ -601,9 +612,7 @@ fn validate_application_metadata(
let value = value
.as_str()
.ok_or_else(|| anyhow!("application.{key} entries must be strings"))?;
if value.is_empty()
|| (key == "resources" && !is_normalized_relative_path(value))
{
if value.is_empty() || (key == "resources" && !is_normalized_relative_path(value)) {
bail!("application.{key} contains an invalid entry");
}
}
Expand Down Expand Up @@ -1033,6 +1042,15 @@ mod tests {
)
.unwrap();

let inspected = serde_json::to_value(package_requirements(&unsigned).unwrap()).unwrap();
assert_eq!(
inspected,
serde_json::json!({
"package_id": "org.example.application",
"capabilities": ["process.spawn", "window.create"]
})
);

assert_eq!(request.developer_id, "019f9e5ac6687902b0e72fe53abfbef1");
assert_eq!(request.package_id, "org.example.application");
assert_eq!(
Expand Down
3 changes: 3 additions & 0 deletions crates/msign/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ fn main() -> Result<()> {
let cli = Cli::parse();

match cli.command {
Command::Package {
command: PackageCommand::Requirements(args),
} => commands::mpkg::requirements(args),
Command::Elf {
command: ElfCommand::Sign(args),
} => commands::elf::sign(args),
Expand Down
22 changes: 22 additions & 0 deletions docs/package-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ IDs, developer identities, or a central registration mechanism.

## Automatic selection

For an existing unsigned MPKG produced by CI, Kome can obtain a package-scoped
certificate and sign it without a `Kome.toml`, compilation, or repackaging:

```bash
kome login
kome developer use YOUR_DEVELOPER_ID
kome sign /path/to/Edit.mpkg \
--key /private/edit/application.key \
--public-key /private/edit/application.pub \
--certificate /private/edit/developer.cert \
--issuer-public-key /private/edit/developer.issuer.pub \
--output /path/to/Edit-signed.mpkg
```

Use a private directory outside the source repository for credential files.
Kome uses `msign package requirements` to read the actual package ID and required
capabilities with the existing MPKG parser; these are declarations, not proof of
authenticity. Kome then validates the issued certificate and verifies the signed
package before publishing the output file. Without an explicit output path,
the input `Edit.mpkg` produces `Edit-signed.mpkg`. With no MPKG argument, the
existing Kome project build/package/sign workflow is unchanged.

For an unsigned MPKG, automatic selection reads the Package ID and requested
binary capabilities from the package manifest. A candidate identity is usable
only when all of the following are true:
Expand Down
35 changes: 35 additions & 0 deletions rust-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Official app callers use an `app-release` Environment with
variable `APP_RELEASE_ENABLED` is explicitly set to `true` and these Environment
values have been configured:

New callers should use one atomic Secret, `MSIGN_IDENTITY_B64`, populated by the
approval-protected initializer described below. The release helper also accepts
the following legacy inputs, but refuses to mix them with an atomic identity:

* Secret `MSIGN_CERTIFICATE_B64`: base64 of the issued Developer Certificate file.
* Secret `MSIGN_PRIVATE_KEY_B64`: base64 of its corresponding private-key file.
* Variable `MSIGN_ROOT_PUBLIC_KEY_B64`: base64 of the trusted issuer public-key file.
Expand All @@ -82,3 +86,34 @@ success or failure. The signed package is verified against the trusted root
before publication. Existing release tags/assets are never replaced. The initial
release helper publishes prereleases as `v<version>-r<revision>`; update the
manifest revision before making another release of the same version.

## First-time signing setup on Actions

`signing-setup.py` runs only from an explicitly dispatched default-branch job
protected by the configured Environment/Team. It accepts only successful
default-branch artifacts from the chosen package workflow and verifies their
actual source revision, SDK receipt, package identity and checksum. It builds no
application code and does not publish or enable releases.

The caller needs the pinned SDK's `msign` and `kome` host tools and a short-lived
fine-grained PAT, limited to the caller repository with Environments read/write,
as the Environment Secret `SIGNING_SETUP_TOKEN`. This token is used only by `gh`
to encrypt/store/delete Environment Secrets; it is never passed to Kome or msign.
The normal read-only job token reads run and protection metadata.

The initializer creates a new package-specific key on the ephemeral runner and
first saves an encrypted `MSIGN_SETUP_KEYPAIR_B64` recovery Secret. A retry must
restore the same package/developer key. It never silently rotates credentials.
Kome prints its public device-login URL/code to the Actions log; the maintainer
authorizes in a browser, and Kome validates the selected Developer membership,
obtains the certificate, signs the selected MPKG, and verifies it locally.

Only after verification does the initializer atomically save the certificate,
key and issuer public key as `MSIGN_IDENTITY_B64`. No credential or signed test
package is uploaded as an artifact. The temporary Kome session is logged out,
local temporary files are removed, and recovery/setup Secrets are deleted on
success. Deleting the setup Secret does not revoke its PAT: revoke that short-
lived PAT in GitHub settings after setup. On issuance failure, the recovery
Secret remains for the next approved attempt; session/files are still cleaned.
Existing production credentials are refused, and publication must remain
disabled throughout setup. Credential rotation is deliberately a separate task.
23 changes: 18 additions & 5 deletions rust-sdk/host-tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
import os
from pathlib import Path
import subprocess
import shutil


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sdk', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--tool', choices=['msign'], required=True)
parser.add_argument('--tool', choices=['msign', 'kome'], required=True)
parser.add_argument('--test', action='store_true')
args = parser.parse_args()
sdk = json.loads(args.sdk.read_text())
Expand All @@ -22,15 +23,27 @@ def main():
env.update(RUSTC=sdk['rustc'], RUSTDOC=sdk['rustdoc'])
command = [sdk['cargo'], 'build', '--release', '--manifest-path', str(devkit / 'Cargo.toml'),
'--package', args.tool, '--no-default-features', '--target-dir', str(args.output.resolve())]
for config in sdk['cargo_config']:
command += ['--config', config]
cwd = devkit
if args.tool == 'kome':
# Host-only CLI resolution is isolated from native application/std patches.
project = args.output.resolve() / 'kome-source'
project.mkdir(parents=True, exist_ok=False)
shutil.copytree(devkit / 'crates/kome/src', project / 'src')
(project / 'Cargo.toml').write_text((devkit / 'crates/kome/Cargo.toml').read_text() + '\n[workspace]\n')
shutil.copyfile(Path(__file__).parent / 'kome.Cargo.lock', project / 'Cargo.lock')
command[command.index('--manifest-path') + 1] = str(project / 'Cargo.toml')
command += ['--locked', '--features', 'keyring/vendored']
cwd = project
else:
for config in sdk['cargo_config']:
command += ['--config', config]
if args.test:
test = command.copy()
test[1] = 'test'
test.remove('--release')
env['CARGO_PROFILE_TEST_DEBUG'] = '0'
subprocess.run(test, env=env, cwd=devkit, check=True)
subprocess.run(command, env=env, cwd=devkit, check=True)
subprocess.run(test, env=env, cwd=cwd, check=True)
subprocess.run(command, env=env, cwd=cwd, check=True)


if __name__ == '__main__':
Expand Down
Loading
Loading