Add static keychain publication workflow
This commit is contained in:
parent
b6ffcde54c
commit
cfd41522d1
11 changed files with 1852 additions and 46 deletions
|
|
@ -23,7 +23,11 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
|
||||
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
|
||||
pub const NODE_ENROLLMENT_REQUEST_NAMESPACE: &str = "geth.node-enrollment-request.v1@geth.local";
|
||||
pub const AUTHORIZED_KEYS_NAMESPACE: &str = "geth.authorized-keys.v1@eric.wendland.dev";
|
||||
pub const KEYCHAIN_CHECKPOINT_NAMESPACE: &str = "geth.sigchain-checkpoint.v1@eric.wendland.dev";
|
||||
pub const DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL: &str = "https://example.com/.well-known/sshsigchain/";
|
||||
pub const DEFAULT_ADMIN_PRINCIPAL: &str = "admin";
|
||||
pub const KEYCHAIN_CHECKPOINT_VERSION: u16 = 1;
|
||||
|
||||
pub type SignedKeychainOp = geth_codec::SignedEnvelope<KeychainOp, KeyId>;
|
||||
|
||||
|
|
@ -194,6 +198,21 @@ pub struct KeychainSigchainEntry {
|
|||
pub signatures: Vec<KeychainOpSignature>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainCheckpoint {
|
||||
pub version: u16,
|
||||
pub profile: KeychainProfile,
|
||||
pub base_url: String,
|
||||
pub head: Option<AuthOpId>,
|
||||
pub ops: usize,
|
||||
pub signatures: usize,
|
||||
pub sigchain_bytes: u64,
|
||||
pub sigchain_hash: String,
|
||||
pub allowed_signers_hash: String,
|
||||
pub reduced_view_hash: String,
|
||||
pub generated_at: UnixMillis,
|
||||
}
|
||||
|
||||
pub trait KeychainSignatureVerifier {
|
||||
fn verify_keychain_signature(&self, op: &KeychainOp, signature: &KeychainOpSignature) -> bool;
|
||||
}
|
||||
|
|
@ -299,6 +318,8 @@ pub enum KeychainError {
|
|||
InvalidNamespaceComponent { field: String, value: String },
|
||||
#[error("invalid keychain principal: {0}")]
|
||||
InvalidPrincipal(String),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
|
|
@ -763,6 +784,44 @@ pub fn flatten_sigchain_entries(
|
|||
(ops, signatures)
|
||||
}
|
||||
|
||||
pub fn keychain_checkpoint(
|
||||
ops: &[KeychainOp],
|
||||
signatures: &[KeychainOpSignature],
|
||||
sigchain_jsonl: &str,
|
||||
allowed_signers: &str,
|
||||
base_url: impl Into<String>,
|
||||
generated_at: UnixMillis,
|
||||
) -> Result<KeychainCheckpoint, KeychainError> {
|
||||
let sorted_ops = sorted_keychain_ops(ops.to_vec());
|
||||
let view = reduce_keychain_ops(&sorted_ops);
|
||||
Ok(KeychainCheckpoint {
|
||||
version: KEYCHAIN_CHECKPOINT_VERSION,
|
||||
profile: KeychainProfile::geth(),
|
||||
base_url: normalize_base_url(base_url.into()),
|
||||
head: sorted_ops.last().map(|op| op.id.clone()),
|
||||
ops: sorted_ops.len(),
|
||||
signatures: signatures.len(),
|
||||
sigchain_bytes: sigchain_jsonl.len() as u64,
|
||||
sigchain_hash: blake3_tagged_hash(sigchain_jsonl.as_bytes()),
|
||||
allowed_signers_hash: blake3_tagged_hash(allowed_signers.as_bytes()),
|
||||
reduced_view_hash: geth_codec::hash_canonical(&view)?.to_string(),
|
||||
generated_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn blake3_tagged_hash(bytes: &[u8]) -> String {
|
||||
format!("blake3:{}", blake3::hash(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn normalize_base_url(mut value: String) -> String {
|
||||
if !value.ends_with('/') {
|
||||
value.push('/');
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn validate_namespace(value: &str) -> Result<(), KeychainError> {
|
||||
let has_single_domain_separator = value.matches('@').count() == 1;
|
||||
let valid = has_single_domain_separator
|
||||
|
|
@ -1085,6 +1144,53 @@ mod tests {
|
|||
assert_eq!(decoded_signatures, signatures);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_records_static_publication_hashes() {
|
||||
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".to_owned()),
|
||||
valid_after_ms: None,
|
||||
valid_before_ms: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
let signatures = Vec::new();
|
||||
let entries = sigchain_entries(&ops, &signatures);
|
||||
let jsonl = encode_sigchain_jsonl(&entries).expect("jsonl");
|
||||
let allowed = render_allowed_signers(&allowed_signers(&ops, &signatures));
|
||||
let checkpoint = keychain_checkpoint(
|
||||
&ops,
|
||||
&signatures,
|
||||
&jsonl,
|
||||
&allowed,
|
||||
"https://example.com/.well-known/sshsigchain",
|
||||
UnixMillis(10),
|
||||
)
|
||||
.expect("checkpoint");
|
||||
|
||||
assert_eq!(checkpoint.version, KEYCHAIN_CHECKPOINT_VERSION);
|
||||
assert_eq!(
|
||||
checkpoint.base_url,
|
||||
"https://example.com/.well-known/sshsigchain/"
|
||||
);
|
||||
assert_eq!(checkpoint.ops, 2);
|
||||
assert_eq!(checkpoint.sigchain_bytes, jsonl.len() as u64);
|
||||
assert_eq!(
|
||||
checkpoint.sigchain_hash,
|
||||
blake3_tagged_hash(jsonl.as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint.allowed_signers_hash,
|
||||
blake3_tagged_hash(allowed.as_bytes())
|
||||
);
|
||||
assert!(!checkpoint.reduced_view_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigchain_verification_replays_against_prior_admin_view() {
|
||||
let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into();
|
||||
|
|
|
|||
Loading…
Reference in a new issue