///|
/// A bundle text that cannot be read.
pub suberror BundleError {
/// The media type is not a bundle version that the library reads.
UnsupportedBundleMediaType(String)
/// The field at `field` (a dotted JSON path) breaks `rule`.
InvalidBundle(field~ : String, rule~ : String)
} derive(Debug)
///|
pub extend BundleError with @debug.Debug::{to_repr}
///|
/// Text for people that names the field and the rule.
pub fn BundleError::message(self : BundleError) -> String {
match self {
UnsupportedBundleMediaType(media_type) =>
"Unsupported bundle media type \{media_type.escape()}"
InvalidBundle(field~, rule~) =>
if field.is_empty() {
"Invalid bundle: \{rule}"
} else {
"Invalid bundle field \{field}: \{rule}"
}
}
}
///|
/// The bundle format version.
pub enum BundleVersion {
V0_1
V0_2
V0_3
} derive(Eq, Compare, Debug)
///|
pub extend BundleVersion with Eq::{equal, not_equal}
///|
pub extend BundleVersion with Compare::{compare, op_lt, op_le, op_ge, op_gt}
///|
pub extend BundleVersion with @debug.Debug::{to_repr}
///|
/// The key material that signed the content.
priv enum Material {
/// A leaf certificate. Like sigstore-go, verification ignores the other
/// certificates of a v0.1 or v0.2 chain and uses only the trusted root.
Certificate(Bytes)
/// A key that the verifier knows by a hint. Not supported.
KeyHint
}
///|
priv struct Envelope {
payload : Bytes
payload_type : String
signatures : Array[Bytes]
}
///|
/// The hash of a message digest, read from its protobuf enum name.
priv enum DigestAlgorithm {
Sha2_256
Sha2_384
Sha2_512
/// Another `HashAlgorithm` name, such as `SHA3_256`.
OtherDigest(String)
}
///|
fn DigestAlgorithm::from_name(name : String) -> DigestAlgorithm {
match name {
"SHA2_256" => Sha2_256
"SHA2_384" => Sha2_384
"SHA2_512" => Sha2_512
other => OtherDigest(other)
}
}
///|
/// The name that Rekor writes in a hashedrekord body.
fn DigestAlgorithm::rekor_name(self : DigestAlgorithm) -> String {
match self {
Sha2_256 => "sha256"
Sha2_384 => "sha384"
Sha2_512 => "sha512"
OtherDigest(name) => name
}
}
///|
/// The protobuf enum name.
fn DigestAlgorithm::name(self : DigestAlgorithm) -> String {
match self {
Sha2_256 => "SHA2_256"
Sha2_384 => "SHA2_384"
Sha2_512 => "SHA2_512"
OtherDigest(name) => name
}
}
///|
priv enum Content {
Dsse(Envelope)
Message(algorithm~ : DigestAlgorithm, digest~ : Bytes, signature~ : Bytes)
}
///|
priv struct InclusionProof {
log_index : Int64
root_hash : Bytes
tree_size : Int64
hashes : Array[Bytes]
checkpoint : String
}
///|
priv struct LogEntry {
log_index : Int64
log_id : Bytes
kind : String
version : String
integrated_time : Int64
promise : Bytes?
proof : InclusionProof?
body : Bytes
}
///|
/// A parsed Sigstore bundle. Only `Bundle::parse` makes it, and it holds the
/// values that verification checks.
pub struct Bundle {
priv version : BundleVersion
priv material : Material
priv content : Content
priv entries : Array[LogEntry]
priv timestamps : Int
}
///|
/// The format version of the bundle.
pub fn Bundle::version(self : Bundle) -> BundleVersion {
self.version
}
///|
/// The most tlog entries and timestamps that a bundle can hold, as in sigstore-go.
let max_entries : Int = 32
///|
let hash_algorithm_names : Array[String] = [
"HASH_ALGORITHM_UNSPECIFIED", "SHA2_256", "SHA2_384", "SHA2_512", "SHA3_256", "SHA3_384",
]
///|
fn media_version(media_type : String) -> BundleVersion? {
match media_type {
"application/vnd.dev.sigstore.bundle+json;version=0.1" => Some(V0_1)
"application/vnd.dev.sigstore.bundle+json;version=0.2" => Some(V0_2)
"application/vnd.dev.sigstore.bundle+json;version=0.3" => Some(V0_3)
"application/vnd.dev.sigstore.bundle.v0.1+json" => Some(V0_1)
"application/vnd.dev.sigstore.bundle.v0.2+json" => Some(V0_2)
"application/vnd.dev.sigstore.bundle.v0.3+json" => Some(V0_3)
_ => None
}
}
///|
/// Read a bundle from its protobuf JSON text, with the rules of sigstore-go.
pub fn Bundle::parse(text : StringView) -> Bundle raise BundleError {
parse_bundle(text) catch {
Shape(field, rule) => raise InvalidBundle(field~, rule~)
UnsupportedBundleMediaType(_) as err => raise err
InvalidBundle(..) as err => raise err
err => raise InvalidBundle(field="", rule=err.to_string())
}
}
///|
fn parse_bundle(text : StringView) -> Bundle raise {
let json = parse_json(text)
// The media type comes first, so a later version with new fields is
// "not supported", not "invalid".
let raw_media_type = match json {
Object(map) =>
match map.get("mediaType") {
Some(value) => Some(value)
None => map.get("media_type")
}
_ => None
}
guard raw_media_type is Some(String(media_type)) else {
raise Shape("mediaType", "field is required")
}
guard media_version(media_type) is Some(version) else {
raise UnsupportedBundleMediaType(media_type)
}
let root = fields(json, "", [
"mediaType", "verificationMaterial", "messageSignature", "dsseEnvelope",
])
let material = root.require_object("verificationMaterial", [
"publicKey", "x509CertificateChain", "certificate", "tlogEntries", "timestampVerificationData",
])
let content = match (root.has("dsseEnvelope"), root.has("messageSignature")) {
(true, false) => Dsse(read_envelope(root.require_object("dsseEnvelope", ["payload", "payloadType", "signatures"])))
(false, true) => read_message(root.require_object("messageSignature", ["messageDigest", "signature"]))
_ => raise Shape("", "exactly one of dsseEnvelope and messageSignature is required")
}
let key_material = read_material(material, version)
let entries = material
.array("tlogEntries")
.mapi((index, json) => read_entry(json, "\{material.at("tlogEntries")}[\{index}]", version))
guard entries.length() <= max_entries else {
raise Shape(material.at("tlogEntries"), "more than \{max_entries} entries")
}
let timestamps = match material.object("timestampVerificationData", ["rfc3161Timestamps"]) {
Some(data) => {
let list = data.array("rfc3161Timestamps")
for index, item in list {
ignore(fields(item, "\{data.at("rfc3161Timestamps")}[\{index}]", ["signedTimestamp"]).require_bytes("signedTimestamp"))
}
list.length()
}
None => 0
}
guard timestamps <= max_entries else {
raise Shape(material.at("timestampVerificationData"), "more than \{max_entries} timestamps")
}
{ version, material: key_material, content, entries, timestamps }
}
///|
fn read_material(material : Fields, version : BundleVersion) -> Material raise Shape {
let present = ["publicKey", "x509CertificateChain", "certificate"].filter(name => material.has(name))
guard present.length() == 1 else {
raise Shape(material.path, "exactly one of publicKey, x509CertificateChain and certificate is required")
}
match present[0] {
"publicKey" => {
ignore(material.require_object("publicKey", ["hint"]).string("hint"))
KeyHint
}
"x509CertificateChain" => {
guard version != V0_3 else {
raise Shape(material.at("x509CertificateChain"), "a v0.3 bundle must use certificate")
}
let chain = material.require_object("x509CertificateChain", ["certificates"])
let certificates = chain
.array("certificates")
.mapi((index, json) => fields(json, "\{chain.at("certificates")}[\{index}]", ["rawBytes"]).require_bytes("rawBytes"))
guard !certificates.is_empty() else {
raise Shape(chain.at("certificates"), "the chain is empty")
}
Certificate(certificates[0])
}
_ => {
let certificate = material.require_object("certificate", ["rawBytes"])
Certificate(certificate.require_bytes("rawBytes"))
}
}
}
///|
fn read_envelope(envelope : Fields) -> Envelope raise Shape {
let signatures = envelope
.array("signatures")
.mapi((index, json) => fields(json, "\{envelope.at("signatures")}[\{index}]", ["sig", "keyid"]).require_bytes("sig"))
{
payload: envelope.require_bytes("payload"),
payload_type: envelope.require_string("payloadType"),
signatures,
}
}
///|
fn read_message(message : Fields) -> Content raise Shape {
let digest = message.require_object("messageDigest", ["algorithm", "digest"])
Message(
algorithm=DigestAlgorithm::from_name(
digest
.enum_name("algorithm", hash_algorithm_names)
.unwrap_or("HASH_ALGORITHM_UNSPECIFIED"),
),
digest=digest.require_bytes("digest"),
signature=message.require_bytes("signature"),
)
}
///|
fn read_entry(json : Json, path : String, version : BundleVersion) -> LogEntry raise Shape {
let entry = fields(json, path, [
"logIndex", "logId", "kindVersion", "integratedTime", "inclusionPromise", "inclusionProof",
"canonicalizedBody",
])
let log_index = entry.int64("logIndex")
guard log_index >= 0L else { raise Shape(entry.at("logIndex"), "negative log index") }
let log_id = entry.require_object("logId", ["keyId"]).require_bytes("keyId")
let kind_version = entry.require_object("kindVersion", ["kind", "version"])
let promise = match entry.object("inclusionPromise", ["signedEntryTimestamp"]) {
Some(promise) => Some(promise.require_bytes("signedEntryTimestamp"))
None => None
}
let proof = match entry.object("inclusionProof", ["logIndex", "rootHash", "treeSize", "hashes", "checkpoint"]) {
Some(proof) => {
let checkpoint = proof.require_object("checkpoint", ["envelope"]).require_string("envelope")
let hashes = proof
.array("hashes")
.mapi((index, item) => match item {
String(text) =>
@encoding.decode_base64(text) catch {
_ => raise Shape("\{proof.at("hashes")}[\{index}]", "not base64")
}
_ => raise Shape("\{proof.at("hashes")}[\{index}]", "expected a string")
})
Some({
log_index: proof.int64("logIndex"),
root_hash: proof.require_bytes("rootHash"),
tree_size: proof.int64("treeSize"),
hashes,
checkpoint,
})
}
None => None
}
if version == V0_1 {
guard promise is Some(_) else {
raise Shape(entry.at("inclusionPromise"), "a v0.1 bundle requires an inclusion promise")
}
} else {
guard proof is Some(_) else {
raise Shape(entry.at("inclusionProof"), "a v0.2 or later bundle requires an inclusion proof")
}
}
{
log_index,
log_id,
kind: kind_version.require_string("kind"),
version: kind_version.require_string("version"),
integrated_time: entry.int64("integratedTime"),
promise,
proof,
body: entry.require_bytes("canonicalizedBody"),
}
}