add canonical sigchain bundle format

This commit is contained in:
Eric Wendland 2026-07-19 03:16:14 +02:00
commit decff4b995
14 changed files with 1515 additions and 42 deletions

View file

@ -650,23 +650,32 @@ operator-pinned chain ID, OpenSSH root public key, and namespace. Parent hashes
define order without a redundant sequence counter. Every record carries a define order without a redundant sequence counter. Every record carries a
public authority transition for devices, keys, causal revocation, scoped public authority transition for devices, keys, causal revocation, scoped
permissions, and anchor policy, plus optional profile commitments whose payloads permissions, and anchor policy, plus optional profile commitments whose payloads
can be selectively disclosed. Geth already provides a verifier for independently can be selectively disclosed. Geth already provides a verifier for JSONL
produced JSONL transport files: interchange and canonical bundles:
```sh ```sh
geth keychain verify-sigchain \ geth keychain verify-sigchain \
--in ./geth.sshsigchain.v1.jsonl \ --in ./chain.sscb \
--chain-id <64-hex-character-chain-id> \ --chain-id <64-hex-character-chain-id> \
--root-key ~/.ssh/geth-root.pub --root-key ~/.ssh/geth-root.pub
# Convert the human-friendly record interchange to the canonical snapshot.
geth keychain bundle-create --in ./chain.jsonl --out ./chain.sscb
# Extract records and available disclosures for inspection.
geth keychain bundle-extract --in ./chain.sscb --out ./chain.jsonl
``` ```
The verifier reports active authority devices/keys, disclosed and incomplete The verifier reports active authority devices/keys, disclosed and incomplete
profiles, the head digest, and current attester/backend anchor thresholds. profiles, the head digest, and current attester/backend anchor thresholds. It
SSHSIGCHAIN local auto-detects JSONL interchange or the canonical `.sscb` bundle. The bundle is
record storage, signing, publication, import, accepted-head persistence, and the deterministic on-disk/full-snapshot distribution format and can be hosted
unchanged at `/.well-known/sshsigchain/v1/<chain-id>/chain.sscb` by a static
HTTP server. HTTP and every other distribution backend remain untrusted inputs.
Local append workflows, accepted-head persistence, concrete fetchers, and
concrete anchor adapters remain follow-up work. Until they exist, do not concrete anchor adapters remain follow-up work. Until they exist, do not
substitute an unpinned checkpoint or a local operation-log view for the substitute an unpinned checkpoint, HTTP response, or local operation-log view
SSHSIGCHAIN trust tuple. for the SSHSIGCHAIN trust tuple.
Signing is mediated by OpenSSH. `--signing-key` may point at a private key file, Signing is mediated by OpenSSH. `--signing-key` may point at a private key file,
a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is a FIDO/YubiKey OpenSSH security-key stub, or a public key whose private half is

View file

@ -1048,7 +1048,7 @@ pub enum KeychainCommand {
#[arg(long)] #[arg(long)]
principal: Option<String>, principal: Option<String>,
}, },
/// Verify a linked SSHSIGCHAIN JSONL file against an explicit root key /// Verify SSHSIGCHAIN JSONL or a canonical bundle against an explicit root key
VerifySigchain { VerifySigchain {
#[arg(long = "in")] #[arg(long = "in")]
input: PathBuf, input: PathBuf,
@ -1059,6 +1059,22 @@ pub enum KeychainCommand {
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)] #[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
namespace: String, namespace: String,
}, },
/// Convert an SSHSIGCHAIN JSONL interchange file to the canonical binary bundle
BundleCreate {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long, default_value = geth_keychain::SSH_SIGCHAIN_NAMESPACE)]
namespace: String,
},
/// Extract records from a canonical SSHSIGCHAIN bundle as JSONL
BundleExtract {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: PathBuf,
},
/// Explain why one keychain operation was accepted or rejected /// Explain why one keychain operation was accepted or rejected
Explain { op_id: String }, Explain { op_id: String },
/// Explain the current trust state of one signer /// Explain the current trust state of one signer
@ -2646,6 +2662,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
root_key_path: root_key, root_key_path: root_key,
namespace: Some(namespace), namespace: Some(namespace),
}, },
Command::Keychain {
command:
KeychainCommand::BundleCreate {
input,
out,
namespace,
},
} => ControlRequest::KeychainSigchainBundleCreate {
input,
out,
namespace,
},
Command::Keychain {
command: KeychainCommand::BundleExtract { input, out },
} => ControlRequest::KeychainSigchainBundleExtract { input, out },
Command::Keychain { Command::Keychain {
command: KeychainCommand::Explain { op_id }, command: KeychainCommand::Explain { op_id },
} => ControlRequest::KeychainExplain { op_id }, } => ControlRequest::KeychainExplain { op_id },
@ -4117,6 +4148,11 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
anchor_backend_threshold, anchor_backend_threshold,
required_anchor_backends, required_anchor_backends,
required_anchor_classes, required_anchor_classes,
format,
disclosures,
claims,
receipts,
bundle_hash,
note, note,
} => { } => {
println!("sigchain: {}", input.display()); println!("sigchain: {}", input.display());
@ -4131,6 +4167,34 @@ fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> {
println!("anchor_backend_threshold: {anchor_backend_threshold}"); println!("anchor_backend_threshold: {anchor_backend_threshold}");
println!("required_anchor_backends: {required_anchor_backends}"); println!("required_anchor_backends: {required_anchor_backends}");
println!("required_anchor_classes: {required_anchor_classes}"); println!("required_anchor_classes: {required_anchor_classes}");
println!("format: {format}");
println!("disclosures: {disclosures}");
println!("claims: {claims}");
println!("receipts: {receipts}");
println!("bundle_hash: {}", bundle_hash.as_deref().unwrap_or("none"));
eprintln!("note: {note}");
}
ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format,
records,
disclosures,
claims,
receipts,
bundle_hash,
static_http_path,
note,
} => {
println!("input: {}", input.display());
println!("out: {}", out.display());
println!("format: {format}");
println!("records: {records}");
println!("disclosures: {disclosures}");
println!("claims: {claims}");
println!("receipts: {receipts}");
println!("bundle_hash: {bundle_hash}");
println!("static_http_path: {static_http_path}");
eprintln!("note: {note}"); eprintln!("note: {note}");
} }
ControlResponse::KeychainExplained { subject, lines } => { ControlResponse::KeychainExplained { subject, lines } => {
@ -5346,6 +5410,38 @@ mod tests {
command: KeychainCommand::VerifySigchain { .. } command: KeychainCommand::VerifySigchain { .. }
} }
)); ));
assert!(matches!(
Cli::try_parse_from([
"geth",
"keychain",
"bundle-create",
"--in",
"chain.jsonl",
"--out",
"chain.sscb",
])
.expect("parse canonical bundle creation")
.command,
Command::Keychain {
command: KeychainCommand::BundleCreate { .. }
}
));
assert!(matches!(
Cli::try_parse_from([
"geth",
"keychain",
"bundle-extract",
"--in",
"chain.sscb",
"--out",
"chain.jsonl",
])
.expect("parse canonical bundle extraction")
.command,
Command::Keychain {
command: KeychainCommand::BundleExtract { .. }
}
));
for removed in [ for removed in [
"sigchain", "sigchain",
"publish-bundle", "publish-bundle",

View file

@ -272,6 +272,15 @@ pub enum ControlRequest {
root_key_path: PathBuf, root_key_path: PathBuf,
namespace: Option<String>, namespace: Option<String>,
}, },
KeychainSigchainBundleCreate {
input: PathBuf,
out: PathBuf,
namespace: String,
},
KeychainSigchainBundleExtract {
input: PathBuf,
out: PathBuf,
},
KeychainExplain { KeychainExplain {
op_id: String, op_id: String,
}, },
@ -754,6 +763,23 @@ pub enum ControlResponse {
anchor_backend_threshold: u16, anchor_backend_threshold: u16,
required_anchor_backends: usize, required_anchor_backends: usize,
required_anchor_classes: usize, required_anchor_classes: usize,
format: String,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: Option<String>,
note: String,
},
KeychainSigchainBundleWritten {
input: PathBuf,
out: PathBuf,
format: String,
records: usize,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: String,
static_http_path: String,
note: String, note: String,
}, },
KeychainExplained { KeychainExplained {
@ -1768,8 +1794,8 @@ mod tests {
use super::*; use super::*;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 118; const CONTROL_REQUEST_VARIANTS: usize = 120;
const CONTROL_RESPONSE_VARIANTS: usize = 110; const CONTROL_RESPONSE_VARIANTS: usize = 111;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19; const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20; const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3; const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;

View file

@ -15,15 +15,19 @@ mod sshsigchain;
pub use sshsigchain::{ pub use sshsigchain::{
AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier, AnchorAttesterPolicy, AnchorBackendPolicy, AnchorPolicy, AnchorReceipt, AnchorReceiptVerifier,
AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState, AnchoredHistory, AuthorityDeviceState, AuthorityKey, AuthorityKeyId, AuthorityKeyState,
AuthorityState, AuthorityTransition, ChainId as SshSigchainChainId, AuthorityState, AuthorityTransition, CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE,
Digest as SshSigchainDigest, HeadClaim, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, CanonicalSshSigchainBundle, ChainId as SshSigchainChainId, Digest as SshSigchainDigest,
MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure, ProfileExtension, DistributionEndpoint, DistributionError, HeadClaim, MAX_CANONICAL_BUNDLE_BYTES,
SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE, SSH_SIGCHAIN_NAMESPACE, MAX_JSONL_BYTES, MAX_JSONL_LINE_BYTES, MAX_NAMESPACE_BYTES, Permission, ProfileDisclosure,
SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION, SshSigchainError, SshSigchainRecord, ProfileExtension, SSH_SIGCHAIN_ANCHOR_NAMESPACE, SSH_SIGCHAIN_KEY_PROOF_NAMESPACE,
SshSigchainTrust, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory, SSH_SIGCHAIN_NAMESPACE, SSH_SIGCHAIN_VERIFIER_PRINCIPAL, SSH_SIGCHAIN_VERSION,
authority_key_id, decode_sshsigchain_jsonl, encode_sshsigchain_jsonl, key_proof_signing_bytes, SshSigchainBundleSource, SshSigchainError, SshSigchainRecord, SshSigchainTrust,
profile_payload_commitment, select_anchored_head, verify_anchor_receipts, SshSigchainVerification, SshSigchainVerifier, VerifiedAnchoredHistory, authority_key_id,
verify_anchored_history, verify_head_claim, verify_sshsigchain, canonical_bundle_hash, decode_canonical_bundle, decode_sshsigchain_jsonl,
encode_canonical_bundle, encode_sshsigchain_jsonl, key_proof_signing_bytes,
profile_payload_commitment, select_anchored_head, static_http_bundle_path,
verify_anchor_receipts, verify_anchored_history, verify_canonical_bundle_records,
verify_head_claim, verify_sshsigchain,
}; };
use geth_types::{ use geth_types::{

View file

@ -9,6 +9,15 @@ use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
mod bundle;
pub use bundle::{
CANONICAL_BUNDLE_EXTENSION, CANONICAL_BUNDLE_MEDIA_TYPE, CanonicalSshSigchainBundle,
DistributionEndpoint, DistributionError, MAX_CANONICAL_BUNDLE_BYTES, SshSigchainBundleSource,
canonical_bundle_hash, decode_canonical_bundle, encode_canonical_bundle,
static_http_bundle_path, verify_canonical_bundle_records,
};
pub const SSH_SIGCHAIN_VERSION: u8 = 1; pub const SSH_SIGCHAIN_VERSION: u8 = 1;
pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1"; pub const SSH_SIGCHAIN_NAMESPACE: &str = "sshsigchain.v1";
pub const SSH_SIGCHAIN_KEY_PROOF_NAMESPACE: &str = "sshsigchain.key-proof.v1"; pub const SSH_SIGCHAIN_KEY_PROOF_NAMESPACE: &str = "sshsigchain.key-proof.v1";

View file

@ -0,0 +1,987 @@
//! Canonical SSHSIGCHAIN object and bundle encoding.
use super::*;
pub const CANONICAL_BUNDLE_EXTENSION: &str = "sscb";
pub const CANONICAL_BUNDLE_MEDIA_TYPE: &str = "application/vnd.sshsigchain.bundle.v1";
pub const MAX_CANONICAL_BUNDLE_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_CANONICAL_OBJECT_BYTES: usize = 5 * 1024 * 1024;
pub const MAX_BUNDLE_DISCLOSURES: usize = 1_000_000;
pub const MAX_BUNDLE_CLAIMS: usize = 100_000;
pub const MAX_BUNDLE_RECEIPTS: usize = 100_000;
const BUNDLE_MAGIC: &[u8; 4] = b"SSCB";
const RECORD_MAGIC: &[u8; 4] = b"SSCR";
const DISCLOSURE_MAGIC: &[u8; 4] = b"SSCD";
const CLAIM_MAGIC: &[u8; 4] = b"SSHC";
const RECEIPT_MAGIC: &[u8; 4] = b"SSRC";
const BUNDLE_HASH_DOMAIN: &[u8] = b"sshsigchain.bundle.v1\0";
/// One canonical, self-contained chain snapshot. The root public key remains
/// an out-of-band trust input and is deliberately absent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanonicalSshSigchainBundle {
pub chain_id: ChainId,
pub namespace: String,
pub records: Vec<SshSigchainRecord>,
pub claims: Vec<HeadClaim>,
pub receipts: Vec<AnchorReceipt>,
}
/// Locates an untrusted distribution source. It is routing configuration, not
/// a trust anchor; integrity and authority come from SSHSIGCHAIN verification.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DistributionEndpoint {
pub backend_id: String,
pub class: String,
pub locator: String,
}
impl DistributionEndpoint {
pub fn new(
backend_id: impl Into<String>,
class: impl Into<String>,
locator: impl Into<String>,
) -> Result<Self, DistributionError> {
let backend_id = validate_identifier(backend_id.into())?;
let class = validate_identifier(class.into())?;
let locator = locator.into();
if locator.is_empty() || locator.len() > 2048 {
return Err(DistributionError::InvalidEndpoint);
}
Ok(Self {
backend_id,
class,
locator,
})
}
}
/// A distribution backend returns untrusted canonical bundle bytes. Callers
/// MUST decode and verify them against local trust and accepted-head state.
pub trait SshSigchainBundleSource {
type Error: std::error::Error + Send + Sync + 'static;
fn fetch_bundle(
&self,
endpoint: &DistributionEndpoint,
chain_id: ChainId,
accepted_head: Option<Digest>,
) -> Result<Option<Vec<u8>>, Self::Error>;
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DistributionError {
#[error(
"canonical SSHSIGCHAIN bundle is {0} bytes, over the {MAX_CANONICAL_BUNDLE_BYTES}-byte limit"
)]
BundleTooLarge(usize),
#[error(
"canonical SSHSIGCHAIN object is {0} bytes, over the {MAX_CANONICAL_OBJECT_BYTES}-byte limit"
)]
ObjectTooLarge(usize),
#[error("canonical SSHSIGCHAIN object is malformed or truncated")]
Malformed,
#[error("canonical SSHSIGCHAIN object has unsupported version {0}")]
UnsupportedVersion(u8),
#[error("canonical SSHSIGCHAIN bundle is not in its unique encoding")]
NonCanonical,
#[error("canonical SSHSIGCHAIN bundle contains records from another chain")]
MixedChain,
#[error(
"canonical SSHSIGCHAIN bundle does not match the configured chain ID or SSHSIG namespace"
)]
WrongTrust,
#[error("SSHSIGCHAIN distribution endpoint is invalid")]
InvalidEndpoint,
#[error("canonical SSHSIGCHAIN records are empty or not in causal order")]
InvalidRecordOrder,
#[error("canonical SSHSIGCHAIN disclosure does not name a committed profile in its record")]
UnknownDisclosure,
#[error("canonical SSHSIGCHAIN bundle has duplicate disclosures, claims, or receipts")]
DuplicateObject,
#[error("canonical SSHSIGCHAIN claim does not name a record in the bundled history")]
UnknownClaimHead,
#[error("canonical SSHSIGCHAIN receipt does not name a bundled claim")]
UnknownReceiptClaim,
#[error("canonical SSHSIGCHAIN bundle has too many claims or receipts")]
TooManyObjects,
#[error("canonical SSHSIGCHAIN object violates the protocol: {0}")]
Protocol(#[from] SshSigchainError),
}
#[must_use]
pub fn static_http_bundle_path(chain_id: ChainId) -> String {
format!(
"/.well-known/sshsigchain/v1/{}/chain.{CANONICAL_BUNDLE_EXTENSION}",
chain_id.to_hex()
)
}
#[must_use]
pub fn canonical_bundle_hash(bytes: &[u8]) -> Digest {
let mut input = Vec::with_capacity(BUNDLE_HASH_DOMAIN.len() + bytes.len());
input.extend_from_slice(BUNDLE_HASH_DOMAIN);
input.extend_from_slice(bytes);
Digest(*blake3::hash(&input).as_bytes())
}
pub fn verify_canonical_bundle_records<V: SshSigchainVerifier + ?Sized>(
bundle: &CanonicalSshSigchainBundle,
trust: &SshSigchainTrust,
verifier: &V,
) -> Result<SshSigchainVerification, DistributionError> {
if bundle.chain_id != trust.chain_id || bundle.namespace != trust.namespace {
return Err(DistributionError::WrongTrust);
}
verify_sshsigchain(&bundle.records, trust, verifier).map_err(Into::into)
}
pub fn encode_canonical_bundle(
bundle: &CanonicalSshSigchainBundle,
) -> Result<Vec<u8>, DistributionError> {
validate_bundle_records(bundle)?;
let mut out = Vec::new();
out.extend_from_slice(BUNDLE_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
out.extend_from_slice(&bundle.chain_id.0);
let namespace = validate_namespace(bundle.namespace.clone())?;
push_u16_bytes(&mut out, namespace.as_bytes())?;
push_u32_count(&mut out, bundle.records.len())?;
let mut disclosures = Vec::new();
let mut history = BTreeSet::new();
for record in &bundle.records {
let record_hash = record.record_hash()?;
history.insert(record_hash);
let object = encode_record_object(record)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
for extension in &record.extensions {
if let Some(disclosure) = &extension.disclosure {
disclosures.push((
record_hash,
extension.profile_id.clone(),
disclosure.clone(),
));
}
}
}
disclosures.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
if disclosures.len() > MAX_BUNDLE_DISCLOSURES {
return Err(DistributionError::TooManyObjects);
}
if disclosures
.windows(2)
.any(|pair| (pair[0].0, &pair[0].1) == (pair[1].0, &pair[1].1))
{
return Err(DistributionError::DuplicateObject);
}
push_u32_count(&mut out, disclosures.len())?;
for (record_hash, profile_id, disclosure) in disclosures {
let object = encode_disclosure_object(record_hash, &profile_id, &disclosure)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
if bundle.claims.len() > MAX_BUNDLE_CLAIMS || bundle.receipts.len() > MAX_BUNDLE_RECEIPTS {
return Err(DistributionError::TooManyObjects);
}
let mut claims = Vec::with_capacity(bundle.claims.len());
for claim in &bundle.claims {
if claim.chain_id != bundle.chain_id || !history.contains(&claim.head) {
return Err(DistributionError::UnknownClaimHead);
}
claims.push((claim.claim_hash()?, claim.clone()));
}
claims.sort_by_key(|(claim_hash, _)| *claim_hash);
let mut claim_hashes = BTreeSet::new();
push_u32_count(&mut out, claims.len())?;
for (claim_hash, claim) in &claims {
if !claim_hashes.insert(*claim_hash) {
return Err(DistributionError::DuplicateObject);
}
let object = encode_claim_object(claim)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
let mut receipts = bundle.receipts.clone();
receipts.sort_by(|left, right| {
(&left.claim_hash, &left.backend_id).cmp(&(&right.claim_hash, &right.backend_id))
});
if receipts.windows(2).any(|pair| {
(pair[0].claim_hash, &pair[0].backend_id) == (pair[1].claim_hash, &pair[1].backend_id)
}) {
return Err(DistributionError::DuplicateObject);
}
push_u32_count(&mut out, receipts.len())?;
for receipt in &receipts {
if !claim_hashes.contains(&receipt.claim_hash) {
return Err(DistributionError::UnknownReceiptClaim);
}
let object = encode_receipt_object(receipt)?;
validate_object_size(&object)?;
push_u32_bytes(&mut out, &object)?;
}
if out.len() > MAX_CANONICAL_BUNDLE_BYTES {
return Err(DistributionError::BundleTooLarge(out.len()));
}
Ok(out)
}
pub fn decode_canonical_bundle(
bytes: &[u8],
) -> Result<CanonicalSshSigchainBundle, DistributionError> {
if bytes.len() > MAX_CANONICAL_BUNDLE_BYTES {
return Err(DistributionError::BundleTooLarge(bytes.len()));
}
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(BUNDLE_MAGIC)?;
cursor.expect_version()?;
let chain_id = ChainId(cursor.array()?);
let namespace = validate_namespace(cursor.string16()?)?;
let record_count = cursor.count32(MAX_RECORDS)?;
let mut records = Vec::new();
for _ in 0..record_count {
records.push(decode_record_object(cursor.bytes32()?)?);
}
let disclosure_count = cursor.count32(MAX_BUNDLE_DISCLOSURES)?;
let mut disclosures = Vec::new();
for _ in 0..disclosure_count {
disclosures.push(decode_disclosure_object(cursor.bytes32()?)?);
}
let claim_count = cursor.count32(MAX_BUNDLE_CLAIMS)?;
let mut claims = Vec::new();
for _ in 0..claim_count {
claims.push(decode_claim_object(cursor.bytes32()?)?);
}
let receipt_count = cursor.count32(MAX_BUNDLE_RECEIPTS)?;
let mut receipts = Vec::new();
for _ in 0..receipt_count {
receipts.push(decode_receipt_object(cursor.bytes32()?)?);
}
cursor.finish()?;
let mut record_indexes = BTreeMap::new();
for (index, record) in records.iter().enumerate() {
record_indexes.insert(record.record_hash()?, index);
}
for (record_hash, profile_id, disclosure) in disclosures {
let record = records
.get_mut(
*record_indexes
.get(&record_hash)
.ok_or(DistributionError::UnknownDisclosure)?,
)
.ok_or(DistributionError::UnknownDisclosure)?;
let extension = record
.extensions
.iter_mut()
.find(|extension| extension.profile_id == profile_id)
.ok_or(DistributionError::UnknownDisclosure)?;
if extension.disclosure.is_some() {
return Err(DistributionError::DuplicateObject);
}
extension.disclosure = Some(disclosure);
record.validate(true)?;
}
let bundle = CanonicalSshSigchainBundle {
chain_id,
namespace,
records,
claims,
receipts,
};
let canonical = encode_canonical_bundle(&bundle)?;
if canonical != bytes {
return Err(DistributionError::NonCanonical);
}
Ok(bundle)
}
fn validate_bundle_records(bundle: &CanonicalSshSigchainBundle) -> Result<(), DistributionError> {
if bundle.records.is_empty() || bundle.records.len() > MAX_RECORDS {
return Err(DistributionError::InvalidRecordOrder);
}
let mut previous = None;
for record in &bundle.records {
record.validate(true)?;
if record.chain_id != bundle.chain_id {
return Err(DistributionError::MixedChain);
}
if record.previous != previous {
return Err(DistributionError::InvalidRecordOrder);
}
previous = Some(record.record_hash()?);
}
Ok(())
}
fn encode_record_object(record: &SshSigchainRecord) -> Result<Vec<u8>, DistributionError> {
let mut out = Vec::new();
out.extend_from_slice(RECORD_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u32_bytes(&mut out, &record.signing_bytes()?)?;
push_u32_bytes(&mut out, &record.signature)?;
Ok(out)
}
fn decode_record_object(bytes: &[u8]) -> Result<SshSigchainRecord, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(RECORD_MAGIC)?;
cursor.expect_version()?;
let signing_bytes = cursor.bytes32()?;
let signature = cursor.bytes32()?.to_vec();
cursor.finish()?;
decode_record_signing_bytes(signing_bytes, signature)
}
fn encode_disclosure_object(
record_hash: Digest,
profile_id: &str,
disclosure: &ProfileDisclosure,
) -> Result<Vec<u8>, DistributionError> {
let mut out = Vec::new();
out.extend_from_slice(DISCLOSURE_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
out.extend_from_slice(&record_hash.0);
push_u16_bytes(
&mut out,
validate_identifier(profile_id.to_owned())?.as_bytes(),
)?;
out.extend_from_slice(&disclosure.nonce);
push_u32_bytes(&mut out, &disclosure.payload)?;
Ok(out)
}
fn decode_disclosure_object(
bytes: &[u8],
) -> Result<(Digest, String, ProfileDisclosure), DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(DISCLOSURE_MAGIC)?;
cursor.expect_version()?;
let record_hash = Digest(cursor.array()?);
let profile_id = validate_identifier(cursor.string16()?)?;
let nonce = cursor.array()?;
let payload = cursor.bytes32()?.to_vec();
if payload.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(payload.len()).into());
}
cursor.finish()?;
Ok((
record_hash,
profile_id,
ProfileDisclosure { nonce, payload },
))
}
fn encode_claim_object(claim: &HeadClaim) -> Result<Vec<u8>, DistributionError> {
if claim.signature.is_empty() {
return Err(SshSigchainError::MissingSignature.into());
}
if claim.signature.len() > MAX_SIGNATURE_BYTES {
return Err(SshSigchainError::SignatureTooLarge(claim.signature.len()).into());
}
let mut out = Vec::new();
out.extend_from_slice(CLAIM_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u32_bytes(&mut out, &claim.signing_bytes()?)?;
push_u32_bytes(&mut out, &claim.signature)?;
Ok(out)
}
fn decode_claim_object(bytes: &[u8]) -> Result<HeadClaim, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(CLAIM_MAGIC)?;
cursor.expect_version()?;
let signing = cursor.bytes32()?;
let signature = cursor.bytes32()?.to_vec();
cursor.finish()?;
let mut signing_cursor = Cursor::new(signing);
signing_cursor.expect_magic(ANCHOR_MAGIC.try_into().expect("fixed magic"))?;
signing_cursor.expect_version()?;
let chain_id = ChainId(signing_cursor.array()?);
let head = Digest(signing_cursor.array()?);
let signer_key_id = AuthorityKeyId(Digest(signing_cursor.array()?));
let signer_public_key = signing_cursor.string16()?;
signing_cursor.finish()?;
HeadClaim {
chain_id,
head,
signer_key_id,
signer_public_key,
signature: Vec::new(),
}
.with_signature(signature)
.map_err(Into::into)
}
fn encode_receipt_object(receipt: &AnchorReceipt) -> Result<Vec<u8>, DistributionError> {
if receipt.evidence.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(receipt.evidence.len()).into());
}
let mut out = Vec::new();
out.extend_from_slice(RECEIPT_MAGIC);
out.push(SSH_SIGCHAIN_VERSION);
push_u16_bytes(
&mut out,
validate_identifier(receipt.backend_id.clone())?.as_bytes(),
)?;
out.extend_from_slice(&receipt.claim_hash.0);
push_u32_bytes(&mut out, &receipt.evidence)?;
Ok(out)
}
fn decode_receipt_object(bytes: &[u8]) -> Result<AnchorReceipt, DistributionError> {
validate_object_size(bytes)?;
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(RECEIPT_MAGIC)?;
cursor.expect_version()?;
let backend_id = validate_identifier(cursor.string16()?)?;
let claim_hash = Digest(cursor.array()?);
let evidence = cursor.bytes32()?.to_vec();
if evidence.len() > MAX_PAYLOAD_BYTES {
return Err(SshSigchainError::PayloadTooLarge(evidence.len()).into());
}
cursor.finish()?;
Ok(AnchorReceipt {
backend_id,
claim_hash,
evidence,
})
}
fn decode_record_signing_bytes(
bytes: &[u8],
signature: Vec<u8>,
) -> Result<SshSigchainRecord, DistributionError> {
let mut cursor = Cursor::new(bytes);
cursor.expect_magic(SIGNING_MAGIC.try_into().expect("fixed magic"))?;
cursor.expect_version()?;
let chain_id = ChainId(cursor.array()?);
let previous = cursor.optional_digest()?;
let signer_key_id = AuthorityKeyId(Digest(cursor.array()?));
let signer_public_key = cursor.string16()?;
let authority = decode_transition(&mut cursor)?;
let extension_count = cursor.count16(MAX_EXTENSIONS)?;
let mut extensions = Vec::with_capacity(extension_count);
for _ in 0..extension_count {
extensions.push(ProfileExtension::withheld(
cursor.string16()?,
Digest(cursor.array()?),
));
}
cursor.finish()?;
let record = SshSigchainRecord {
chain_id,
previous,
signer_key_id,
signer_public_key,
authority,
extensions,
signature,
};
record.validate(true)?;
if record.signing_bytes()? != bytes {
return Err(DistributionError::NonCanonical);
}
Ok(record)
}
fn decode_transition(cursor: &mut Cursor<'_>) -> Result<AuthorityTransition, DistributionError> {
Ok(match cursor.u8()? {
0 => AuthorityTransition::Genesis {
device_id: cursor.string16()?,
root_key: decode_authority_key(cursor)?,
anchor_policy: decode_anchor_policy(cursor)?,
},
1 => AuthorityTransition::DeviceAdd {
device_id: cursor.string16()?,
permission_ceiling: decode_permissions(cursor)?,
},
2 => AuthorityTransition::DeviceRevoke {
device_id: cursor.string16()?,
},
3 => AuthorityTransition::KeyAdd {
device_id: cursor.string16()?,
key: decode_authority_key(cursor)?,
proof: cursor.bytes32()?.to_vec(),
},
4 => AuthorityTransition::KeyRevoke {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
},
5 => AuthorityTransition::PermissionGrant {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
},
6 => AuthorityTransition::PermissionRevoke {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
},
7 => AuthorityTransition::AnchorPolicySet {
policy: decode_anchor_policy(cursor)?,
},
8 => AuthorityTransition::Noop,
_ => return Err(DistributionError::Malformed),
})
}
fn decode_authority_key(cursor: &mut Cursor<'_>) -> Result<AuthorityKey, DistributionError> {
Ok(AuthorityKey {
public_key: cursor.string16()?,
permissions: decode_permissions(cursor)?,
delegable_permissions: decode_permissions(cursor)?,
})
}
fn decode_permissions(cursor: &mut Cursor<'_>) -> Result<Vec<Permission>, DistributionError> {
let count = cursor.count16(u16::MAX as usize)?;
let mut permissions = Vec::with_capacity(count);
for _ in 0..count {
permissions.push(match cursor.u8()? {
0 => Permission::All,
1 => Permission::DeviceAdd,
2 => Permission::DeviceRevoke,
3 => Permission::KeyAddSelf,
4 => Permission::KeyAddAny,
5 => Permission::KeyRevokeSelf,
6 => Permission::KeyRevokeAny,
7 => Permission::ManagePermissions,
8 => Permission::AnchorPolicy,
9 => Permission::AnchorAttest,
10 => Permission::ProfileWrite(cursor.string16()?),
11 => Permission::ProfileDelegate(cursor.string16()?),
_ => return Err(DistributionError::Malformed),
});
}
permission_set(&permissions)?;
Ok(permissions)
}
fn decode_anchor_policy(cursor: &mut Cursor<'_>) -> Result<AnchorPolicy, DistributionError> {
let attester_threshold = cursor.u16()?;
let attester_count = cursor.count16(u16::MAX as usize)?;
let mut attesters = Vec::with_capacity(attester_count);
for _ in 0..attester_count {
attesters.push(AnchorAttesterPolicy {
key_id: AuthorityKeyId(Digest(cursor.array()?)),
weight: cursor.u16()?,
required: cursor.boolean()?,
});
}
let required_class_count = cursor.count16(u16::MAX as usize)?;
let mut required_classes = Vec::with_capacity(required_class_count);
for _ in 0..required_class_count {
required_classes.push(cursor.string16()?);
}
let backend_threshold = cursor.u16()?;
let backend_count = cursor.count16(u16::MAX as usize)?;
let mut backends = Vec::with_capacity(backend_count);
for _ in 0..backend_count {
backends.push(AnchorBackendPolicy {
backend_id: cursor.string16()?,
class: cursor.string16()?,
locator: cursor.string16()?,
weight: cursor.u16()?,
required: cursor.boolean()?,
});
}
let policy = AnchorPolicy {
attester_threshold,
attesters,
required_classes,
backend_threshold,
backends,
};
validate_anchor_policy(&policy)?;
Ok(policy)
}
struct Cursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn take(&mut self, length: usize) -> Result<&'a [u8], DistributionError> {
let end = self
.offset
.checked_add(length)
.ok_or(DistributionError::Malformed)?;
let value = self
.bytes
.get(self.offset..end)
.ok_or(DistributionError::Malformed)?;
self.offset = end;
Ok(value)
}
fn expect_magic(&mut self, magic: &[u8; 4]) -> Result<(), DistributionError> {
if self.take(4)? == magic {
Ok(())
} else {
Err(DistributionError::Malformed)
}
}
fn expect_version(&mut self) -> Result<(), DistributionError> {
let version = self.u8()?;
if version == SSH_SIGCHAIN_VERSION {
Ok(())
} else {
Err(DistributionError::UnsupportedVersion(version))
}
}
fn u8(&mut self) -> Result<u8, DistributionError> {
Ok(self.take(1)?[0])
}
fn boolean(&mut self) -> Result<bool, DistributionError> {
match self.u8()? {
0 => Ok(false),
1 => Ok(true),
_ => Err(DistributionError::Malformed),
}
}
fn u16(&mut self) -> Result<u16, DistributionError> {
Ok(u16::from_be_bytes(
self.take(2)?.try_into().expect("exact length"),
))
}
fn u32(&mut self) -> Result<u32, DistributionError> {
Ok(u32::from_be_bytes(
self.take(4)?.try_into().expect("exact length"),
))
}
fn count16(&mut self, maximum: usize) -> Result<usize, DistributionError> {
let count = usize::from(self.u16()?);
if count > maximum {
Err(DistributionError::TooManyObjects)
} else {
Ok(count)
}
}
fn count32(&mut self, maximum: usize) -> Result<usize, DistributionError> {
let count = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
if count > maximum {
Err(DistributionError::TooManyObjects)
} else {
Ok(count)
}
}
fn bytes32(&mut self) -> Result<&'a [u8], DistributionError> {
let length = usize::try_from(self.u32()?).map_err(|_| DistributionError::Malformed)?;
self.take(length)
}
fn string16(&mut self) -> Result<String, DistributionError> {
let length = usize::from(self.u16()?);
String::from_utf8(self.take(length)?.to_vec()).map_err(|_| DistributionError::Malformed)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], DistributionError> {
self.take(N)?
.try_into()
.map_err(|_| DistributionError::Malformed)
}
fn optional_digest(&mut self) -> Result<Option<Digest>, DistributionError> {
match self.u8()? {
0 => Ok(None),
1 => Ok(Some(Digest(self.array()?))),
_ => Err(DistributionError::Malformed),
}
}
fn finish(self) -> Result<(), DistributionError> {
if self.offset == self.bytes.len() {
Ok(())
} else {
Err(DistributionError::NonCanonical)
}
}
}
fn push_u32_count(out: &mut Vec<u8>, count: usize) -> Result<(), DistributionError> {
out.extend_from_slice(
&u32::try_from(count)
.map_err(|_| SshSigchainError::LengthOverflow)?
.to_be_bytes(),
);
Ok(())
}
fn validate_object_size(bytes: &[u8]) -> Result<(), DistributionError> {
if bytes.len() > MAX_CANONICAL_OBJECT_BYTES {
Err(DistributionError::ObjectTooLarge(bytes.len()))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const ROOT_KEY: &str = "ssh-ed25519 AQID";
fn signed_genesis() -> SshSigchainRecord {
SshSigchainRecord::unsigned(
ChainId([7; 32]),
None,
ROOT_KEY,
AuthorityTransition::Genesis {
device_id: "device:root".to_owned(),
root_key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![Permission::All],
delegable_permissions: vec![Permission::All],
},
anchor_policy: AnchorPolicy::default(),
},
vec![
ProfileExtension::disclosed("example.profile", [9; 32], b"disclosed".to_vec())
.expect("extension"),
],
)
.expect("record")
.with_signature(vec![1, 2, 3])
.expect("signature")
}
#[test]
fn canonical_bundle_roundtrips_and_preserves_disclosures() {
let record = signed_genesis();
let claim = HeadClaim::unsigned(
record.chain_id,
record.record_hash().expect("head"),
ROOT_KEY,
)
.expect("claim")
.with_signature(vec![4, 5, 6])
.expect("claim signature");
let receipt = AnchorReceipt {
backend_id: "static-http".to_owned(),
claim_hash: claim.claim_hash().expect("claim hash"),
evidence: b"receipt".to_vec(),
};
let bundle = CanonicalSshSigchainBundle {
chain_id: record.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record.clone()],
claims: vec![claim],
receipts: vec![receipt],
};
let encoded = encode_canonical_bundle(&bundle).expect("encode");
let decoded = decode_canonical_bundle(&encoded).expect("decode");
assert_eq!(decoded, bundle);
assert_eq!(
encode_canonical_bundle(&decoded).expect("reencode"),
encoded
);
}
#[test]
fn disclosure_bytes_are_separate_from_record_identity() {
let disclosed = signed_genesis();
let mut withheld = disclosed.clone();
withheld.extensions[0].disclosure = None;
assert_eq!(
disclosed.record_hash().expect("hash"),
withheld.record_hash().expect("hash")
);
assert_ne!(
encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: disclosed.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![disclosed],
claims: vec![],
receipts: vec![],
})
.expect("disclosed bundle"),
encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: withheld.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![withheld],
claims: vec![],
receipts: vec![],
})
.expect("withheld bundle")
);
}
#[test]
fn decoder_rejects_trailing_and_noncanonical_bytes() {
let record = signed_genesis();
let mut encoded = encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: record.chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record],
claims: vec![],
receipts: vec![],
})
.expect("encode");
encoded.push(0);
assert_eq!(
decode_canonical_bundle(&encoded),
Err(DistributionError::NonCanonical)
);
}
#[test]
fn canonical_bundle_roundtrips_every_authority_transition() {
let chain_id = ChainId([5; 32]);
let key_id = authority_key_id(ROOT_KEY).expect("key ID");
let transitions = vec![
AuthorityTransition::Genesis {
device_id: "device:root".to_owned(),
root_key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![Permission::All],
delegable_permissions: vec![Permission::All],
},
anchor_policy: AnchorPolicy::default(),
},
AuthorityTransition::DeviceAdd {
device_id: "device:second".to_owned(),
permission_ceiling: vec![Permission::DeviceAdd],
},
AuthorityTransition::DeviceRevoke {
device_id: "device:second".to_owned(),
},
AuthorityTransition::KeyAdd {
device_id: "device:root".to_owned(),
key: AuthorityKey {
public_key: ROOT_KEY.to_owned(),
permissions: vec![],
delegable_permissions: vec![],
},
proof: vec![1],
},
AuthorityTransition::KeyRevoke { key_id },
AuthorityTransition::PermissionGrant {
key_id,
permissions: vec![Permission::DeviceAdd],
delegable_permissions: vec![Permission::AnchorPolicy],
},
AuthorityTransition::PermissionRevoke {
key_id,
permissions: vec![Permission::DeviceAdd],
delegable_permissions: vec![Permission::AnchorPolicy],
},
AuthorityTransition::AnchorPolicySet {
policy: AnchorPolicy {
attester_threshold: 1,
attesters: vec![AnchorAttesterPolicy {
key_id,
weight: 1,
required: true,
}],
required_classes: vec!["http".to_owned()],
backend_threshold: 1,
backends: vec![AnchorBackendPolicy {
backend_id: "static-http".to_owned(),
class: "http".to_owned(),
locator: "https://example.test".to_owned(),
weight: 1,
required: true,
}],
},
},
AuthorityTransition::Noop,
];
let mut records = Vec::new();
for (index, transition) in transitions.into_iter().enumerate() {
let previous = records
.last()
.map(SshSigchainRecord::record_hash)
.transpose()
.expect("previous hash");
records.push(
SshSigchainRecord::unsigned(chain_id, previous, ROOT_KEY, transition, vec![])
.expect("record")
.with_signature(vec![u8::try_from(index + 1).expect("small index")])
.expect("signature"),
);
}
let bundle = CanonicalSshSigchainBundle {
chain_id,
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records,
claims: vec![],
receipts: vec![],
};
let bytes = encode_canonical_bundle(&bundle).expect("encode");
assert_eq!(decode_canonical_bundle(&bytes).expect("decode"), bundle);
}
#[test]
fn static_http_path_is_stable_and_contains_only_lowercase_chain_id() {
assert_eq!(
static_http_bundle_path(ChainId([0xab; 32])),
concat!(
"/.well-known/sshsigchain/v1/",
"abababababababababababababababababababababababababababababababab/",
"chain.sscb"
)
);
}
#[test]
fn canonical_bundle_matches_published_base_vector() {
let record = SshSigchainRecord::unsigned(
ChainId([0; 32]),
None,
ROOT_KEY,
AuthorityTransition::Noop,
vec![],
)
.expect("record")
.with_signature(vec![1])
.expect("signature");
let bytes = encode_canonical_bundle(&CanonicalSshSigchainBundle {
chain_id: ChainId([0; 32]),
namespace: SSH_SIGCHAIN_NAMESPACE.to_owned(),
records: vec![record],
claims: vec![],
receipts: vec![],
})
.expect("bundle");
assert_eq!(
hex::encode(bytes),
concat!(
"5353434201",
"0000000000000000000000000000000000000000000000000000000000000000",
"000e737368736967636861696e2e7631",
"00000001",
"00000069",
"5353435201",
"0000005b",
"5353435301000000000000000000000000000000000000000000000000000000000000000000",
"eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a2",
"00107373682d656432353531392041514944080000",
"0000000101",
"00000000",
"00000000",
"00000000"
)
);
}
}

View file

@ -120,6 +120,8 @@ pub enum NodeError {
Keychain(#[from] geth_keychain::KeychainError), Keychain(#[from] geth_keychain::KeychainError),
#[error("SSH sigchain error: {0}")] #[error("SSH sigchain error: {0}")]
SshSigchain(#[from] geth_keychain::SshSigchainError), SshSigchain(#[from] geth_keychain::SshSigchainError),
#[error("SSH sigchain distribution error: {0}")]
SshSigchainDistribution(#[from] geth_keychain::DistributionError),
#[error("json error: {0}")] #[error("json error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("io error: {0}")] #[error("io error: {0}")]
@ -6502,13 +6504,20 @@ pub fn handle_request(
root_key_path, root_key_path,
namespace, namespace,
} => { } => {
let records = read_sshsigchain_jsonl_file(&input)?; let loaded = read_sshsigchain_file(&input)?;
let trust = geth_keychain::SshSigchainTrust::new( let trust = geth_keychain::SshSigchainTrust::new(
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?, geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()), namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
std::fs::read_to_string(root_key_path)?, std::fs::read_to_string(root_key_path)?,
)?; )?;
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?; if loaded
.namespace
.as_ref()
.is_some_and(|namespace| namespace != &trust.namespace)
{
return Err(geth_keychain::DistributionError::WrongTrust.into());
}
let verified = verify_keychain_sshsigchain_with_ssh(&loaded.records, &trust)?;
Ok(ControlResponse::KeychainSigchainVerified { Ok(ControlResponse::KeychainSigchainVerified {
input, input,
chain_id: trust.chain_id.to_hex(), chain_id: trust.chain_id.to_hex(),
@ -6528,11 +6537,67 @@ pub fn handle_request(
.filter(|backend| backend.required) .filter(|backend| backend.required)
.count(), .count(),
required_anchor_classes: verified.state.anchor_policy.required_classes.len(), required_anchor_classes: verified.state.anchor_policy.required_classes.len(),
format: loaded.format.to_owned(),
disclosures: loaded.disclosures,
claims: loaded.claims,
receipts: loaded.receipts,
bundle_hash: loaded.bundle_hash,
note: note:
"verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key" "verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key; bundled claims and receipts are reported but require anchor-policy verification before head selection"
.to_owned(), .to_owned(),
}) })
} }
ControlRequest::KeychainSigchainBundleCreate {
input,
out,
namespace,
} => {
let records = read_sshsigchain_jsonl_file(&input)?;
let chain_id = records
.first()
.ok_or(geth_keychain::SshSigchainError::EmptyChain)?
.chain_id;
let bundle = geth_keychain::CanonicalSshSigchainBundle {
chain_id,
namespace,
records,
claims: vec![],
receipts: vec![],
};
let bytes = geth_keychain::encode_canonical_bundle(&bundle)?;
std::fs::write(&out, &bytes)?;
Ok(ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format: "canonical-bundle-v1".to_owned(),
records: bundle.records.len(),
disclosures: count_sigchain_disclosures(&bundle.records),
claims: 0,
receipts: 0,
bundle_hash: geth_keychain::canonical_bundle_hash(&bytes).to_hex(),
static_http_path: geth_keychain::static_http_bundle_path(chain_id),
note: "wrote the canonical SSHSIGCHAIN bundle; the root public key remains an out-of-band trust input and is intentionally not embedded".to_owned(),
})
}
ControlRequest::KeychainSigchainBundleExtract { input, out } => {
let bytes =
read_bounded_sigchain_file(&input, geth_keychain::MAX_CANONICAL_BUNDLE_BYTES)?;
let bundle = geth_keychain::decode_canonical_bundle(&bytes)?;
let jsonl = geth_keychain::encode_sshsigchain_jsonl(&bundle.records)?;
std::fs::write(&out, jsonl)?;
Ok(ControlResponse::KeychainSigchainBundleWritten {
input,
out,
format: "jsonl-interchange-v1".to_owned(),
records: bundle.records.len(),
disclosures: count_sigchain_disclosures(&bundle.records),
claims: bundle.claims.len(),
receipts: bundle.receipts.len(),
bundle_hash: geth_keychain::canonical_bundle_hash(&bytes).to_hex(),
static_http_path: geth_keychain::static_http_bundle_path(bundle.chain_id),
note: "extracted record JSONL including available disclosures; head claims and anchor receipts remain in the canonical bundle because JSONL carries records only".to_owned(),
})
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained { ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(), subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?, lines: explain_keychain_op(&store, node, &op_id)?,
@ -9619,12 +9684,73 @@ fn read_sshsigchain_jsonl_file(
input: &Path, input: &Path,
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> { ) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64); let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
let mut text = String::new(); let mut bytes = Vec::new();
reader.read_to_string(&mut text)?; reader.read_to_end(&mut bytes)?;
if text.len() > geth_keychain::MAX_JSONL_BYTES { if bytes.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into()); return Err(geth_keychain::SshSigchainError::JsonlTooLarge(bytes.len()).into());
} }
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?) let text =
std::str::from_utf8(&bytes).map_err(|_| geth_keychain::DistributionError::Malformed)?;
Ok(geth_keychain::decode_sshsigchain_jsonl(text)?)
}
struct LoadedSshSigchain {
records: Vec<geth_keychain::SshSigchainRecord>,
namespace: Option<String>,
format: &'static str,
disclosures: usize,
claims: usize,
receipts: usize,
bundle_hash: Option<String>,
}
fn read_sshsigchain_file(input: &Path) -> Result<LoadedSshSigchain, NodeError> {
let bytes = read_bounded_sigchain_file(input, geth_keychain::MAX_CANONICAL_BUNDLE_BYTES)?;
if bytes.starts_with(b"SSCB") {
let bundle = geth_keychain::decode_canonical_bundle(&bytes)?;
return Ok(LoadedSshSigchain {
disclosures: count_sigchain_disclosures(&bundle.records),
claims: bundle.claims.len(),
receipts: bundle.receipts.len(),
bundle_hash: Some(geth_keychain::canonical_bundle_hash(&bytes).to_hex()),
records: bundle.records,
namespace: Some(bundle.namespace),
format: "canonical-bundle-v1",
});
}
if bytes.len() > geth_keychain::MAX_JSONL_BYTES {
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(bytes.len()).into());
}
let text =
std::str::from_utf8(&bytes).map_err(|_| geth_keychain::DistributionError::Malformed)?;
let records = geth_keychain::decode_sshsigchain_jsonl(text)?;
Ok(LoadedSshSigchain {
disclosures: count_sigchain_disclosures(&records),
records,
namespace: None,
format: "jsonl-interchange-v1",
claims: 0,
receipts: 0,
bundle_hash: None,
})
}
fn read_bounded_sigchain_file(input: &Path, maximum: usize) -> Result<Vec<u8>, NodeError> {
let mut reader = std::fs::File::open(input)?.take((maximum + 1) as u64);
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if bytes.len() > maximum {
return Err(geth_keychain::DistributionError::BundleTooLarge(bytes.len()).into());
}
Ok(bytes)
}
fn count_sigchain_disclosures(records: &[geth_keychain::SshSigchainRecord]) -> usize {
records
.iter()
.flat_map(|record| &record.extensions)
.filter(|extension| extension.disclosure.is_some())
.count()
} }
fn verify_keychain_sigchain_with_ssh( fn verify_keychain_sigchain_with_ssh(
@ -11104,6 +11230,21 @@ mod tests {
assert_eq!(verified.records, 1); assert_eq!(verified.records, 1);
assert_eq!(verified.state.active_key_count(), 1); assert_eq!(verified.state.active_key_count(), 1);
let bundle_bytes =
geth_keychain::encode_canonical_bundle(&geth_keychain::CanonicalSshSigchainBundle {
chain_id: trust.chain_id,
namespace: trust.namespace.clone(),
records: vec![init.clone()],
claims: vec![],
receipts: vec![],
})
.expect("encode canonical bundle");
let bundle =
geth_keychain::decode_canonical_bundle(&bundle_bytes).expect("decode canonical bundle");
let bundled = verify_keychain_sshsigchain_with_ssh(&bundle.records, &trust)
.expect("verify canonical bundle records");
assert_eq!(bundled.head, verified.head);
let mut tampered = init; let mut tampered = init;
tampered.chain_id = geth_keychain::SshSigchainChainId([0x43; 32]); tampered.chain_id = geth_keychain::SshSigchainChainId([0x43; 32]);
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err()); assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());

View file

@ -547,7 +547,7 @@ pub(crate) fn node_error_code(error: &NodeError) -> &'static str {
NodeError::Store(_) => "store_error", NodeError::Store(_) => "store_error",
NodeError::Config(_) => "config_error", NodeError::Config(_) => "config_error",
NodeError::Codec(_) => "codec_error", NodeError::Codec(_) => "codec_error",
NodeError::SshSigchain(_) => "sshsigchain_error", NodeError::SshSigchain(_) | NodeError::SshSigchainDistribution(_) => "sshsigchain_error",
_ => "node_error", _ => "node_error",
} }
} }

View file

@ -36,7 +36,12 @@ SSHSIGCHAIN v1 is redesigned before deployment:
- cached ancestors are never replaced by older heads and incomparable verified - cached ancestors are never replaced by older heads and incomparable verified
histories fail as forks; and histories fail as forks; and
- a policy-change head is witnessed under the preceding policy before the new - a policy-change head is witnessed under the preceding policy before the new
policy governs descendants. policy governs descendants;
- canonical `.sscb` bundles deterministically store records, separately
discloseable payloads, claims, and receipts without embedding root trust;
- JSONL is noncanonical human-facing interchange; and
- distribution backends return untrusted bundle bytes through one interface,
with a static HTTP profile that requires no server application.
The generic core defines backend interfaces and deterministic policy behavior, The generic core defines backend interfaces and deterministic policy behavior,
not Nostr, HTTP, blockchain, or transparency-log clients. Those adapters belong not Nostr, HTTP, blockchain, or transparency-log clients. Those adapters belong
@ -60,5 +65,6 @@ draft. No migration parser or version alias is retained.
Rollback protection still depends on persistent local accepted heads and the Rollback protection still depends on persistent local accepted heads and the
operator's anchor policy. A Nostr relay, mutable HTTP URL, blockchain, or operator's anchor policy. A Nostr relay, mutable HTTP URL, blockchain, or
transparency service has only the guarantees its receipt verifier and deployment transparency service has only the guarantees its receipt verifier and deployment
actually establish. Concrete publication, durable accepted-head storage, and actually establish. Canonical bundle conversion and verification exist, while
automatic HTTP/Iroh fetching, durable accepted-head storage, and
cross-implementation vectors remain follow-up work. cross-implementation vectors remain follow-up work.

View file

@ -492,6 +492,15 @@ test-only static workflow was removed rather than migrated. Iroh keychain sync
remains the current replicated local operation-log path while explicit remains the current replicated local operation-log path while explicit
SSHSIGCHAIN production and import workflows are completed. SSHSIGCHAIN production and import workflows are completed.
SSHSIGCHAIN's canonical `.sscb` bundle is the portable on-disk and full-snapshot
distribution boundary. It deterministically stores signed record objects,
separate disclosures, head claims, and anchor receipts without embedding the
root key. JSONL remains inspection/interchange only. Bundle sources implement a
backend-neutral untrusted fetch interface; the static HTTP profile publishes
the same bytes below `/.well-known/sshsigchain/v1/<chain-id>/chain.sscb`.
Fetching never grants trust or selects a head. Geth may use Iroh rather than
HTTP operationally without changing the format or verification path.
New devices can use the node enrollment flow instead of hand-editing keychain New devices can use the node enrollment flow instead of hand-editing keychain
state. `geth node enroll join` explicitly imports an owner admin public key as state. `geth node enroll join` explicitly imports an owner admin public key as
the new node's trust anchor, imports the signed peer card only as untrusted the new node's trust anchor, imports the signed peer card only as untrusted

View file

@ -62,6 +62,7 @@ settles:
- `geth cas add-private` - `geth cas add-private`
- `geth cas get-private` - `geth cas get-private`
- `geth keychain verify-sigchain` - `geth keychain verify-sigchain`
- `geth keychain bundle-create|bundle-extract`
Migration expectation: overlay membership records and authorization resources Migration expectation: overlay membership records and authorization resources
should remain readable, but packet runtime flags, platform activation details, should remain readable, but packet runtime flags, platform activation details,
@ -72,10 +73,10 @@ properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from
earlier pre-deployment builds are rejected with a clear error and should be earlier pre-deployment builds are rejected with a clear error and should be
recreated from plaintext. recreated from plaintext.
The SSHSIGCHAIN verifier is experimental while SSHSIGCHAIN signing, storage, The SSHSIGCHAIN verifier and canonical bundle conversion are experimental while
publication, import, head persistence, and independently generated wire vectors SSHSIGCHAIN signing, append storage, automatic distribution, import, head
are completed. The prior test-only static commands were removed and are not a persistence, and independently generated wire vectors are completed. The prior
compatibility path. test-only static commands were removed and are not a compatibility path.
## Adding Commands ## Adding Commands

View file

@ -576,10 +576,28 @@ resource-scoped capability decisions.
backend receipt policies, required backend classes, old-policy witnessing backend receipt policies, required backend classes, old-policy witnessing
of policy-change heads, cached-head rollback checks, and of policy-change heads, cached-head rollback checks, and
incomparable-history fork failure. incomparable-history fork failure.
- `[x]` Define and implement the canonical `.sscb` on-disk/full-snapshot
bundle with deterministic record, disclosure, claim, and receipt objects.
- `[x]` Decoding rejects trailing bytes, noncanonical order, duplicates,
unknown references, mixed chains, and over-limit inputs.
- `[x]` `bundle-create` and `bundle-extract` convert record JSONL while
clearly preserving claims/receipts only in the canonical bundle.
- `[x]` `verify-sigchain` auto-detects JSONL or canonical bundles and binds
a bundle's namespace and chain ID to local trust.
- `[x]` Specify the backend-neutral distribution-source contract and a static
HTTP full-snapshot profile at
`/.well-known/sshsigchain/v1/<chain-id>/chain.sscb`.
- `[ ]` Implement automatic distribution fetchers.
- Acceptance criteria: an HTTP fetcher enforces streaming limits,
conditional caching, redirect policy, trust-tuple matching, accepted-head
ancestry, anchor policy, and fork failure.
- Acceptance criteria: the Iroh fetcher returns the identical canonical
bundle/object model and remains the only geth node-to-node transport.
- `[~]` Add explicit CLI storage, signing, verification, publication, and - `[~]` Add explicit CLI storage, signing, verification, publication, and
import workflows for a pinned SSHSIGCHAIN trust tuple. import workflows for a pinned SSHSIGCHAIN trust tuple.
- `[x]` `geth keychain verify-sigchain` verifies a JSONL transport file - `[x]` `geth keychain verify-sigchain` verifies JSONL interchange or a
against an operator-pinned chain ID and OpenSSH root public key. canonical bundle against an operator-pinned chain ID, namespace, and
OpenSSH root public key.
- `[ ]` Local record storage, signing, publication, import, and - `[ ]` Local record storage, signing, publication, import, and
head-advance workflows are complete. head-advance workflows are complete.
- `[ ]` Persist accepted heads and require proof of extension before a source - `[ ]` Persist accepted heads and require proof of extension before a source
@ -602,8 +620,9 @@ resource-scoped capability decisions.
- `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies an - `[x]` An OpenSSH `ssh-keygen -Y` integration test signs and verifies an
Authority v1 genesis link. Authority v1 genesis link.
- `[~]` The specification and reference implementation share base signing, - `[~]` The specification and reference implementation share base signing,
key-proof, commitment, and head-claim vectors. canonical-bundle, key-proof, commitment, and head-claim vectors.
- `[ ]` Publish the complete vector set in the specification. - `[x]` Publish outer-link and canonical-bundle framing vectors.
- `[ ]` Publish key-proof, commitment, head-claim, and receipt vectors.
- `[ ]` Add independently generated cross-implementation vectors. - `[ ]` Add independently generated cross-implementation vectors.
- `[x]` SSH-admin-rooted keychain initialization. - `[x]` SSH-admin-rooted keychain initialization.

View file

@ -103,6 +103,23 @@ generated wire vectors. Those later workflows must preserve the same explicit
trust tuple and Authority v1 semantics; they must not introduce a compatibility trust tuple and Authority v1 semantics; they must not introduce a compatibility
route for the removed static format. route for the removed static format.
The verifier also accepts the canonical `.sscb` on-disk bundle. JSONL is a
human-facing record interchange and does not preserve standalone head claims or
anchor receipts. Convert between them with:
```sh
geth keychain bundle-create --in chain.jsonl --out chain.sscb
geth keychain bundle-extract --in chain.sscb --out chain.jsonl
```
The canonical bundle keeps disclosures separate from signed record objects and
sorts every object class deterministically. Adding a disclosure changes the
bundle hash but not the chain head. The same file is the simple static HTTP
distribution artifact at
`/.well-known/sshsigchain/v1/<chain-id>/chain.sscb`. Distribution is untrusted:
the configured root, authority replay, anchor policy, locally accepted head,
and fork checks still decide acceptance.
## Local commands ## Local commands
Bootstrap an owner node: Bootstrap an owner node:

View file

@ -334,7 +334,150 @@ A conforming verifier MUST:
Records are never reordered by timestamps, transport arrival, identifiers, or Records are never reordered by timestamps, transport arrival, identifiers, or
record counts. A verifier without all trust inputs fails closed. record counts. A verifier without all trust inputs fails closed.
## 9. JSONL transport and limits ## 9. Canonical objects and on-disk bundle
The canonical on-disk and full-snapshot distribution format is a single
SSHSIGCHAIN Canonical Bundle (`.sscb`). It contains records, disclosures, head
claims, and anchor receipts while deliberately omitting the root public key.
The root remains an out-of-band trust input.
All objects start with four magic bytes and version `0x01`. `bytes32` means a
big-endian `u32` length followed by that many bytes.
```text
record object:
"SSCR" || 0x01 || record_signing_bytes:bytes32 || signature:bytes32
disclosure object:
"SSCD" || 0x01 || record_hash:32 || profile_id:string16 ||
nonce:32 || payload:bytes32
head-claim object:
"SSHC" || 0x01 || claim_signing_bytes:bytes32 || signature:bytes32
anchor-receipt object:
"SSRC" || 0x01 || backend_id:string16 || claim_hash:32 ||
evidence:bytes32
```
The bundle grammar is:
```text
"SSCB" 4 bytes
0x01 bundle version
chain_id 32 bytes
namespace string16
record_count u32
records record_count objects, each wrapped as bytes32
disclosure_count u32
disclosures disclosure_count objects, each wrapped as bytes32
claim_count u32
claims claim_count objects, each wrapped as bytes32
receipt_count u32
receipts receipt_count objects, each wrapped as bytes32
```
Canonical ordering and uniqueness rules are:
- records occur in causal genesis-to-head order and every parent must equal the
preceding record hash;
- disclosures are sorted by `(record_hash, profile_id)` and that pair is unique;
- claims are sorted by claim hash and claim hashes are unique;
- receipts are sorted by `(claim_hash, backend_id)` and that pair is unique;
- disclosures must open a commitment in the named bundled record;
- claims must name a record in the bundled history; and
- receipts must name a bundled claim.
A decoder MUST reject trailing bytes, out-of-order objects, duplicate objects,
noncanonical embedded signing bytes, mixed chain IDs, unknown references, or a
bundle which does not encode byte-for-byte identically when re-encoded. The
namespace is self-description only and MUST exactly match the locally configured
trust tuple before record verification.
Bundle identity is:
```text
BLAKE3("sshsigchain.bundle.v1\0" || canonical_bundle_bytes)
```
Bundle identity is not chain identity: adding a valid disclosure or receipt
changes the bundle hash without changing any record hash. A canonical bundle is
therefore suitable as the canonical single-file on-disk snapshot, attachment,
cache object, or static-server artifact. Implementations may maintain indexes
or databases internally, but export/import MUST round-trip through this format.
The reference maximum bundle size is 256 MiB and the maximum embedded object
size is 5 MiB. Implementations MUST enforce bounds while streaming, before
allocating lengths supplied by an untrusted bundle.
### 9.1 Base bundle vector
The following structural vector contains the section 4.1 zero-chain `Noop`
record, signature bytes `01`, namespace `sshsigchain.v1`, and no disclosures,
claims, or receipts:
```text
53534342010000000000000000000000000000000000000000000000000000000000000000000e737368736967636861696e2e7631000000010000006953534352010000005b5353435301000000000000000000000000000000000000000000000000000000000000000000eea8e117bae085d4a9793d8b7d726a89558b63e515a6e1ca32d526f93b24c8a200107373682d6564323535313920415149440800000000000101000000000000000000000000
```
It fixes bundle and record-object framing. Like the section 4.1 outer vector,
it is not a valid verified chain because the first transition is not `Genesis`
and `01` is not an OpenSSH SSHSIG signature.
## 10. Distribution profiles
Distribution is untrusted availability and routing. It does not grant
authority, choose a canonical head, replace the root trust tuple, or satisfy an
anchor policy merely because a file was fetched successfully.
A distribution endpoint has a local/configured `backend_id`, `class`, and
opaque `locator`. Backends conceptually implement:
```text
fetch_bundle(endpoint, chain_id, accepted_head) -> none | canonical_bundle_bytes
```
The `accepted_head` permits an incremental P2P backend to optimize its response,
but every returned result must decode to a complete verifiable snapshot that
contains the accepted head. HTTP, Iroh, removable-media, object-store, IPFS, or
application-specific backends can implement the same interface. Fetch failures
affect availability only. Results enter the same signature, authority,
anchoring, cached-head, and fork checks.
### 10.1 Static HTTP profile
The static HTTP profile needs no server application or directory listing. For
chain ID `<lowercase-chain-id>`, publish the bundle at:
```text
/.well-known/sshsigchain/v1/<lowercase-chain-id>/chain.sscb
```
The response media type SHOULD be:
```text
application/vnd.sshsigchain.bundle.v1
```
A client performs an ordinary `GET`, accepts a successful full response, caps
bytes while streaming, decodes the canonical bundle, requires its chain ID and
namespace to match local trust, verifies the chain, verifies applicable head
claims and receipts, then applies cached-head/fork rules. `404` means the source
currently has no bundle. Servers SHOULD replace `chain.sscb` atomically.
Servers MAY expose the lowercase bundle hash as a strong `ETag`; clients MAY use
`If-None-Match`. HTTP cache validators are performance hints, not trust. HTTPS
protects privacy, locators, and availability against network interference, but
SSHSIGCHAIN verification remains mandatory even over authenticated HTTPS.
Plain HTTP remains integrity-safe only in the narrow sense that tampering is
detected; it offers no confidentiality or reliable availability.
This full-snapshot profile intentionally favors deployment with a static file
server. A later chunked profile may add immutable hash-addressed objects and a
small mutable manifest, but it must preserve the object encodings and all
verification rules above.
## 11. JSONL interchange
The reference transport uses one JSON object per nonblank line with exactly the The reference transport uses one JSON object per nonblank line with exactly the
record fields in section 3. Byte arrays use JSON arrays of octets; enum objects record fields in section 3. Byte arrays use JSON arrays of octets; enum objects
@ -345,7 +488,11 @@ Reference limits are: 16 KiB canonical key, 64 KiB signature/proof, 1 MiB one
profile payload, 1,024 extensions per record, 100,000 records, 5 MiB one JSONL profile payload, 1,024 extensions per record, 100,000 records, 5 MiB one JSONL
line, and 64 MiB one JSONL input. Implementations may impose smaller limits. line, and 64 MiB one JSONL input. Implementations may impose smaller limits.
## 10. OpenSSH interoperability JSONL is a human-facing interchange representation, not the canonical on-disk
format. It carries records and optional disclosures only; canonical bundles are
required to preserve head claims and receipts.
## 12. OpenSSH interoperability
Outer records use commands equivalent to: Outer records use commands equivalent to:
@ -358,7 +505,7 @@ ssh-keygen -Y verify -f <one-key-allowed-signers> -I sshsigchain \
The one-key `allowed_signers` file is only an input to cryptographic signature The one-key `allowed_signers` file is only an input to cryptographic signature
verification. It never establishes authorization or root trust. verification. It never establishes authorization or root trust.
## 11. Security considerations ## 13. Security considerations
- Use independent random chain IDs and protocol-specific SSHSIG namespaces. - Use independent random chain IDs and protocol-specific SSHSIG namespaces.
- A stolen active key retains its scoped authority until a causally prior - A stolen active key retains its scoped authority until a causally prior
@ -369,6 +516,8 @@ verification. It never establishes authorization or root trust.
nonce is reused. Always use a fresh unpredictable nonce. nonce is reused. Always use a fresh unpredictable nonce.
- Anchor strength is no greater than the configured independent backends and - Anchor strength is no greater than the configured independent backends and
receipt verifiers. Cached-head checks remain mandatory. receipt verifiers. Cached-head checks remain mandatory.
- Distribution sources and HTTP cache metadata are untrusted. A fetched bundle
must contain the cached head and pass the complete verifier before use.
- Resource bearer secrets, discovery records, and profile payloads cannot - Resource bearer secrets, discovery records, and profile payloads cannot
mutate SSHSIGCHAIN authority. mutate SSHSIGCHAIN authority.
- Implementations should apply a local OpenSSH algorithm policy and reject - Implementations should apply a local OpenSSH algorithm policy and reject