use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD};
#[cfg(feature = "sign")]
use ed25519_dalek::Signer as _;
use sha2::Digest as _;
#[cfg(feature = "sign")]
use sigstore_bundle::{BundleV03, VerificationMaterialV03};
#[cfg(feature = "sign")]
use sigstore_oidc::IdentityToken;
#[cfg(feature = "sign")]
use sigstore_rekor::{DsseEntry, RekorClient};
#[cfg(feature = "sign")]
use sigstore_sign::SigningContext;
#[cfg(feature = "sign")]
use sigstore_trust_root::SigstoreInstance;
use sigstore_trust_root::{SIGSTORE_PRODUCTION_TRUSTED_ROOT, TrustedRoot};
use sigstore_types::{Artifact, Bundle, DerPublicKey, Sha256Hash, SignatureContent};
#[cfg(feature = "sign")]
use sigstore_types::{DsseEnvelope, DsseSignature, KeyId, PayloadBytes, SignatureBytes};
use sigstore_verify::VerificationPolicy;
#[cfg(feature = "sign")]
use crate::minisign::SecretKey;
use crate::minisign::{PublicKey, key_id_hex};
pub const GITHUB_ISSUER: &str = "https://token.actions.githubusercontent.com";
pub const GITLAB_ISSUER: &str = "https://gitlab.com";
#[cfg(feature = "sign")]
const PUBLIC_REKOR_URL: &str = "https://rekor.sigstore.dev";
pub const IN_TOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
pub const TOKEN_ENV: &str = "SIGSTORE_ID_TOKEN";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(
"no OIDC identity available: run inside a CI job with an id-token permission, or set {TOKEN_ENV}"
)]
NoIdentity,
#[error("identity token: {0}")]
Token(String),
#[error("signing: {0}")]
Sign(String),
#[error("transparency log: {0}")]
Log(String),
#[error("bundle is not valid JSON: {0}")]
BundleJson(String),
#[error("bundle does not carry a DSSE envelope")]
NotDsse,
#[error("bundle payload type is {0:?}, expected {IN_TOTO_PAYLOAD_TYPE}")]
PayloadType(String),
#[error("bundle payload is not an in-toto statement: {0}")]
Payload(String),
#[error("trusted root: {0}")]
TrustedRoot(String),
#[error("bundle does not verify: {0}")]
Verification(String),
#[error("signature does not verify with the pinned key {0}")]
BadKeySignature(String),
#[error("bundle carries a certificate but a key was pinned; pin an identity instead")]
KeyPinnedForCertificate,
#[error("bundle carries a public key hint but an identity was pinned; pin the key instead")]
IdentityPinnedForKey,
#[error("signed by {actual:?}, expected an identity starting with {expected:?}")]
IdentityPrefix { actual: String, expected: String },
#[error("bundle has no transparency log entry; pass --allow-unlogged to accept one")]
Unlogged,
#[error(
"no identity to verify against for {0:?}: pass --identity, --identity-prefix, or --issuer for a keyless release, or --pubkey for a key-signed one"
)]
NoPolicy(String),
#[error(
"no identity to verify against for {0:?}: pass --identity, --identity-prefix, or --issuer"
)]
NoIdentityPolicy(String),
}
#[cfg(feature = "sign")]
fn runtime() -> Result<tokio::runtime::Runtime, Error> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Error::Sign(format!("tokio runtime: {e}")))
}
#[cfg(feature = "sign")]
pub struct OidcIdentity {
token: IdentityToken,
pub identity: String,
pub issuer: String,
}
#[cfg(feature = "sign")]
impl std::fmt::Debug for OidcIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OidcIdentity")
.field("identity", &self.identity)
.field("issuer", &self.issuer)
.finish_non_exhaustive()
}
}
#[cfg(feature = "sign")]
impl OidcIdentity {
fn from_token(token: IdentityToken) -> Result<OidcIdentity, Error> {
let identity = certificate_identity(token.expose_secret())?;
Ok(OidcIdentity {
issuer: token.issuer().to_string(),
identity,
token,
})
}
}
#[cfg(feature = "sign")]
pub fn ambient_identity() -> Result<OidcIdentity, Error> {
if let Ok(raw) = std::env::var(TOKEN_ENV)
&& !raw.trim().is_empty()
{
let token = IdentityToken::from_jwt(raw.trim()).map_err(|e| Error::Token(e.to_string()))?;
return OidcIdentity::from_token(token);
}
let rt = runtime()?;
let detected = rt
.block_on(IdentityToken::detect_ambient())
.map_err(|e| Error::Token(e.to_string()))?;
match detected {
Some(token) => OidcIdentity::from_token(token),
None => Err(Error::NoIdentity),
}
}
pub fn certificate_identity(jwt: &str) -> Result<String, Error> {
let payload = jwt
.split('.')
.nth(1)
.ok_or_else(|| Error::Token("not a JWT".into()))?;
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| Error::Token("payload is not base64url".into()))?;
let claims: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| Error::Token(e.to_string()))?;
let str_claim = |name: &str| claims.get(name).and_then(|v| v.as_str());
let issuer = str_claim("iss").unwrap_or_default();
if issuer == GITHUB_ISSUER
&& let Some(workflow) = str_claim("job_workflow_ref")
{
return Ok(format!("https://github.com/{workflow}"));
}
if let Some(ci_config) = str_claim("ci_config_ref_uri") {
return Ok(format!("https://{ci_config}"));
}
if let Some(email) = str_claim("email")
&& claims
.get("email_verified")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Ok(email.to_string());
}
str_claim("sub")
.map(str::to_string)
.ok_or_else(|| Error::Token("no sub claim".into()))
}
#[cfg(feature = "sign")]
pub enum Signer {
Oidc(OidcIdentity),
Key { key: SecretKey, log: bool },
}
#[cfg(feature = "sign")]
impl Signer {
pub fn identity(&self) -> crate::model::Identity {
match self {
Signer::Oidc(oidc) => crate::model::Identity {
scheme: crate::model::Scheme::SigstoreOidc,
key_id: oidc.identity.clone(),
issuer: Some(oidc.issuer.clone()),
pin_workflow: None,
},
Signer::Key { key, .. } => crate::model::Identity {
scheme: crate::model::Scheme::SigstoreKey,
key_id: key_id_hex(&key.public_key().key_id),
issuer: None,
pin_workflow: None,
},
}
}
}
pub fn spki_der(key: &PublicKey) -> Vec<u8> {
const PREFIX: [u8; 12] = [
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
];
let mut der = PREFIX.to_vec();
der.extend_from_slice(key.key.as_bytes());
der
}
#[cfg(feature = "sign")]
fn spki_pem(key: &PublicKey) -> String {
let b64 = BASE64.encode(spki_der(key));
let mut pem = String::from("-----BEGIN PUBLIC KEY-----\n");
for chunk in b64.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(chunk).unwrap_or_default());
pem.push('\n');
}
pem.push_str("-----END PUBLIC KEY-----\n");
pem
}
pub fn key_hint(key: &PublicKey) -> String {
BASE64.encode(sha2::Sha256::digest(spki_der(key)))
}
#[cfg(feature = "sign")]
pub fn sign(signer: Signer, statement: &[u8]) -> Result<String, Error> {
let rt = runtime()?;
let bundle = match signer {
Signer::Oidc(oidc) => {
let context = SigningContext::from_embedded(SigstoreInstance::PublicGood)
.map_err(|e| Error::Sign(e.to_string()))?;
let signer = context.signer(oidc.token);
rt.block_on(signer.sign_raw_statement(statement))
.map_err(|e| Error::Sign(e.to_string()))?
}
Signer::Key { key, log } => {
let pae = sigstore_types::pae(IN_TOTO_PAYLOAD_TYPE, statement);
let signature = key.signing_key().sign(&pae);
let envelope = DsseEnvelope::new(
IN_TOTO_PAYLOAD_TYPE.to_string(),
PayloadBytes::from_bytes(statement),
DsseSignature::new(
SignatureBytes::from_bytes(&signature.to_bytes()),
KeyId::default(),
),
);
let public = key.public_key();
let mut bundle = BundleV03::new(
VerificationMaterialV03::PublicKey {
hint: key_hint(&public),
},
SignatureContent::DsseEnvelope(envelope.clone()),
);
if log {
let entry: DsseEntry = serde_json::from_value(serde_json::json!({
"apiVersion": "0.0.1",
"kind": "dsse",
"spec": {
"proposedContent": {
"envelope": serde_json::to_string(&envelope)
.map_err(|e| Error::Sign(e.to_string()))?,
"verifiers": [BASE64.encode(spki_pem(&public))],
},
},
}))
.map_err(|e| Error::Sign(e.to_string()))?;
let rekor =
RekorClient::new(PUBLIC_REKOR_URL).map_err(|e| Error::Log(e.to_string()))?;
let log_entry = rt
.block_on(rekor.create_dsse_entry(entry))
.map_err(|e| Error::Log(e.to_string()))?;
let entry = log_entry
.to_bundle_entry(sigstore_types::KindVersion::DsseV001)
.map_err(|e| Error::Log(e.to_string()))?;
bundle = bundle.with_tlog_entry(entry);
}
bundle.into_bundle()
}
};
bundle.to_json().map_err(|e| Error::Sign(e.to_string()))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Policy {
pub issuer: Option<String>,
pub identity: Option<String>,
pub identity_prefix: Option<String>,
}
impl Policy {
pub fn for_project(project: &str) -> Option<Policy> {
if let Some((host, owner, repo)) = crate::model::repository(project) {
return Some(Policy {
issuer: Some(GITHUB_ISSUER.into()),
identity: None,
identity_prefix: Some(format!("https://{host}/{owner}/{repo}/")),
});
}
let (host, path) = project.split_once('/')?;
if path.is_empty() || path.split('/').count() < 2 {
return None;
}
match host {
"gitlab.com" => Some(Policy {
issuer: Some(GITLAB_ISSUER.into()),
identity: None,
identity_prefix: Some(format!("https://gitlab.com/{path}//")),
}),
_ => None,
}
}
pub fn is_empty(&self) -> bool {
self.issuer.is_none() && self.identity.is_none() && self.identity_prefix.is_none()
}
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if let Some(id) = &self.identity {
parts.push(format!("identity {id}"));
}
if let Some(prefix) = &self.identity_prefix {
parts.push(format!("identity under {prefix}"));
}
if let Some(issuer) = &self.issuer {
parts.push(format!("issuer {issuer}"));
}
parts.join(", ")
}
}
pub enum Trust<'a> {
Identity(&'a Policy),
Key(&'a PublicKey),
}
impl Trust<'_> {
pub fn describe(&self) -> String {
match self {
Trust::Identity(policy) => policy.describe(),
Trust::Key(key) => format!("key {}", key_id_hex(&key.key_id)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignedBy {
Identity { identity: String, issuer: String },
Key { key_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleVerified {
pub statement: Vec<u8>,
pub signed_by: SignedBy,
pub integrated_time: Option<i64>,
}
const SOURCE_REPOSITORY_URI: &str = "1.3.6.1.4.1.57264.1.12";
const SOURCE_REPOSITORY_ID: &str = "1.3.6.1.4.1.57264.1.15";
const SOURCE_REPOSITORY_OWNER_URI: &str = "1.3.6.1.4.1.57264.1.16";
const SOURCE_REPOSITORY_OWNER_ID: &str = "1.3.6.1.4.1.57264.1.17";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct SourceRepository {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
}
impl SourceRepository {
pub fn new(uri: impl Into<String>) -> SourceRepository {
SourceRepository {
uri: uri.into(),
id: None,
owner_uri: None,
owner_id: None,
}
}
pub fn with_id(self, id: impl Into<String>) -> SourceRepository {
SourceRepository {
id: Some(id.into()),
..self
}
}
pub fn with_owner(self, uri: impl Into<String>, id: impl Into<String>) -> SourceRepository {
SourceRepository {
owner_uri: Some(uri.into()),
owner_id: Some(id.into()),
..self
}
}
pub fn from_certificate(der: &[u8]) -> Result<Option<SourceRepository>, Error> {
use x509_cert::der::Decode as _;
use x509_cert::der::asn1::Utf8StringRef;
use x509_cert::der::oid::ObjectIdentifier;
let cert = x509_cert::Certificate::from_der(der)
.map_err(|e| Error::Verification(format!("certificate does not parse: {e}")))?;
let extensions = cert
.tbs_certificate()
.extensions()
.map(Vec::as_slice)
.unwrap_or_default();
let value = |oid: &str| -> Result<Option<String>, Error> {
let oid = ObjectIdentifier::new_unwrap(oid);
let Some(ext) = extensions.iter().find(|e| e.extn_id == oid) else {
return Ok(None);
};
let text = Utf8StringRef::from_der(ext.extn_value.as_bytes()).map_err(|e| {
Error::Verification(format!("certificate extension {oid} is malformed: {e}"))
})?;
Ok(Some(text.as_str().to_string()).filter(|s| !s.is_empty()))
};
let Some(uri) = value(SOURCE_REPOSITORY_URI)? else {
return Ok(None);
};
Ok(Some(SourceRepository {
uri,
id: value(SOURCE_REPOSITORY_ID)?,
owner_uri: value(SOURCE_REPOSITORY_OWNER_URI)?,
owner_id: value(SOURCE_REPOSITORY_OWNER_ID)?,
}))
}
}
pub fn source_repository(bundle_json: &str) -> Result<Option<SourceRepository>, Error> {
let bundle = Bundle::from_json(bundle_json).map_err(|e| Error::BundleJson(e.to_string()))?;
match bundle.signing_certificate() {
Some(cert) => SourceRepository::from_certificate(cert.as_bytes()),
None => Ok(None),
}
}
pub fn trusted_root(json: Option<&str>) -> Result<TrustedRoot, Error> {
TrustedRoot::from_json(json.unwrap_or(SIGSTORE_PRODUCTION_TRUSTED_ROOT))
.map_err(|e| Error::TrustedRoot(e.to_string()))
}
pub fn peek_statement(bundle_json: &str) -> Result<Vec<u8>, Error> {
let bundle = Bundle::from_json(bundle_json).map_err(|e| Error::BundleJson(e.to_string()))?;
let SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
return Err(Error::NotDsse);
};
if envelope.payload_type != IN_TOTO_PAYLOAD_TYPE {
return Err(Error::PayloadType(envelope.payload_type.clone()));
}
Ok(envelope.payload.as_bytes().to_vec())
}
pub fn verify(
bundle_json: &str,
trust: &Trust<'_>,
require_log: bool,
trusted_root: &TrustedRoot,
) -> Result<BundleVerified, Error> {
let bundle = Bundle::from_json(bundle_json).map_err(|e| Error::BundleJson(e.to_string()))?;
let SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
return Err(Error::NotDsse);
};
if envelope.payload_type != IN_TOTO_PAYLOAD_TYPE {
return Err(Error::PayloadType(envelope.payload_type.clone()));
}
let statement = envelope.payload.as_bytes().to_vec();
let parsed: serde_json::Value =
serde_json::from_slice(&statement).map_err(|e| Error::Payload(e.to_string()))?;
let first = parsed["subject"][0]["digest"]["sha256"]
.as_str()
.ok_or_else(|| Error::Payload("no subject with a sha256".into()))?;
let digest = Sha256Hash::from_hex(first).map_err(|e| Error::Payload(e.to_string()))?;
let artifact = Artifact::from_digest(digest);
let logged = !bundle.verification_material.tlog_entries.is_empty();
if require_log && !logged {
return Err(Error::Unlogged);
}
let has_certificate = bundle.signing_certificate().is_some();
match trust {
Trust::Identity(policy) => {
if !has_certificate {
return Err(Error::IdentityPinnedForKey);
}
let mut verification = VerificationPolicy::any_identity();
if !require_log {
verification = verification.skip_tlog_unsafe();
}
if let Some(issuer) = &policy.issuer {
verification = verification.require_issuer(issuer.clone());
}
let result = sigstore_verify::verify(artifact, &bundle, &verification, trusted_root)
.map_err(|e| Error::Verification(e.to_string()))?;
let identity = result
.identity()
.map(|identity| identity.as_str().to_owned())
.ok_or_else(|| Error::Verification("certificate has no subject identity".into()))?;
let issuer = result
.issuer()
.map(str::to_owned)
.ok_or_else(|| Error::Verification("certificate has no issuer".into()))?;
if let Some(expected) = &policy.identity
&& !crate::forge::same_url(&identity, expected)
{
return Err(Error::Verification(format!(
"identity mismatch: expected {expected}, got {identity}"
)));
}
if let Some(prefix) = &policy.identity_prefix
&& !crate::forge::identity_has_prefix(&identity, prefix)
{
return Err(Error::IdentityPrefix {
actual: identity,
expected: prefix.clone(),
});
}
Ok(BundleVerified {
statement,
signed_by: SignedBy::Identity { identity, issuer },
integrated_time: result.integrated_time().map(|t| t.as_second()),
})
}
Trust::Key(key) => {
if has_certificate {
return Err(Error::KeyPinnedForCertificate);
}
let key_id = key_id_hex(&key.key_id);
let pae = envelope.pae();
let ok = <[u8; 64]>::try_from(envelope.signature.sig.as_bytes())
.ok()
.map(|b| ed25519_dalek::Signature::from_bytes(&b))
.is_some_and(|sig| ed25519_dalek::Verifier::verify(&key.key, &pae, &sig).is_ok());
if !ok {
return Err(Error::BadKeySignature(key_id));
}
let integrated_time = if logged {
let der = DerPublicKey::from_bytes(&spki_der(key));
let result = sigstore_verify::verify(
artifact,
&bundle,
&sigstore_verify::PublicKeyVerificationPolicy::new(der),
trusted_root,
)
.map_err(|e| Error::Verification(e.to_string()))?;
result.integrated_time().map(|t| t.as_second())
} else {
None
};
Ok(BundleVerified {
statement,
signed_by: SignedBy::Key { key_id },
integrated_time,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt(claims: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#);
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
format!("{header}.{payload}.sig")
}
#[test]
fn github_actions_identity_is_the_workflow_uri() {
let token = jwt(serde_json::json!({
"iss": GITHUB_ISSUER,
"sub": "repo:jdx/mise:ref:refs/tags/v1.0.0",
"job_workflow_ref": "jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0",
"aud": "sigstore", "exp": 4102444800u64,
}));
assert_eq!(
certificate_identity(&token).unwrap(),
"https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0"
);
let email = jwt(serde_json::json!({
"iss": "https://accounts.google.com", "sub": "123",
"email": "me@example.com", "email_verified": true,
"aud": "sigstore", "exp": 4102444800u64,
}));
assert_eq!(certificate_identity(&email).unwrap(), "me@example.com");
let unverified = jwt(serde_json::json!({
"iss": "https://x", "sub": "123", "email": "me@example.com",
"aud": "sigstore", "exp": 4102444800u64,
}));
assert_eq!(certificate_identity(&unverified).unwrap(), "123");
assert!(certificate_identity("garbage").is_err());
}
#[test]
fn forge_projects_imply_a_policy() {
let p = Policy::for_project("github.com/jdx/mise").unwrap();
assert_eq!(p.issuer.as_deref(), Some(GITHUB_ISSUER));
assert_eq!(
p.identity_prefix.as_deref(),
Some("https://github.com/jdx/mise/")
);
let sub = Policy::for_project("github.com/oxc-project/oxc/oxlint").unwrap();
assert_eq!(
sub.identity_prefix.as_deref(),
Some("https://github.com/oxc-project/oxc/")
);
assert!(Policy::for_project("mise.jdx.dev").is_none());
assert!(Policy::for_project("github.com/jdx").is_none());
assert!(Policy::for_project("gitlab.com/group/proj").is_some());
assert_eq!(
p.describe(),
"identity under https://github.com/jdx/mise/, issuer https://token.actions.githubusercontent.com"
);
}
#[test]
fn a_certificate_that_does_not_parse_is_an_error() {
assert!(matches!(
SourceRepository::from_certificate(b"not a certificate"),
Err(Error::Verification(_))
));
}
#[test]
fn embedded_trusted_root_parses() {
trusted_root(None).unwrap();
}
#[test]
#[cfg(feature = "sign")]
fn key_signed_bundles_round_trip_unlogged() {
let root = trusted_root(None).unwrap();
let key = SecretKey::from_seed([7u8; 32]);
let statement = br#"{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"a","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}],"predicateType":"x","predicate":{}}"#;
let bundle = sign(
Signer::Key {
key: key.clone(),
log: false,
},
statement,
)
.unwrap();
assert!(bundle.contains("\"publicKey\""), "{bundle}");
assert_eq!(peek_statement(&bundle).unwrap(), statement);
assert_eq!(source_repository(&bundle).unwrap(), None);
let public = key.public_key();
let err = verify(&bundle, &Trust::Key(&public), true, &root).unwrap_err();
assert!(matches!(err, Error::Unlogged), "{err}");
let ok = verify(&bundle, &Trust::Key(&public), false, &root).unwrap();
assert_eq!(ok.statement, statement);
assert_eq!(
ok.signed_by,
SignedBy::Key {
key_id: key_id_hex(&public.key_id)
}
);
assert_eq!(ok.integrated_time, None);
let other = SecretKey::from_seed([8u8; 32]).public_key();
let err = verify(&bundle, &Trust::Key(&other), false, &root).unwrap_err();
assert!(matches!(err, Error::BadKeySignature(_)), "{err}");
let err = verify(&bundle, &Trust::Identity(&Policy::default()), false, &root).unwrap_err();
assert!(matches!(err, Error::IdentityPinnedForKey), "{err}");
let tampered = bundle.replace("\"payload\":\"", "\"payload\":\"e");
assert!(verify(&tampered, &Trust::Key(&public), false, &root).is_err());
assert!(matches!(
verify("nope", &Trust::Key(&public), false, &root).unwrap_err(),
Error::BundleJson(_)
));
}
#[test]
#[cfg(feature = "sign")]
fn spki_and_hint_are_stable() {
let key = SecretKey::from_seed([1u8; 32]).public_key();
let der = spki_der(&key);
assert_eq!(der.len(), 44);
assert_eq!(&der[12..], key.key.as_bytes());
assert_eq!(key_hint(&key).len(), 44);
assert!(spki_pem(&key).starts_with("-----BEGIN PUBLIC KEY-----\n"));
}
}