// Signature authorization and root-chain rotation checks.
//
// Everything here is *structural*. No cryptographic operation is performed:
// this module never reads a public key, never hashes a canonical encoding and
// never verifies a signature value. What it can answer honestly is:
//
// * which key ids claim to have signed a document,
// * whether those key ids are the ones the role authorizes,
// * whether the number of authorized signatures reaches the threshold,
// * what bytes a signature would have to cover for a verifier to accept it,
// * whether a sequence of root documents is a consistent rotation chain.
//
// That last point is where a structural preflight earns its keep: a broken
// rotation chain is invisible to shape checks but guarantees that a real TUF
// client will refuse the update.
///|
/// The key ids listed in an envelope's `signatures` array, in document order.
///
/// An entry counts only when it carries both a non-empty `keyid` and a non-empty
/// `sig`; entries that are not objects or that miss either field are skipped,
/// because [`preflight_report`] already reports those as shape findings and this
/// function is used to decide how many usable signatures a document has.
pub fn signature_keyids(input : String) -> Array[String] {
let document = @json.parse(input) catch { _ => return [] }
let envelope = match document {
Object(envelope) => envelope
_ => return []
}
let signatures = match envelope.get("signatures") {
Some(Array(signatures)) => signatures
_ => return []
}
let keyids : Array[String] = []
for signature in signatures {
match signature {
Object(entry) => {
let keyid = match entry.get("keyid") {
Some(String(keyid)) => keyid
_ => ""
}
let value = match entry.get("sig") {
Some(String(value)) => value
_ => ""
}
if keyid != "" && value != "" {
keyids.push(keyid)
}
}
_ => ()
}
}
keyids
}
///|
/// The `sig` values listed in an envelope, in document order, skipping entries
/// that carry no usable value.
///
/// Exposed so a caller can tell "two signatures" from "one signature repeated",
/// and so a test can prove the preflight never treats a signature as valid.
pub fn signature_values(input : String) -> Array[String] {
let document = @json.parse(input) catch { _ => return [] }
let envelope = match document {
Object(envelope) => envelope
_ => return []
}
let signatures = match envelope.get("signatures") {
Some(Array(signatures)) => signatures
_ => return []
}
let values : Array[String] = []
for signature in signatures {
match signature {
Object(entry) =>
match entry.get("sig") {
Some(String(value)) => if value != "" { values.push(value) }
_ => ()
}
_ => ()
}
}
values
}
///|
/// The distinct key ids in `keyids`, in first-seen order.
pub fn distinct(keyids : Array[String]) -> Array[String] {
let seen : Map[String, Bool] = Map([])
let result : Array[String] = []
for keyid in keyids {
match seen.get(keyid) {
Some(_) => ()
None => {
seen[keyid] = true
result.push(keyid)
}
}
}
result
}
///|
/// Whether `keyids` contains `keyid`.
pub fn contains_keyid(keyids : Array[String], keyid : String) -> Bool {
for candidate in keyids {
if candidate == keyid {
return true
}
}
false
}
///|
/// How many distinct key ids in `signature_keyids` are also authorized by the
/// role, which is the number a threshold compares against.
pub fn count_authorized(
signature_keyids : Array[String],
authorized_keyids : Array[String],
) -> Int {
let mut count = 0
for keyid in distinct(signature_keyids) {
if contains_keyid(authorized_keyids, keyid) {
count += 1
}
}
count
}
///|
/// The signature key ids that are neither authorized by the previous root nor
/// part of the document's own role.
///
/// During a root rotation the new root is expected to carry signatures from both
/// the previous root's keys and its own new keys: a client checks the former, and
/// a second client that already holds the new root checks the latter. Reporting
/// the new keys as "unexpected" would therefore be noise, so they are excluded.
pub fn unexpected_signers(
signature_keyids : Array[String],
authorized_keyids : Array[String],
own_keyids : Array[String],
) -> Array[String] {
let result : Array[String] = []
for keyid in signature_keyids {
if !contains_keyid(authorized_keyids, keyid) &&
!contains_keyid(own_keyids, keyid) {
result.push(keyid)
}
}
result
}
///|
/// The outcome of a probability-free authorization check: enough authorized
/// signatures, not enough, or a problem that prevented the check entirely.
pub enum AuthorizationOutcome {
/// At least `threshold` distinct authorized keys signed.
Satisfied
/// Fewer than `threshold` distinct authorized keys signed.
Unsatisfied
/// The document could not be parsed, or it names a role that does not exist.
Undecidable
}
///|
/// Whether two verdicts are the same.
pub fn AuthorizationOutcome::equal(
self : AuthorizationOutcome,
other : AuthorizationOutcome,
) -> Bool {
match (self, other) {
(Satisfied, Satisfied)
| (Unsatisfied, Unsatisfied)
| (Undecidable, Undecidable) => true
_ => false
}
}
///|
/// A short name for the verdict, used in tests and log lines.
pub fn AuthorizationOutcome::to_string(self : AuthorizationOutcome) -> String {
match self {
Satisfied => "satisfied"
Unsatisfied => "unsatisfied"
Undecidable => "undecidable"
}
}
///|
/// A structural authorization verdict for one document against one role.
pub struct Authorization {
role : String
keyids : Array[String]
threshold : Int
satisfied_count : Int
authorized_keyids : Array[String]
outcome : AuthorizationOutcome
}
///|
/// The role that was checked.
pub fn Authorization::role(self : Authorization) -> String {
self.role
}
///|
/// The declared threshold, or `0` when the role entry was unusable.
pub fn Authorization::threshold(self : Authorization) -> Int {
self.threshold
}
///|
/// How many distinct authorized keys signed the document.
pub fn Authorization::satisfied_count(self : Authorization) -> Int {
self.satisfied_count
}
///|
/// The key ids the role authorizes, in root document order.
pub fn Authorization::authorized_keyids(self : Authorization) -> Array[String] {
self.authorized_keyids
}
///|
/// The verdict.
pub fn Authorization::outcome(self : Authorization) -> AuthorizationOutcome {
self.outcome
}
///|
/// Whether the document carries enough authorized signatures to be considered
/// by a verifier. Never a statement that any signature is valid.
pub fn Authorization::is_satisfied(self : Authorization) -> Bool {
self.outcome is Satisfied
}
///|
/// The key ids of `role` from a `root` document, or `None` when the root is not
/// usable or does not define the role.
pub fn authorized_keyids(root : String, role : String) -> Array[String]? {
let signed = signed_object(root)
let signed = match signed {
Some(signed) => signed
None => return None
}
match role_keyids(signed, role) {
Some(keyids) => Some(keyids)
None => None
}
}
///|
/// A root role's `keyids` array, or `None` when the roles table or the entry is
/// missing or not an array of strings.
///
/// Non-string elements are dropped here; shape checks report them separately.
fn role_keyids(signed : Map[String, Json], role : String) -> Array[String]? {
let roles = match signed.get("roles") {
Some(Object(roles)) => roles
_ => return None
}
let entry = match roles.get(role) {
Some(Object(entry)) => entry
_ => return None
}
match entry.get("keyids") {
Some(Array(keyids)) => {
let result : Array[String] = []
for keyid in keyids {
match keyid {
String(name) => result.push(name)
_ => ()
}
}
Some(result)
}
_ => None
}
}
///|
/// A root role's `threshold`, or `None` when it is missing or not a positive
/// integer.
pub fn role_threshold(root : String, role : String) -> Int? {
let signed = match signed_object(root) {
Some(signed) => signed
None => return None
}
let roles = match signed.get("roles") {
Some(Object(roles)) => roles
_ => return None
}
let entry = match roles.get(role) {
Some(Object(entry)) => entry
_ => return None
}
match entry.get("threshold") {
Some(Number(value, ..)) if in_int_range(value) => Some(value.to_int())
_ => None
}
}
///|
/// Check whether `document` carries enough authorized signatures for `role`, as
/// authorized by `root`.
///
/// `root` is the root metadata whose `roles[role]` entry applies: the document's
/// own root when checking a root at its own version, or the previous root when
/// checking a rotation.
pub fn check_authorization(
root : String,
document : String,
role : String,
) -> Authorization {
let signature_keyids = signature_keyids(document)
let keyids = match authorized_keyids(root, role) {
Some(keyids) => keyids
None =>
return Authorization::{
role,
keyids: signature_keyids,
threshold: 0,
satisfied_count: 0,
authorized_keyids: [],
outcome: Undecidable,
}
}
let threshold = match role_threshold(root, role) {
Some(threshold) => threshold
None =>
return Authorization::{
role,
keyids: signature_keyids,
threshold: 0,
satisfied_count: 0,
authorized_keyids: keyids,
outcome: Undecidable,
}
}
let satisfied_count = count_authorized(signature_keyids, keyids)
Authorization::{
role,
keyids: signature_keyids,
threshold,
satisfied_count,
authorized_keyids: keyids,
outcome: if satisfied_count >= threshold {
Satisfied
} else {
Unsatisfied
},
}
}
///|
/// Append the findings for one authorization verdict to `findings`.
///
/// `document` is the metadata being authorized. Its own role key ids are read so
/// that, during a root rotation, the new root's own keys are not reported as
/// unexpected signers: a client that already holds that root checks them.
pub fn authorization_findings(
authorization : Authorization,
document : String,
findings : Array[Finding],
) -> Unit {
let role = authorization.role
match authorization.outcome {
Undecidable =>
findings.push(Finding::{
severity: Error,
code: "unverifiable-role:" + role,
location: "",
})
Satisfied => ()
Unsatisfied =>
findings.push(Finding::{
severity: Error,
code: "insufficient-signatures:" + role,
location: "\{authorization.satisfied_count}/\{authorization.threshold}",
})
}
let own_keyids = match authorized_keyids(document, role) {
Some(keyids) => keyids
None => []
}
for
keyid in unexpected_signers(
authorization.keyids,
authorization.authorized_keyids,
own_keyids,
) {
findings.push(Finding::{
severity: Warning,
code: "unexpected-signer:" + role,
location: keyid,
})
}
}
///|
/// Which key ids signed each document of a root chain, in the order the
/// documents were supplied.
///
/// The chain is the sequence a client walks while rotating trust: the
/// authoritative root first, then each newer root in turn.
pub fn chain_signers(chain : ArrayView[String]) -> Array[Array[String]] {
let result : Array[Array[String]] = []
for document in chain {
result.push(signature_keyids(document))
}
result
}
///|
/// The version numbers of a root chain, with `None` for a document whose
/// version field is missing or malformed.
pub fn chain_versions(chain : ArrayView[String]) -> Array[Int?] {
let result : Array[Int?] = []
for document in chain {
result.push(metadata_version(document))
}
result
}
///|
/// Append findings for a root rotation chain.
///
/// `chain[0]` is the root the caller already trusts; every later element must
/// be the next version and must be signed by keys authorized by its immediate
/// predecessor's `root` role, because that is the only root a client would hold
/// when it processes the update. Cryptographic verification is out of scope, so
/// a chain that is structurally consistent here can still fail in a real
/// verifier.
pub fn check_root_chain(
chain : ArrayView[String],
findings : Array[Finding],
) -> Unit {
if chain.length() == 0 {
findings.push(Finding::{
severity: Error,
code: "empty-root-chain",
location: "",
})
return
}
// Every root must be structurally usable before the rotation itself is judged.
for index in 0..
if current != previous + 1 {
findings.push(Finding::{
severity: Error,
code: "non-consecutive-root-version",
location: "\{location}:expected=\{previous + 1},actual=\{current}",
})
}
(None, _) =>
findings.push(Finding::{
severity: Error,
code: "missing-root-version",
location: "root[" + (index - 1).to_string() + "]",
})
(_, None) =>
findings.push(Finding::{
severity: Error,
code: "missing-root-version",
location,
})
}
// The rotation is authorized by the *previous* root's root role.
authorization_findings(
check_authorization(chain[index - 1], chain[index], "root"),
chain[index],
findings,
)
}
}
///|
/// Findings for a root rotation chain, as `severity:code[:location]` strings.
pub fn preflight_root_chain(chain : ArrayView[String]) -> Array[String] {
let findings : Array[Finding] = []
check_root_chain(chain, findings)
Report::{ findings, role: "root-chain", version: None, }.to_strings()
}