add canonical sigchain bundle format
This commit is contained in:
parent
76eb785ee2
commit
decff4b995
14 changed files with 1515 additions and 42 deletions
25
README.md
25
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
987
crates/geth-keychain/src/sshsigchain/bundle.rs
Normal file
987
crates/geth-keychain/src/sshsigchain/bundle.rs
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue