make sshsigchain the only portable sigchain format
This commit is contained in:
parent
9e5871d02a
commit
73500e1944
15 changed files with 812 additions and 1603 deletions
|
|
@ -5,25 +5,23 @@
|
|||
//! to update them. It intentionally has no dependency on the daemon, SQLite,
|
||||
//! Iroh, local sockets, or any particular publication mechanism.
|
||||
//!
|
||||
//! Applications can publish `KeychainSigchainEntry` values in an append-only
|
||||
//! JSONL file, object store, database row stream, document CRDT, or another
|
||||
//! transport. Consumers decode entries, flatten them into operations and
|
||||
//! signatures, then call `verify_sigchain_with_profile` with an application
|
||||
//! profile and a `KeychainSignatureVerifier` implementation.
|
||||
//!
|
||||
//! The default `KeychainProfile` is geth-specific. Other applications should
|
||||
//! create their own profile with `KeychainProfile::for_application` or
|
||||
//! `KeychainProfile::new` so signed payload namespaces do not overlap.
|
||||
//! The current local operation-log verifier remains local to geth's daemon
|
||||
//! state. Portable SSHSIGCHAIN records are implemented separately in
|
||||
//! [`sshsigchain`]: they use an explicit trust tuple, fixed signing bytes, and
|
||||
//! a causal hash chain rather than a timestamp-sorted JSONL bundle.
|
||||
|
||||
mod sshsigchain;
|
||||
|
||||
pub use sshsigchain::{
|
||||
ChainId as SshSigchainChainId, Digest as SshSigchainDigest, KEYCHAIN_V2_PAYLOAD_VERSION,
|
||||
KEYCHAIN_V2_PROFILE, KeychainV2Policy, KeychainV2State, KeychainV2Verification,
|
||||
SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
|
||||
ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION, KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
KeychainSshSigchainPolicy, KeychainSshSigchainState, KeychainSshSigchainVerification,
|
||||
MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, SSH_SIGCHAIN_NAMESPACE,
|
||||
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainPolicy,
|
||||
SshSigchainRecord, SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier,
|
||||
decode_keychain_v2_payload, keychain_v2_payload, keychain_v2_unsigned_record,
|
||||
verify_keychain_v2_sigchain, verify_sshsigchain,
|
||||
decode_keychain_sshsigchain_payload, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl,
|
||||
keychain_sshsigchain_payload, keychain_sshsigchain_unsigned_record,
|
||||
verify_keychain_sshsigchain, verify_sshsigchain,
|
||||
};
|
||||
|
||||
use geth_types::{
|
||||
|
|
@ -35,10 +33,7 @@ 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>;
|
||||
|
||||
|
|
@ -203,27 +198,6 @@ pub struct KeychainSigchainReport {
|
|||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeychainSigchainEntry {
|
||||
pub op: KeychainOp,
|
||||
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;
|
||||
}
|
||||
|
|
@ -331,11 +305,6 @@ pub enum KeychainError {
|
|||
InvalidPrincipal(String),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] geth_codec::CodecError),
|
||||
#[error("sigchain JSONL line {line}: {source}")]
|
||||
SigchainJsonl {
|
||||
line: usize,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -739,100 +708,6 @@ 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)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -1118,90 +993,6 @@ 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 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();
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
//! Reference implementation of the small, generic SSHSIGCHAIN v2 core.
|
||||
//! Reference implementation of the small, generic SSHSIGCHAIN v1 core.
|
||||
//!
|
||||
//! The core deliberately knows nothing about geth's resource or identity
|
||||
//! model. It verifies one linear, linked sequence against an explicitly
|
||||
//! configured root key, then delegates authorization and state transitions to
|
||||
//! an application profile. `KeychainV2Policy` below is geth's first profile.
|
||||
//! See `docs/sshsigchain-v2.md` for the interoperable format.
|
||||
//! an application profile. `KeychainSshSigchainPolicy` below is geth's first profile.
|
||||
//! See `docs/sshsigchain.md` for the interoperable format.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{KeychainOp, KeychainOpKind, KeychainView, admin_key_fingerprint, reduce_keychain_ops};
|
||||
use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
|
||||
|
||||
pub const SSH_SIGCHAIN_VERSION: u8 = 2;
|
||||
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v2";
|
||||
pub const KEYCHAIN_V2_PROFILE: &str = "geth.keychain.v2";
|
||||
pub const KEYCHAIN_V2_PAYLOAD_VERSION: u16 = 1;
|
||||
pub const SSH_SIGCHAIN_VERSION: u8 = 1;
|
||||
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
|
||||
pub const SSH_SIGCHAIN_VERIFIER_PRINCIPAL: &str = "sshsigchain";
|
||||
pub const KEYCHAIN_SSH_SIGCHAIN_PROFILE: &str = "geth.keychain.sshsigchain.v1";
|
||||
pub const KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION: u16 = 1;
|
||||
pub const MAX_PROFILE_BYTES: usize = 128;
|
||||
pub const MAX_NAMESPACE_BYTES: usize = 128;
|
||||
pub const MAX_PUBLIC_KEY_BYTES: usize = 16 * 1024;
|
||||
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_RECORDS: usize = 100_000;
|
||||
pub const MAX_JSONL_LINE_BYTES: usize = 5 * 1024 * 1024;
|
||||
pub const MAX_JSONL_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
const SIGNING_MAGIC: &[u8] = b"SSCS";
|
||||
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v2\0";
|
||||
const RECORD_HASH_DOMAIN: &[u8] = b"sshsigchain.record-hash.v1\0";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct ChainId(pub [u8; 32]);
|
||||
|
|
@ -84,6 +88,7 @@ impl SshSigchainTrust {
|
|||
/// A JSON-serializable transport envelope. Its JSON representation is not
|
||||
/// signed; the exact bytes from [`SshSigchainRecord::signing_bytes`] are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SshSigchainRecord {
|
||||
pub chain_id: ChainId,
|
||||
pub profile: String,
|
||||
|
|
@ -94,6 +99,46 @@ pub struct SshSigchainRecord {
|
|||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn encode_sshsigchain_jsonl(records: &[SshSigchainRecord]) -> Result<String, SshSigchainError> {
|
||||
let mut output = String::new();
|
||||
for record in records {
|
||||
record.validate(true)?;
|
||||
output.push_str(
|
||||
&serde_json::to_string(record)
|
||||
.map_err(|error| SshSigchainError::JsonEncoding(error.to_string()))?,
|
||||
);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn decode_sshsigchain_jsonl(input: &str) -> Result<Vec<SshSigchainRecord>, SshSigchainError> {
|
||||
if input.len() > MAX_JSONL_BYTES {
|
||||
return Err(SshSigchainError::JsonlTooLarge(input.len()));
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_JSONL_LINE_BYTES {
|
||||
return Err(SshSigchainError::JsonLineTooLarge {
|
||||
line: index + 1,
|
||||
bytes: line.len(),
|
||||
});
|
||||
}
|
||||
let record = serde_json::from_str(line).map_err(|error| SshSigchainError::JsonLine {
|
||||
line: index + 1,
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
records.push(record);
|
||||
if records.len() > MAX_RECORDS {
|
||||
return Err(SshSigchainError::TooManyRecords(records.len()));
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
impl SshSigchainRecord {
|
||||
pub fn unsigned(
|
||||
chain_id: ChainId,
|
||||
|
|
@ -276,96 +321,98 @@ where
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct KeychainV2Policy;
|
||||
pub struct KeychainSshSigchainPolicy;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeychainV2State {
|
||||
pub struct KeychainSshSigchainState {
|
||||
initialized: bool,
|
||||
admin_public_keys: BTreeMap<KeyId, String>,
|
||||
seen_op_ids: BTreeSet<AuthOpId>,
|
||||
ops: Vec<KeychainOp>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeychainV2Verification {
|
||||
pub struct KeychainSshSigchainVerification {
|
||||
pub view: KeychainView,
|
||||
pub records: usize,
|
||||
pub head: Digest,
|
||||
}
|
||||
|
||||
pub fn keychain_v2_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
|
||||
geth_codec::encode_canonical(&KeychainV2Payload {
|
||||
version: KEYCHAIN_V2_PAYLOAD_VERSION,
|
||||
pub fn keychain_sshsigchain_payload(op: &KeychainOp) -> Result<Vec<u8>, SshSigchainError> {
|
||||
geth_codec::encode_canonical(&KeychainSshSigchainPayload {
|
||||
version: KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION,
|
||||
op: CanonicalKeychainOp::from(op),
|
||||
})
|
||||
.map_err(|error| SshSigchainError::PayloadEncoding(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn decode_keychain_v2_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
|
||||
let decoded = geth_codec::decode_canonical::<KeychainV2Payload>(bytes)
|
||||
pub fn decode_keychain_sshsigchain_payload(bytes: &[u8]) -> Result<KeychainOp, SshSigchainError> {
|
||||
let decoded = geth_codec::decode_canonical::<KeychainSshSigchainPayload>(bytes)
|
||||
.map_err(|error| SshSigchainError::PayloadDecoding(error.to_string()))?;
|
||||
if decoded.version != KEYCHAIN_V2_PAYLOAD_VERSION {
|
||||
if decoded.version != KEYCHAIN_SSH_SIGCHAIN_PAYLOAD_VERSION {
|
||||
return Err(SshSigchainError::UnsupportedPayloadVersion(decoded.version));
|
||||
}
|
||||
let op = KeychainOp::from(decoded.op);
|
||||
let canonical = keychain_v2_payload(&op)?;
|
||||
let canonical = keychain_sshsigchain_payload(&op)?;
|
||||
if canonical != bytes {
|
||||
return Err(SshSigchainError::NonCanonicalPayload);
|
||||
}
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
pub fn keychain_v2_unsigned_record(
|
||||
pub fn keychain_sshsigchain_unsigned_record(
|
||||
trust: &SshSigchainTrust,
|
||||
sequence: u64,
|
||||
previous: Option<Digest>,
|
||||
op: &KeychainOp,
|
||||
signer_public_key: impl AsRef<str>,
|
||||
) -> Result<SshSigchainRecord, SshSigchainError> {
|
||||
if trust.profile != KEYCHAIN_V2_PROFILE {
|
||||
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
|
||||
return Err(SshSigchainError::WrongKeychainProfile(
|
||||
trust.profile.clone(),
|
||||
));
|
||||
}
|
||||
SshSigchainRecord::unsigned(
|
||||
trust.chain_id,
|
||||
KEYCHAIN_V2_PROFILE,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
sequence,
|
||||
previous,
|
||||
keychain_v2_payload(op)?,
|
||||
keychain_sshsigchain_payload(op)?,
|
||||
signer_public_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_keychain_v2_sigchain<V>(
|
||||
pub fn verify_keychain_sshsigchain<V>(
|
||||
records: &[SshSigchainRecord],
|
||||
trust: &SshSigchainTrust,
|
||||
verifier: &V,
|
||||
) -> Result<KeychainV2Verification, SshSigchainError>
|
||||
) -> Result<KeychainSshSigchainVerification, SshSigchainError>
|
||||
where
|
||||
V: SshSigchainVerifier + ?Sized,
|
||||
{
|
||||
if trust.profile != KEYCHAIN_V2_PROFILE {
|
||||
if trust.profile != KEYCHAIN_SSH_SIGCHAIN_PROFILE {
|
||||
return Err(SshSigchainError::WrongKeychainProfile(
|
||||
trust.profile.clone(),
|
||||
));
|
||||
}
|
||||
let verified = verify_sshsigchain(records, trust, verifier, &KeychainV2Policy)?;
|
||||
Ok(KeychainV2Verification {
|
||||
let verified = verify_sshsigchain(records, trust, verifier, &KeychainSshSigchainPolicy)?;
|
||||
Ok(KeychainSshSigchainVerification {
|
||||
view: reduce_keychain_ops(&verified.state.ops),
|
||||
records: verified.records,
|
||||
head: verified.head,
|
||||
})
|
||||
}
|
||||
|
||||
impl SshSigchainPolicy for KeychainV2Policy {
|
||||
type State = KeychainV2State;
|
||||
impl SshSigchainPolicy for KeychainSshSigchainPolicy {
|
||||
type State = KeychainSshSigchainState;
|
||||
|
||||
fn initial_state(&self, trust: &SshSigchainTrust) -> Result<Self::State, String> {
|
||||
let root =
|
||||
canonical_ssh_public_key(&trust.root_public_key).map_err(|error| error.to_string())?;
|
||||
Ok(KeychainV2State {
|
||||
Ok(KeychainSshSigchainState {
|
||||
initialized: false,
|
||||
admin_public_keys: BTreeMap::from([(KeyId::new(admin_key_fingerprint(&root)), root)]),
|
||||
seen_op_ids: BTreeSet::new(),
|
||||
ops: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -383,7 +430,11 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
|
||||
fn apply(&self, state: &mut Self::State, record: &SshSigchainRecord) -> Result<(), String> {
|
||||
let op = decode_keychain_v2_payload(&record.payload).map_err(|error| error.to_string())?;
|
||||
let op = decode_keychain_sshsigchain_payload(&record.payload)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if !state.seen_op_ids.insert(op.id.clone()) {
|
||||
return Err(format!("duplicate keychain operation ID: {}", op.id));
|
||||
}
|
||||
match &op.kind {
|
||||
KeychainOpKind::KeychainInit => {
|
||||
if state.initialized || record.sequence != 0 {
|
||||
|
|
@ -403,30 +454,48 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
if valid_after_ms.is_some() || valid_before_ms.is_some() {
|
||||
return Err(
|
||||
"v2 does not accept key validity windows as security policy; use a causally ordered revocation record"
|
||||
"SSHSIGCHAIN does not accept key validity windows as security policy; use a causally ordered revocation record"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
let public_key = public_key.as_deref().ok_or_else(|| {
|
||||
"AdminKeyAdd requires its canonical public key in v2".to_owned()
|
||||
"AdminKeyAdd requires its canonical public key in SSHSIGCHAIN".to_owned()
|
||||
})?;
|
||||
let public_key =
|
||||
canonical_ssh_public_key(public_key).map_err(|error| error.to_string())?;
|
||||
if KeyId::new(admin_key_fingerprint(&public_key)) != *key {
|
||||
return Err("AdminKeyAdd key ID does not match its public key".to_owned());
|
||||
}
|
||||
if record.sequence == 1 && public_key != record.signer_public_key {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
state.admin_public_keys.insert(key.clone(), public_key);
|
||||
}
|
||||
KeychainOpKind::AdminKeyRevoke { key } => {
|
||||
if !state.initialized {
|
||||
return Err("keychain must start with KeychainInit".to_owned());
|
||||
}
|
||||
if record.sequence == 1 {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
state.admin_public_keys.remove(key);
|
||||
}
|
||||
_ => {
|
||||
if !state.initialized {
|
||||
return Err("keychain must start with KeychainInit".to_owned());
|
||||
}
|
||||
if record.sequence == 1 {
|
||||
return Err(
|
||||
"sequence 1 must record the configured root signer as an admin key"
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.ops.push(op);
|
||||
|
|
@ -435,13 +504,13 @@ impl SshSigchainPolicy for KeychainV2Policy {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct KeychainV2Payload {
|
||||
struct KeychainSshSigchainPayload {
|
||||
version: u16,
|
||||
op: CanonicalKeychainOp,
|
||||
}
|
||||
|
||||
/// `KeychainOpKind` uses a human-facing internally tagged JSON enum. Postcard
|
||||
/// deliberately cannot deserialize that representation, so v2 uses this
|
||||
/// deliberately cannot deserialize that representation, so SSHSIGCHAIN uses this
|
||||
/// profile-local externally tagged mirror for its signed binary payload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct CanonicalKeychainOp {
|
||||
|
|
@ -654,7 +723,9 @@ pub enum SshSigchainError {
|
|||
InvalidChainId,
|
||||
#[error("SSH sigchain profile must be non-empty ASCII and at most {MAX_PROFILE_BYTES} bytes")]
|
||||
InvalidProfile,
|
||||
#[error("SSH sigchain namespace must be non-empty printable ASCII without whitespace")]
|
||||
#[error(
|
||||
"SSH sigchain namespace must be non-empty printable ASCII without whitespace and at most {MAX_NAMESPACE_BYTES} bytes"
|
||||
)]
|
||||
InvalidNamespace,
|
||||
#[error("SSH public key must be a canonical two-field OpenSSH public key")]
|
||||
InvalidPublicKey,
|
||||
|
|
@ -668,6 +739,16 @@ pub enum SshSigchainError {
|
|||
MissingSignature,
|
||||
#[error("SSH sigchain cannot be empty")]
|
||||
EmptyChain,
|
||||
#[error("SSH sigchain JSONL line {line}: {detail}")]
|
||||
JsonLine { line: usize, detail: String },
|
||||
#[error("failed to encode SSH sigchain JSONL: {0}")]
|
||||
JsonEncoding(String),
|
||||
#[error("SSH sigchain JSONL input is {0} bytes, over the {MAX_JSONL_BYTES}-byte limit")]
|
||||
JsonlTooLarge(usize),
|
||||
#[error(
|
||||
"SSH sigchain JSONL line {line} is {bytes} bytes, over the {MAX_JSONL_LINE_BYTES}-byte limit"
|
||||
)]
|
||||
JsonLineTooLarge { line: usize, bytes: usize },
|
||||
#[error("SSH sigchain has {0} records, over the {MAX_RECORDS}-record limit")]
|
||||
TooManyRecords(usize),
|
||||
#[error("SSH sigchain sequence counter overflow")]
|
||||
|
|
@ -690,15 +771,15 @@ pub enum SshSigchainError {
|
|||
InvalidSignature { index: usize },
|
||||
#[error("SSH sigchain policy rejected record: {0}")]
|
||||
Policy(String),
|
||||
#[error("failed to encode keychain v2 payload: {0}")]
|
||||
#[error("failed to encode keychain SSHSIGCHAIN payload: {0}")]
|
||||
PayloadEncoding(String),
|
||||
#[error("failed to decode keychain v2 payload: {0}")]
|
||||
#[error("failed to decode keychain SSHSIGCHAIN payload: {0}")]
|
||||
PayloadDecoding(String),
|
||||
#[error("unsupported keychain v2 payload version {0}")]
|
||||
#[error("unsupported keychain SSHSIGCHAIN payload version {0}")]
|
||||
UnsupportedPayloadVersion(u16),
|
||||
#[error("keychain v2 payload is not its unique canonical encoding")]
|
||||
#[error("keychain SSHSIGCHAIN payload is not its unique canonical encoding")]
|
||||
NonCanonicalPayload,
|
||||
#[error("expected geth keychain v2 profile, got {0}")]
|
||||
#[error("expected geth keychain SSHSIGCHAIN profile, got {0}")]
|
||||
WrongKeychainProfile(String),
|
||||
#[error("length does not fit the SSH sigchain wire format")]
|
||||
LengthOverflow,
|
||||
|
|
@ -732,7 +813,7 @@ fn validate_profile(value: String) -> Result<String, SshSigchainError> {
|
|||
|
||||
fn validate_namespace(value: String) -> Result<String, SshSigchainError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_PROFILE_BYTES
|
||||
|| value.len() > MAX_NAMESPACE_BYTES
|
||||
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
{
|
||||
return Err(SshSigchainError::InvalidNamespace);
|
||||
|
|
@ -804,7 +885,7 @@ mod tests {
|
|||
fn trust() -> SshSigchainTrust {
|
||||
SshSigchainTrust::new(
|
||||
ChainId([7; 32]),
|
||||
KEYCHAIN_V2_PROFILE,
|
||||
KEYCHAIN_SSH_SIGCHAIN_PROFILE,
|
||||
SSH_SIGCHAIN_NAMESPACE,
|
||||
ROOT_KEY,
|
||||
)
|
||||
|
|
@ -826,7 +907,7 @@ mod tests {
|
|||
op: &KeychainOp,
|
||||
signer: &str,
|
||||
) -> SshSigchainRecord {
|
||||
let record = keychain_v2_unsigned_record(trust, sequence, previous, op, signer)
|
||||
let record = keychain_sshsigchain_unsigned_record(trust, sequence, previous, op, signer)
|
||||
.expect("unsigned record");
|
||||
let signature = test_signature(
|
||||
&trust.namespace,
|
||||
|
|
@ -851,7 +932,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn v2_accepts_a_linked_rooted_keychain() {
|
||||
fn accepts_a_linked_rooted_keychain() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -882,7 +963,7 @@ mod tests {
|
|||
ROOT_KEY,
|
||||
);
|
||||
|
||||
let verified = verify_keychain_v2_sigchain(&[init, add, user], &trust, &TestVerifier)
|
||||
let verified = verify_keychain_sshsigchain(&[init, add, user], &trust, &TestVerifier)
|
||||
.expect("valid chain");
|
||||
assert_eq!(verified.records, 3);
|
||||
assert_eq!(
|
||||
|
|
@ -893,7 +974,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn v2_requires_an_explicit_root_instead_of_self_bootstrap() {
|
||||
fn requires_an_explicit_root_instead_of_self_bootstrap() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -903,13 +984,13 @@ mod tests {
|
|||
SECOND_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::RootSignerMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_rejects_a_non_linked_fork() {
|
||||
fn rejects_a_non_linked_fork() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -920,13 +1001,94 @@ mod tests {
|
|||
);
|
||||
let add = signed_record(&trust, 1, None, &root_add_op(), ROOT_KEY);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init, add], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init, add], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::UnexpectedPrevious { index: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_revocation_is_causal_not_timestamp_ordered() {
|
||||
fn requires_the_root_to_be_recorded_before_other_identity_changes() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let user = signed_record(
|
||||
&trust,
|
||||
1,
|
||||
Some(init.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
2,
|
||||
KeychainOpKind::UserAdd {
|
||||
user: "user:alice".into(),
|
||||
name: "Alice".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_sshsigchain(&[init, user], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_keychain_operation_ids() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let add = signed_record(
|
||||
&trust,
|
||||
1,
|
||||
Some(init.record_hash().expect("hash")),
|
||||
&root_add_op(),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let user = signed_record(
|
||||
&trust,
|
||||
2,
|
||||
Some(add.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
3,
|
||||
KeychainOpKind::UserAdd {
|
||||
user: "user:alice".into(),
|
||||
name: "Alice".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let duplicate = signed_record(
|
||||
&trust,
|
||||
3,
|
||||
Some(user.record_hash().expect("hash")),
|
||||
&op(
|
||||
"op:user",
|
||||
4,
|
||||
KeychainOpKind::UserRename {
|
||||
user: "user:alice".into(),
|
||||
name: "Mallory".to_owned(),
|
||||
},
|
||||
),
|
||||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_sshsigchain(&[init, add, user, duplicate], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_is_causal_not_timestamp_ordered() {
|
||||
let trust = trust();
|
||||
let init = signed_record(
|
||||
&trust,
|
||||
|
|
@ -972,20 +1134,80 @@ mod tests {
|
|||
ROOT_KEY,
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_keychain_v2_sigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
|
||||
verify_keychain_sshsigchain(&[init, add, revoke, forged], &trust, &TestVerifier),
|
||||
Err(SshSigchainError::Policy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_payload_rejects_trailing_or_noncanonical_bytes() {
|
||||
let payload =
|
||||
keychain_v2_payload(&op("op:init", 1, KeychainOpKind::KeychainInit)).expect("payload");
|
||||
fn payload_rejects_trailing_or_noncanonical_bytes() {
|
||||
let payload = keychain_sshsigchain_payload(&op("op:init", 1, KeychainOpKind::KeychainInit))
|
||||
.expect("payload");
|
||||
let mut noncanonical = payload;
|
||||
noncanonical.push(0);
|
||||
assert!(matches!(
|
||||
decode_keychain_v2_payload(&noncanonical),
|
||||
decode_keychain_sshsigchain_payload(&noncanonical),
|
||||
Err(SshSigchainError::NonCanonicalPayload)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_transport_roundtrips_without_becoming_signed_data() {
|
||||
let trust = trust();
|
||||
let record = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let jsonl = encode_sshsigchain_jsonl(std::slice::from_ref(&record)).expect("encode JSONL");
|
||||
assert_eq!(
|
||||
decode_sshsigchain_jsonl(&jsonl).expect("decode JSONL"),
|
||||
vec![record]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_transport_rejects_unknown_record_fields() {
|
||||
let trust = trust();
|
||||
let record = signed_record(
|
||||
&trust,
|
||||
0,
|
||||
None,
|
||||
&op("op:init", 1, KeychainOpKind::KeychainInit),
|
||||
ROOT_KEY,
|
||||
);
|
||||
let mut value = serde_json::to_value(record).expect("record JSON");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("record object")
|
||||
.insert("unrecognized".to_owned(), serde_json::Value::Bool(true));
|
||||
assert!(matches!(
|
||||
decode_sshsigchain_jsonl(&format!("{value}\n")),
|
||||
Err(SshSigchainError::JsonLine { line: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_bytes_match_the_published_base_vector() {
|
||||
let record = SshSigchainRecord::unsigned(
|
||||
ChainId([0; 32]),
|
||||
"example.test",
|
||||
0,
|
||||
None,
|
||||
vec![1, 2],
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJn8/JItLIoZOxodjYHXdd3Tv6SHzPOEUM+1BWPvCQc2",
|
||||
)
|
||||
.expect("unsigned record");
|
||||
assert_eq!(
|
||||
hex::encode(record.signing_bytes().expect("signing bytes")),
|
||||
concat!(
|
||||
"5353435301",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"0000000000000000",
|
||||
"00000c6578616d706c652e7465737400000002010200507373682d65643235353139204141414143334e7a6143316c5a4449314e54453541414141494a6e382f4a49744c496f5a4f786f646a59485864643354763653487a504f45554d2b314257507643516332"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue