use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use base64::Engine;
use oci_distribution::secrets::RegistryAuth;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::{oci, ui};
const ISSUER: &str = "https://oauth2.sigstore.dev/auth";
const GITHUB: &str = "https://github.com/login/oauth";
const PREDICATE_TYPE: &str = "https://portaki.app/attestations/author/v1";
const MINIMUM: (u64, u64, u64) = (3, 1, 3);
const INSTALL: &str =
"brew install cosign, or go install github.com/sigstore/cosign/v3/cmd/cosign@v3.1.3";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(300);
pub fn in_ci() -> bool {
["CI", "GITHUB_ACTIONS"]
.iter()
.any(|name| std::env::var_os(name).is_some())
}
pub fn refuse_in_ci(ci: bool) -> Result<()> {
if ci {
bail!(
"--sign signs from a workstation, with your GitHub identity — in CI the release \
action (PortakiApp/portaki-release-action) signs, with provenance: drop --sign"
);
}
Ok(())
}
pub fn cosign_binary() -> PathBuf {
std::env::var_os("PORTAKI_COSIGN")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("cosign"))
}
pub fn check_cosign(bin: &Path) -> Result<()> {
let output = match Command::new(bin).args(["version", "--json"]).output() {
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => {
bail!("--sign needs cosign v3.1.3 or a later 3.x, and none is installed — {INSTALL}")
}
other => other.context("run cosign version")?,
};
let found = serde_json::from_slice::<serde_json::Value>(&output.stdout)
.ok()
.and_then(|version| Some(version.get("gitVersion")?.as_str()?.to_string()));
match found.as_deref().and_then(parse_version) {
Some(version) if version.0 == MINIMUM.0 && version >= MINIMUM => Ok(()),
_ => bail!(
"--sign needs cosign v3.1.3 or a later 3.x — the format the registry verifies — and \
found {} — {INSTALL}",
found.as_deref().unwrap_or("a version it cannot read")
),
}
}
fn parse_version(raw: &str) -> Option<(u64, u64, u64)> {
let mut parts = raw.trim().trim_start_matches('v').split(['.', '-', '+']);
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}
pub async fn sign(
bin: &Path,
pushed: &oci::PushedArtifact,
registry: &str,
coords: &oci::pack::ModuleCoordinates,
) -> Result<String> {
let identity = github_identity().await?;
let scratch = Scratch::new()?;
scratch.write_registry_config(registry)?;
let predicate = scratch.write(
"predicate.json",
serde_json::json!({ "moduleId": coords.id, "version": coords.version })
.to_string()
.as_bytes(),
)?;
let subject = subject(&pushed.image_ref, &pushed.digest);
ui::command(
"cosign attest",
&mut cosign_attest(bin, &subject, &predicate, &scratch.0, &identity.token),
)?;
Ok(identity.email)
}
fn subject(image_ref: &str, digest: &str) -> String {
let repository = match image_ref.rsplit_once(':') {
Some((repository, tag)) if !tag.contains('/') => repository,
_ => image_ref,
};
format!("{repository}@{digest}")
}
fn cosign_attest(
bin: &Path,
subject: &str,
predicate: &Path,
docker_config: &Path,
token: &str,
) -> Command {
let mut command = Command::new(bin);
command
.args([
"attest",
"--yes",
"--oidc-provider",
"envvar",
"--type",
PREDICATE_TYPE,
])
.arg("--predicate")
.arg(predicate)
.arg(subject)
.env("SIGSTORE_ID_TOKEN", token)
.env("DOCKER_CONFIG", docker_config);
command
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Result<Self> {
let dir = std::env::temp_dir().join(format!("portaki-sign-{}", uuid::Uuid::new_v4()));
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder
.create(&dir)
.context("create a temporary directory for cosign")?;
Ok(Self(dir))
}
fn write(&self, name: &str, body: &[u8]) -> Result<PathBuf> {
let path = self.0.join(name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
std::io::Write::write_all(&mut options.open(&path)?, body)
.with_context(|| format!("write {name} for cosign"))?;
Ok(path)
}
fn write_registry_config(&self, registry: &str) -> Result<()> {
let RegistryAuth::Basic(user, password) = oci::auth::resolve_registry_auth(registry)?
else {
bail!("no registry credentials for cosign — set GITHUB_TOKEN or docker login");
};
let auth = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
let body = serde_json::json!({
"auths": { oci::auth::registry_host(registry): { "auth": auth } }
});
self.write("config.json", body.to_string().as_bytes())?;
Ok(())
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct Identity {
token: String,
email: String,
}
async fn github_identity() -> Result<Identity> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.context("open a local port for the sign-in callback")?;
let redirect = format!(
"http://localhost:{}/auth/callback",
listener.local_addr()?.port()
);
let (verifier, state, nonce) = (random(), random(), random());
let url = authorize_url(&redirect, &verifier, &state, &nonce);
ui::blank();
if ui::open_browser(&url) {
ui::success("opened your browser — sign in with GitHub to sign this version");
} else {
ui::field("open", &url);
}
ui::blank();
let code = tokio::time::timeout(SIGN_IN_TIMEOUT, callback(&listener, &state))
.await
.context("no GitHub sign-in within 5 minutes")??;
let token = exchange(&code, &redirect, &verifier).await?;
let email = github_email(&token, &nonce)?;
Ok(Identity { token, email })
}
fn random() -> String {
format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
fn authorize_url(redirect: &str, verifier: &str, state: &str, nonce: &str) -> String {
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()));
let mut url = reqwest::Url::parse(&format!("{ISSUER}/auth")).expect("static URL");
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", "sigstore")
.append_pair("redirect_uri", redirect)
.append_pair("scope", "openid email")
.append_pair("state", state)
.append_pair("nonce", nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("connector_id", GITHUB);
url.into()
}
async fn callback(listener: &tokio::net::TcpListener, state: &str) -> Result<String> {
loop {
let (mut stream, _) = listener.accept().await?;
let mut buffer = vec![0; 8192];
let read = stream.read(&mut buffer).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buffer[..read]);
let Some(query) = callback_query(&request) else {
let _ = stream
.write_all(b"HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n")
.await;
continue;
};
let _ = stream
.write_all(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n\
<p>Portaki: back to the terminal.</p>"
.as_bytes(),
)
.await;
return read_callback(&query, state);
}
}
fn callback_query(request: &str) -> Option<Vec<(String, String)>> {
let target = request
.lines()
.next()?
.strip_prefix("GET ")?
.split(' ')
.next()?;
let url = reqwest::Url::parse(&format!("http://localhost{target}")).ok()?;
(url.path() == "/auth/callback").then(|| url.query_pairs().into_owned().collect())
}
fn read_callback(query: &[(String, String)], state: &str) -> Result<String> {
let get = |key: &str| {
query
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
};
if let Some(error) = get("error") {
bail!(
"GitHub sign-in refused: {error} {}",
get("error_description").unwrap_or_default()
);
}
if get("state") != Some(state) {
bail!("GitHub sign-in answered for another request — start again");
}
get("code")
.map(str::to_string)
.context("GitHub sign-in returned no code")
}
async fn exchange(code: &str, redirect: &str, verifier: &str) -> Result<String> {
#[derive(serde::Deserialize)]
struct Tokens {
id_token: String,
}
let response = crate::http::client()
.post(format!("{ISSUER}/token"))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect),
("client_id", "sigstore"),
("code_verifier", verifier),
])
.send()
.await
.context("exchange the GitHub sign-in at Sigstore")?;
if !response.status().is_success() {
bail!(
"Sigstore refused the GitHub sign-in ({})",
response.status()
);
}
Ok(response
.json::<Tokens>()
.await
.context("read the Sigstore token")?
.id_token)
}
fn github_email(token: &str, nonce: &str) -> Result<String> {
#[derive(serde::Deserialize)]
struct Federated {
connector_id: String,
}
#[derive(serde::Deserialize)]
struct Claims {
email: Option<String>,
email_verified: Option<bool>,
nonce: Option<String>,
federated_claims: Option<Federated>,
}
let payload = token
.split('.')
.nth(1)
.and_then(|part| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(part)
.ok()
})
.context("the Sigstore token is not a JWT")?;
let claims: Claims =
serde_json::from_slice(&payload).context("read the Sigstore token claims")?;
if claims.nonce.as_deref() != Some(nonce) {
bail!("the Sigstore token answers another request — start again");
}
if let Some(federated) = &claims.federated_claims {
if federated.connector_id != GITHUB {
bail!(
"signed in with {} — only a GitHub identity signs a Portaki module",
federated.connector_id
);
}
}
match (claims.email, claims.email_verified) {
(Some(email), Some(true)) => Ok(email),
_ => bail!("your GitHub account has no verified email — Fulcio needs one to sign"),
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[cfg(unix)]
pub(crate) fn fake_cosign(dir: &Path, stdout: &str, code: i32) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("cosign");
std::fs::write(
&path,
format!("#!/bin/sh\nprintf '%s' '{stdout}'\nexit {code}\n"),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[test]
fn ci_refuses_and_points_at_the_release_action() {
let refusal = refuse_in_ci(true).unwrap_err().to_string();
assert!(refusal.contains("portaki-release-action"));
assert!(refuse_in_ci(false).is_ok());
}
#[test]
fn the_pushed_digest_is_signed_never_the_tag() {
assert_eq!(
subject("ghcr.io/acme/portaki-modules-nuki:1.4.0", "sha256:9f2c"),
"ghcr.io/acme/portaki-modules-nuki@sha256:9f2c"
);
assert_eq!(
subject("localhost:5000/portaki-modules-nuki:1.4.0", "sha256:9f2c"),
"localhost:5000/portaki-modules-nuki@sha256:9f2c"
);
}
#[test]
fn the_command_is_cosign_attest_with_the_token_kept_off_argv() {
let command = cosign_attest(
Path::new("/opt/cosign"),
"ghcr.io/acme/portaki-modules-nuki@sha256:9f2c",
Path::new("/tmp/cfg/predicate.json"),
Path::new("/tmp/cfg"),
"secret.jwt.token",
);
assert_eq!(command.get_program(), "/opt/cosign");
let args: Vec<_> = command.get_args().collect();
assert_eq!(
args,
[
"attest",
"--yes",
"--oidc-provider",
"envvar",
"--type",
"https://portaki.app/attestations/author/v1",
"--predicate",
"/tmp/cfg/predicate.json",
"ghcr.io/acme/portaki-modules-nuki@sha256:9f2c"
]
);
assert!(!args
.iter()
.any(|arg| arg.to_string_lossy().contains("secret")));
let envs: Vec<_> = command.get_envs().collect();
assert!(envs.contains(&(
std::ffi::OsStr::new("SIGSTORE_ID_TOKEN"),
Some(std::ffi::OsStr::new("secret.jwt.token"))
)));
assert!(envs.contains(&(
std::ffi::OsStr::new("DOCKER_CONFIG"),
Some(std::ffi::OsStr::new("/tmp/cfg"))
)));
}
#[cfg(unix)]
#[test]
fn cosign_3_1_3_or_a_later_3_x_is_accepted_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
for (version, ok) in [
("v3.1.3", true),
("v3.2.0", true),
("v3.1.2", false),
("v2.4.1", false),
("v4.0.0", false),
] {
let bin = fake_cosign(dir.path(), &format!(r#"{{"gitVersion":"{version}"}}"#), 0);
assert_eq!(check_cosign(&bin).is_ok(), ok, "{version}");
}
}
#[test]
fn a_missing_cosign_says_how_to_install_it() {
let refusal = check_cosign(Path::new("/nonexistent/cosign"))
.unwrap_err()
.to_string();
assert!(refusal.contains("brew install cosign"), "{refusal}");
}
#[cfg(unix)]
#[test]
fn a_failing_cosign_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let bin = fake_cosign(dir.path(), "", 1);
let predicate = dir.path().join("predicate.json");
let mut command = cosign_attest(&bin, "r@sha256:1", &predicate, dir.path(), "t");
assert!(ui::command("cosign attest", &mut command).is_err());
}
#[test]
fn the_predicate_is_private_and_gone_afterwards() {
let scratch = Scratch::new().unwrap();
let dir = scratch.0.clone();
let predicate = scratch
.write(
"predicate.json",
br#"{"moduleId":"nuki","version":"1.4.0"}"#,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&predicate).unwrap(),
r#"{"moduleId":"nuki","version":"1.4.0"}"#
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&predicate).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
drop(scratch);
assert!(!dir.exists());
}
#[test]
fn the_sign_in_asks_sigstore_for_github_with_pkce() {
let url = authorize_url("http://localhost:1/auth/callback", "v", "s", "n");
assert!(url.starts_with("https://oauth2.sigstore.dev/auth/auth?"));
assert!(url.contains("connector_id=https%3A%2F%2Fgithub.com%2Flogin%2Foauth"));
assert!(url.contains("code_challenge_method=S256"));
assert!(!url.contains("code_challenge=v&"));
}
#[test]
fn the_callback_checks_the_state() {
let query = callback_query("GET /auth/callback?code=c&state=s HTTP/1.1\r\n").unwrap();
assert_eq!(read_callback(&query, "s").unwrap(), "c");
assert!(read_callback(&query, "other").is_err());
assert!(callback_query("GET /favicon.ico HTTP/1.1\r\n").is_none());
}
fn jwt(claims: serde_json::Value) -> String {
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
format!("h.{}.s", engine.encode(claims.to_string()))
}
#[test]
fn only_a_verified_github_identity_signs() {
let github = jwt(serde_json::json!({
"email": "dev@example.com", "email_verified": true, "nonce": "n",
"federated_claims": { "connector_id": GITHUB }
}));
assert_eq!(github_email(&github, "n").unwrap(), "dev@example.com");
assert!(github_email(&github, "other").is_err());
let google = jwt(serde_json::json!({
"email": "dev@example.com", "email_verified": true, "nonce": "n",
"federated_claims": { "connector_id": "https://accounts.google.com" }
}));
assert!(github_email(&google, "n").is_err());
let unverified = jwt(serde_json::json!({ "email": "dev@example.com", "nonce": "n" }));
assert!(github_email(&unverified, "n").is_err());
}
}