Extract reusable keychain sigchain model
This commit is contained in:
parent
5b2c30f817
commit
4013c868aa
11 changed files with 938 additions and 20 deletions
|
|
@ -41,6 +41,34 @@ pub struct KeychainOpSignature {
|
|||
pub created_at: UnixMillis,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainAllowedSigner {
|
||||
pub key: KeyId,
|
||||
pub principal: String,
|
||||
pub public_key: String,
|
||||
pub valid_after_ms: Option<i64>,
|
||||
pub valid_before_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainSigchainReport {
|
||||
pub ops: usize,
|
||||
pub signatures: usize,
|
||||
pub accepted_ops: usize,
|
||||
pub rejected_ops: usize,
|
||||
pub active_admin_keys: usize,
|
||||
pub accepted_head: Option<AuthOpId>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainSigchainEntry {
|
||||
pub op: KeychainOp,
|
||||
pub signatures: Vec<KeychainOpSignature>,
|
||||
}
|
||||
|
||||
pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeEnrollmentRequest {
|
||||
pub id: AuthOpId,
|
||||
|
|
@ -127,6 +155,11 @@ pub struct NodeEnrollmentRequestSigningPayload {
|
|||
pub enum KeychainError {
|
||||
#[error("invalid node enrollment status: {0}")]
|
||||
InvalidEnrollmentStatus(String),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -152,6 +185,10 @@ pub enum KeychainOpKind {
|
|||
KeychainInit,
|
||||
AdminKeyAdd {
|
||||
key: KeyId,
|
||||
public_key: Option<String>,
|
||||
principal: Option<String>,
|
||||
valid_after_ms: Option<i64>,
|
||||
valid_before_ms: Option<i64>,
|
||||
},
|
||||
AdminKeyRevoke {
|
||||
key: KeyId,
|
||||
|
|
@ -254,7 +291,7 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
|||
for op in ops {
|
||||
match &op.kind {
|
||||
KeychainOpKind::KeychainInit => initialized = true,
|
||||
KeychainOpKind::AdminKeyAdd { key } => {
|
||||
KeychainOpKind::AdminKeyAdd { key, .. } => {
|
||||
admin_keys.insert(key.clone());
|
||||
}
|
||||
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||
|
|
@ -364,6 +401,204 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn sorted_keychain_ops(mut ops: Vec<KeychainOp>) -> Vec<KeychainOp> {
|
||||
ops.sort_by(|left, right| {
|
||||
(
|
||||
left.created_at.0,
|
||||
keychain_op_order(&left.kind),
|
||||
left.id.to_string(),
|
||||
)
|
||||
.cmp(&(
|
||||
right.created_at.0,
|
||||
keychain_op_order(&right.kind),
|
||||
right.id.to_string(),
|
||||
))
|
||||
});
|
||||
ops
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 {
|
||||
match kind {
|
||||
KeychainOpKind::KeychainInit => 0,
|
||||
KeychainOpKind::AdminKeyAdd { .. } => 1,
|
||||
KeychainOpKind::AdminKeyRevoke { .. } => 2,
|
||||
_ => 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allowed_signers(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
) -> Vec<KeychainAllowedSigner> {
|
||||
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::new();
|
||||
for op in sorted_keychain_ops(ops.to_vec()) {
|
||||
match op.kind {
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key,
|
||||
public_key,
|
||||
principal,
|
||||
valid_after_ms,
|
||||
valid_before_ms,
|
||||
} => {
|
||||
let public_key = public_key
|
||||
.or_else(|| {
|
||||
signatures
|
||||
.iter()
|
||||
.find(|signature| signature.signer == key)
|
||||
.map(|signature| signature.signer_public_key.trim().to_owned())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !public_key.is_empty() {
|
||||
entries.insert(
|
||||
key.clone(),
|
||||
KeychainAllowedSigner {
|
||||
key,
|
||||
principal: principal.unwrap_or_else(|| "admin".to_owned()),
|
||||
public_key,
|
||||
valid_after_ms,
|
||||
valid_before_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||
entries.remove(&key);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
entries.into_values().collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String {
|
||||
let mut text = String::new();
|
||||
for entry in entries {
|
||||
text.push_str(&format!(
|
||||
"{} {}\n",
|
||||
entry.principal,
|
||||
entry.public_key.trim()
|
||||
));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn verify_sigchain(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
verifier: &KeychainSignatureVerifier<'_>,
|
||||
) -> KeychainSigchainReport {
|
||||
let ops = sorted_keychain_ops(ops.to_vec());
|
||||
let mut accepted = Vec::<KeychainOp>::new();
|
||||
let mut trusted_admins = BTreeSet::<KeyId>::new();
|
||||
let mut rejected_ops = 0;
|
||||
for op in &ops {
|
||||
let op_signatures = signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.op_id == op.id)
|
||||
.collect::<Vec<_>>();
|
||||
let bootstrap_init = accepted.is_empty() && matches!(op.kind, KeychainOpKind::KeychainInit);
|
||||
let bootstrap_admin = accepted.len() == 1
|
||||
&& matches!(accepted[0].kind, KeychainOpKind::KeychainInit)
|
||||
&& matches!(&op.kind, KeychainOpKind::AdminKeyAdd { .. });
|
||||
let valid = bootstrap_init
|
||||
|| bootstrap_admin
|
||||
|| op_signatures.iter().any(|signature| {
|
||||
let signer_is_authorized = trusted_admins.contains(&signature.signer);
|
||||
signer_is_authorized
|
||||
&& signature_uses_claimed_key(signature)
|
||||
&& verifier(op, signature)
|
||||
});
|
||||
if valid {
|
||||
accepted.push(op.clone());
|
||||
trusted_admins = reduce_keychain_ops(&accepted)
|
||||
.admin_keys
|
||||
.into_iter()
|
||||
.collect();
|
||||
} else {
|
||||
rejected_ops += 1;
|
||||
}
|
||||
}
|
||||
let view = reduce_keychain_ops(&accepted);
|
||||
KeychainSigchainReport {
|
||||
ops: ops.len(),
|
||||
signatures: signatures.len(),
|
||||
accepted_ops: accepted.len(),
|
||||
rejected_ops,
|
||||
active_admin_keys: view.admin_keys.len(),
|
||||
accepted_head: accepted.last().map(|op| op.id.clone()),
|
||||
note: "verified by replaying keychain operations against the previously accepted admin-key view, similar to git-skm's parent allowed_signers verification".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
|
||||
KeyId::new(admin_key_fingerprint(&signature.signer_public_key)) == signature.signer
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn admin_key_fingerprint(public_key: &str) -> String {
|
||||
format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn sigchain_entries(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
) -> Vec<KeychainSigchainEntry> {
|
||||
sorted_keychain_ops(ops.to_vec())
|
||||
.into_iter()
|
||||
.map(|op| KeychainSigchainEntry {
|
||||
signatures: signatures
|
||||
.iter()
|
||||
.filter(|signature| signature.op_id == op.id)
|
||||
.cloned()
|
||||
.collect(),
|
||||
op,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn encode_sigchain_jsonl(
|
||||
entries: &[KeychainSigchainEntry],
|
||||
) -> Result<String, serde_json::Error> {
|
||||
let mut text = String::new();
|
||||
for entry in entries {
|
||||
text.push_str(&serde_json::to_string(entry)?);
|
||||
text.push('\n');
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn decode_sigchain_jsonl(text: &str) -> Result<Vec<KeychainSigchainEntry>, KeychainError> {
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim().is_empty())
|
||||
.map(|(index, line)| {
|
||||
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
|
||||
KeychainError::SigchainJsonl {
|
||||
line: index + 1,
|
||||
source,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn flatten_sigchain_entries(
|
||||
entries: &[KeychainSigchainEntry],
|
||||
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
|
||||
let ops = entries.iter().map(|entry| entry.op.clone()).collect();
|
||||
let signatures = entries
|
||||
.iter()
|
||||
.flat_map(|entry| entry.signatures.clone())
|
||||
.collect();
|
||||
(ops, signatures)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -390,6 +625,10 @@ mod tests {
|
|||
created_at: UnixMillis(1),
|
||||
kind: KeychainOpKind::AdminKeyAdd {
|
||||
key: "key:admin".into(),
|
||||
public_key: Some("ssh-ed25519 AAAA test@example".to_owned()),
|
||||
principal: Some("admin".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
|
|
@ -422,12 +661,20 @@ mod tests {
|
|||
2,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: "key:admin-a".into(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
|
||||
principal: Some("admin-a".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
op(
|
||||
3,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: "key:admin-b".into(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()),
|
||||
principal: Some("admin-b".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
op(
|
||||
|
|
@ -562,6 +809,110 @@ mod tests {
|
|||
assert!(!view.endpoints.contains_key("endpoint:old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_signers_and_sigchain_jsonl_are_portable() {
|
||||
let ops = vec![
|
||||
op(1, KeychainOpKind::KeychainInit),
|
||||
op(
|
||||
2,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
|
||||
principal: Some("admin-a".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
let signatures = vec![KeychainOpSignature {
|
||||
op_id: ops[1].id.clone(),
|
||||
signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(),
|
||||
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
signature: vec![1],
|
||||
created_at: UnixMillis(2),
|
||||
}];
|
||||
let allowed = allowed_signers(&ops, &signatures);
|
||||
assert_eq!(allowed.len(), 1);
|
||||
assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519"));
|
||||
|
||||
let entries = sigchain_entries(&ops, &signatures);
|
||||
let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl");
|
||||
assert_eq!(jsonl.lines().count(), 2);
|
||||
let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl");
|
||||
assert_eq!(decoded, entries);
|
||||
let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded);
|
||||
assert_eq!(decoded_ops, sorted_keychain_ops(ops));
|
||||
assert_eq!(decoded_signatures, signatures);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchain_verification_replays_against_prior_admin_view() {
|
||||
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();
|
||||
let admin_b: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-b").into();
|
||||
let ops = vec![
|
||||
op(1, KeychainOpKind::KeychainInit),
|
||||
op(
|
||||
2,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: admin_a.clone(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()),
|
||||
principal: Some("admin-a".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
op(
|
||||
3,
|
||||
KeychainOpKind::AdminKeyAdd {
|
||||
key: admin_b.clone(),
|
||||
public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()),
|
||||
principal: Some("admin-b".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
op(
|
||||
4,
|
||||
KeychainOpKind::AdminKeyRevoke {
|
||||
key: admin_a.clone(),
|
||||
},
|
||||
),
|
||||
];
|
||||
let signatures = vec![
|
||||
KeychainOpSignature {
|
||||
op_id: ops[1].id.clone(),
|
||||
signer: admin_a.clone(),
|
||||
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
signature: vec![1],
|
||||
created_at: UnixMillis(2),
|
||||
},
|
||||
KeychainOpSignature {
|
||||
op_id: ops[2].id.clone(),
|
||||
signer: admin_a.clone(),
|
||||
signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(),
|
||||
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
signature: vec![1],
|
||||
created_at: UnixMillis(3),
|
||||
},
|
||||
KeychainOpSignature {
|
||||
op_id: ops[3].id.clone(),
|
||||
signer: admin_b,
|
||||
signer_public_key: "ssh-ed25519 AAAA admin-b".to_owned(),
|
||||
namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
|
||||
signature: vec![1],
|
||||
created_at: UnixMillis(4),
|
||||
},
|
||||
];
|
||||
let report = verify_sigchain(&ops, &signatures, &|_, signature| {
|
||||
signature.signature == vec![1]
|
||||
});
|
||||
assert_eq!(report.accepted_ops, 4);
|
||||
assert_eq!(report.rejected_ops, 0);
|
||||
assert_eq!(report.active_admin_keys, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reducer_excludes_revoked_identity_subtrees() {
|
||||
let ops = vec![
|
||||
|
|
|
|||
Loading…
Reference in a new issue