Skip to content

Commit 776873b

Browse files
committed
PCF-SIG: trust patterns A (key attestations) and B (key endorsement)
Two ways for an application to express trust without X.509 are now fully described in spec Section 12 and supported by the reference implementation: Pattern A (Section 12.1): self-binding key attestations. Application- private TLV entries in PCFSIG_KEY (tag range 0x8000-0xFFFF) MAY carry a JWT, SCITT statement, or custom signed envelope. The attestation MUST internally commit to the key's SHA-256 fingerprint, since the fingerprint field itself covers only key_data and not the TLV stream. Pattern B (Section 12.2): key endorsement via countersignature. A "CA" emits a PCFSIG_SIG whose manifest covers the leaf signer's PCFSIG_KEY partition by uid. This binds the CA cryptographically to the leaf's key material via the partition's data_hash. A new verify::key_endorsements() helper returns the set of signers that endorse a given key fingerprint; the application composes that with its trusted-CA set into a trust decision. Section 12.2.1 describes three Pattern B issuance workflows (file round-trip, stateless server, offline pre-issued). The recommended stateless workflow is supported by new endorse::issue_endorsement and endorse::embed_endorsement helpers: the CA never sees the leaf's container file and needs no per-issuance state. The same response is valid in any PCF file in which the leaf KEY partition is reproduced byte-identically (license pattern). Includes 7 new integration tests covering single-hop endorsement, the stateless workflow, durability across files, weak-hash rejection, and the orthogonality of key endorsement vs leaf data integrity. https://claude.ai/code/session_01ST4PcjqvobURus32WuyEi5
1 parent be99cae commit 776873b

6 files changed

Lines changed: 897 additions & 5 deletions

File tree

reference/PCF-SIG-v1.0/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,62 @@ for report in verify_all_with_recheck(&mut c)? {
7575
# Ok::<(), pcf_sig::Error>(())
7676
```
7777

78+
## Trust patterns
79+
80+
The profile defines two non-X.509 ways for an application to express trust;
81+
both are described in spec Section 12.
82+
83+
**Pattern A — self-binding key attestations.** Carry a JWT, SCITT statement,
84+
or custom signed envelope as an application-private TLV entry (tag range
85+
`0x8000..0xFFFF`) inside the `PCFSIG_KEY` partition (Section 6.4). The
86+
attestation MUST internally commit to the key's SHA-256 fingerprint (e.g.
87+
JWT `cnf.jkt`); otherwise the binding is meaningless because the fingerprint
88+
covers only `key_data`, not the TLV. The application verifies the
89+
attestation independently of PCF-SIG.
90+
91+
**Pattern B — key endorsement via countersignature.** A "CA" emits a
92+
`PCFSIG_SIG` partition whose manifest covers the leaf signer's `PCFSIG_KEY`
93+
partition by uid. Verifiers report it like any other signature; the
94+
application checks whether any trusted CA fingerprint endorses the leaf key.
95+
96+
```rust
97+
use pcf_sig::{key_endorsements, verify_all_with_recheck};
98+
99+
let reports = verify_all_with_recheck(&mut container)?;
100+
let endorsers = key_endorsements(&mut container, &reports, &leaf_fingerprint)?;
101+
let trusted = endorsers.iter().any(|fp| my_trusted_ca_set.contains(fp));
102+
# Ok::<(), pcf_sig::Error>(())
103+
```
104+
105+
For Pattern B the CA does NOT need the leaf's PCF file. The stateless
106+
workflow (spec 12.2.1 W2): the client sends only the leaf key bytes plus the
107+
planned partition identity; the CA returns a self-contained response that
108+
the client embeds locally.
109+
110+
```rust
111+
use pcf_sig::{embed_endorsement, issue_endorsement, EndorsementRequest, KeyFormat};
112+
use pcf::HashAlgo;
113+
114+
// CA side (stateless; no I/O, no file):
115+
let request = EndorsementRequest {
116+
key_format: KeyFormat::Ed25519Raw,
117+
key_data: leaf_public_key_bytes,
118+
intended_uid: agreed_uid, // stable across leaf and CA
119+
intended_label: agreed_label,
120+
data_hash_algo: HashAlgo::Sha256,
121+
};
122+
let response = issue_endorsement(&ca_signer, &request, signed_at)?;
123+
124+
// Client side: embed into the local container:
125+
embed_endorsement(&mut container, &response, ca_key_uid, ca_sig_uid, "ca-key", "ca-sig")?;
126+
# Ok::<(), pcf_sig::Error>(())
127+
```
128+
129+
Because the response commits to the leaf `PCFSIG_KEY` bytes (not to any file
130+
location), a client may also cache and re-use the same response across many
131+
PCF files in which the leaf KEY partition is reproduced byte-identical
132+
(workflow W3, license pattern).
133+
78134
## Relocation stability
79135

80136
The central property: a PCFSIG_SIG signature remains valid across any
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//! Pattern B helpers (spec Section 12.2 and 12.2.1): produce and embed CA
2+
//! endorsements of leaf PCFSIG_KEY partitions, without the CA ever touching
3+
//! the leaf's container file.
4+
//!
5+
//! Two stages:
6+
//!
7+
//! * **CA side** ([`issue_endorsement`]) is a pure function over a private
8+
//! key plus the leaf's planned PCFSIG_KEY identity. It needs no I/O and no
9+
//! container -- the stateless-server workflow W2 of spec Section 12.2.1.
10+
//!
11+
//! * **Client side** ([`embed_endorsement`]) takes the response and writes
12+
//! the CA's PCFSIG_KEY and PCFSIG_SIG partitions into the local container.
13+
14+
use std::io::{Read, Seek, Write};
15+
16+
use pcf::{Container, HashAlgo, LABEL_SIZE, UID_SIZE};
17+
18+
use crate::algo::KeyFormat;
19+
use crate::consts::*;
20+
use crate::error::{Error, Result};
21+
use crate::key::{compute_fingerprint, KeyRecord};
22+
use crate::manifest::{is_crypto_hash, Manifest, SignedEntry};
23+
use crate::sig::SignaturePartition;
24+
use crate::sign::SigningMaterial;
25+
26+
/// CA-side input: everything the CA needs to compute a key endorsement
27+
/// without seeing the leaf's container.
28+
#[derive(Debug, Clone)]
29+
pub struct EndorsementRequest {
30+
/// Leaf key's format id (spec Section 6.2).
31+
pub key_format: KeyFormat,
32+
/// Leaf key's raw bytes in the encoding named by `key_format`.
33+
pub key_data: Vec<u8>,
34+
/// PCF uid that the leaf PCFSIG_KEY partition will use in the client's
35+
/// container. MUST be agreed before issuance and not changed afterwards.
36+
pub intended_uid: [u8; UID_SIZE],
37+
/// PCF 32-byte label field that the leaf PCFSIG_KEY partition will use.
38+
pub intended_label: [u8; LABEL_SIZE],
39+
/// PCF data_hash algorithm the leaf PCFSIG_KEY partition will use.
40+
/// MUST be cryptographic (16, 17, or 18) per spec Section 9.
41+
pub data_hash_algo: HashAlgo,
42+
}
43+
44+
/// CA-side output: bytes the client embeds in its container to publish the
45+
/// endorsement.
46+
#[derive(Debug, Clone, PartialEq, Eq)]
47+
pub struct EndorsementResponse {
48+
/// CA's Key Record bytes (becomes the CA PCFSIG_KEY partition data).
49+
pub ca_key_record_bytes: Vec<u8>,
50+
/// Assembled PCFSIG_SIG partition bytes (manifest || sig_len || sig
51+
/// || trailer_len=0) ready to be added as a single PCF partition.
52+
pub ca_sig_partition_bytes: Vec<u8>,
53+
}
54+
55+
/// Produce a key endorsement (spec Section 12.2.1, workflow W2).
56+
///
57+
/// This function performs NO I/O: it consumes only the CA's signing key and
58+
/// the leaf's planned identity. It can therefore be hosted behind an
59+
/// HSM-fronted, stateless endpoint with no per-issuance database.
60+
pub fn issue_endorsement(
61+
ca: &SigningMaterial,
62+
request: &EndorsementRequest,
63+
signed_at_unix_seconds: i64,
64+
) -> Result<EndorsementResponse> {
65+
if !is_crypto_hash(request.data_hash_algo) {
66+
return Err(Error::NonCryptoTargetHash);
67+
}
68+
if request.intended_uid == pcf::NIL_UID {
69+
return Err(Error::EntryNilUid);
70+
}
71+
72+
// 1. Serialise the leaf Key Record exactly as the client will write it.
73+
let leaf_key_record = KeyRecord::new(request.key_format, request.key_data.clone())?.to_bytes();
74+
75+
// 2. Build the SignedEntry committing to the leaf PCFSIG_KEY partition's
76+
// identity and to the bytes of its Key Record.
77+
let signed_entry = SignedEntry {
78+
uid: request.intended_uid,
79+
partition_type: TYPE_PCFSIG_KEY,
80+
label: request.intended_label,
81+
used_bytes: leaf_key_record.len() as u64,
82+
data_hash_algo: request.data_hash_algo,
83+
data_hash: request.data_hash_algo.compute(&leaf_key_record),
84+
};
85+
86+
// 3. Build the Manifest and sign it.
87+
let manifest_hash = ca
88+
.sig_algo()
89+
.required_manifest_hash()
90+
.expect("implemented algorithms bind a manifest hash");
91+
let manifest = Manifest::new(
92+
ca.sig_algo(),
93+
manifest_hash,
94+
ca.fingerprint(),
95+
signed_at_unix_seconds,
96+
vec![signed_entry],
97+
);
98+
let manifest_bytes = manifest.to_bytes();
99+
let signature = ca.sign(&manifest_bytes);
100+
101+
// 4. Compose the CA's Key Record and the PCFSIG_SIG partition bytes.
102+
let ca_key_record_bytes = ca.to_key_record().to_bytes();
103+
let ca_sig_partition_bytes = SignaturePartition {
104+
manifest,
105+
manifest_bytes,
106+
signature,
107+
trailer: Vec::new(),
108+
}
109+
.to_bytes();
110+
111+
Ok(EndorsementResponse {
112+
ca_key_record_bytes,
113+
ca_sig_partition_bytes,
114+
})
115+
}
116+
117+
/// Client-side: embed an [`EndorsementResponse`] into the local container.
118+
///
119+
/// Adds the CA's PCFSIG_KEY partition (skipped if a partition with that
120+
/// fingerprint is already present) and the CA's PCFSIG_SIG partition.
121+
pub fn embed_endorsement<S: Read + Write + Seek>(
122+
container: &mut Container<S>,
123+
response: &EndorsementResponse,
124+
ca_key_uid: [u8; UID_SIZE],
125+
ca_sig_uid: [u8; UID_SIZE],
126+
ca_key_label: &str,
127+
ca_sig_label: &str,
128+
) -> Result<()> {
129+
// Refuse to duplicate an existing CA key partition.
130+
let new_key = KeyRecord::from_bytes(&response.ca_key_record_bytes)?;
131+
let mut ca_key_already_present = false;
132+
for e in container.entries()? {
133+
if e.partition_type == TYPE_PCFSIG_KEY {
134+
let data = container.read_partition_data(&e)?;
135+
if let Ok(existing) = KeyRecord::from_bytes(&data) {
136+
if existing.fingerprint == new_key.fingerprint {
137+
ca_key_already_present = true;
138+
break;
139+
}
140+
}
141+
}
142+
}
143+
if !ca_key_already_present {
144+
container.add_partition(
145+
TYPE_PCFSIG_KEY,
146+
ca_key_uid,
147+
ca_key_label,
148+
&response.ca_key_record_bytes,
149+
0,
150+
HashAlgo::Sha256,
151+
)?;
152+
}
153+
container.add_partition(
154+
TYPE_PCFSIG_SIG,
155+
ca_sig_uid,
156+
ca_sig_label,
157+
&response.ca_sig_partition_bytes,
158+
0,
159+
HashAlgo::Sha256,
160+
)?;
161+
Ok(())
162+
}
163+
164+
/// Convenience: compute the PCF data_hash that a leaf PCFSIG_KEY partition
165+
/// will publish, given the same inputs the CA used. Lets a client verify
166+
/// locally that the EndorsementRequest it sent and the partition it intends
167+
/// to write agree byte-for-byte.
168+
pub fn expected_leaf_key_data_hash(
169+
key_format: KeyFormat,
170+
key_data: &[u8],
171+
data_hash_algo: HashAlgo,
172+
) -> Result<[u8; pcf::HASH_FIELD_SIZE]> {
173+
let leaf_key_record = KeyRecord::new(key_format, key_data.to_vec())?.to_bytes();
174+
Ok(data_hash_algo.compute(&leaf_key_record))
175+
}
176+
177+
/// Convenience: SHA-256 fingerprint of raw key bytes (spec Section 6.3).
178+
/// Re-exported here so client code can build an [`EndorsementRequest`]
179+
/// without importing `key::compute_fingerprint` directly.
180+
pub fn fingerprint_of(key_data: &[u8]) -> [u8; FINGERPRINT_SIZE] {
181+
compute_fingerprint(key_data)
182+
}

reference/PCF-SIG-v1.0/src/lib.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
5050
mod algo;
5151
pub mod consts;
52+
mod endorse;
5253
mod error;
5354
mod key;
5455
mod manifest;
@@ -58,6 +59,10 @@ mod verify;
5859

5960
pub use algo::{KeyFormat, SigAlgo};
6061
pub use consts::*;
62+
pub use endorse::{
63+
embed_endorsement, expected_leaf_key_data_hash, fingerprint_of, issue_endorsement,
64+
EndorsementRequest, EndorsementResponse,
65+
};
6166
pub use error::{Error, Result};
6267
pub use key::{compute_fingerprint, KeyMetadata, KeyRecord};
6368
pub use manifest::{is_crypto_hash, Manifest, SignedEntry};
@@ -66,6 +71,6 @@ pub use sign::{
6671
ensure_key_partition, sign_partitions, signed_entry_from_partition, SigningMaterial,
6772
};
6873
pub use verify::{
69-
verify_all, verify_all_with_recheck, DataRecheck, EntryReport, EntryVerdict, ManifestVerdict,
70-
SignatureReport, UnverifiableReason,
74+
key_endorsements, verify_all, verify_all_with_recheck, DataRecheck, EntryReport, EntryVerdict,
75+
ManifestVerdict, SignatureReport, UnverifiableReason,
7176
};

reference/PCF-SIG-v1.0/src/verify.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,63 @@ pub enum DataRecheck {
9898
Recompute,
9999
}
100100

101+
/// Find every signer whose Valid signature in `reports` countersigns the
102+
/// PCFSIG_KEY partition whose fingerprint is `leaf_key_fingerprint` (spec
103+
/// Section 12.2). Returns the deduplicated `signer_key_fingerprint`s of those
104+
/// signers, in first-seen order. Self-endorsement (a signer endorsing its own
105+
/// key) is filtered out as semantically vacuous.
106+
///
107+
/// The container is consulted to locate the leaf PCFSIG_KEY partition by
108+
/// fingerprint; if no such partition exists in the file the result is empty.
109+
///
110+
/// The reports passed in MUST come from [`verify_all`] or
111+
/// [`verify_all_with_recheck`] on the same container; the function does not
112+
/// re-verify any signatures.
113+
pub fn key_endorsements<S: Read + Write + Seek>(
114+
container: &mut Container<S>,
115+
reports: &[SignatureReport],
116+
leaf_key_fingerprint: &[u8; FINGERPRINT_SIZE],
117+
) -> Result<Vec<[u8; FINGERPRINT_SIZE]>> {
118+
// 1. Locate the leaf PCFSIG_KEY partition's PCF uid by fingerprint.
119+
let entries = container.entries()?;
120+
let mut leaf_key_uid: Option<[u8; UID_SIZE]> = None;
121+
for e in &entries {
122+
if e.partition_type == TYPE_PCFSIG_KEY {
123+
let data = container.read_partition_data(e)?;
124+
if let Ok(rec) = KeyRecord::from_bytes(&data) {
125+
if &rec.fingerprint == leaf_key_fingerprint {
126+
leaf_key_uid = Some(e.uid);
127+
break;
128+
}
129+
}
130+
}
131+
}
132+
let leaf_key_uid = match leaf_key_uid {
133+
Some(u) => u,
134+
None => return Ok(Vec::new()),
135+
};
136+
137+
// 2. Scan reports for Valid signatures whose manifests cover that uid.
138+
let mut endorsers: Vec<[u8; FINGERPRINT_SIZE]> = Vec::new();
139+
for r in reports {
140+
if !matches!(r.verdict, ManifestVerdict::Valid) {
141+
continue;
142+
}
143+
if &r.signer_key_fingerprint == leaf_key_fingerprint {
144+
// Self-endorsement is semantically empty.
145+
continue;
146+
}
147+
let endorses = r
148+
.entries
149+
.iter()
150+
.any(|er| er.uid == leaf_key_uid && matches!(er.verdict, EntryVerdict::Valid));
151+
if endorses && !endorsers.contains(&r.signer_key_fingerprint) {
152+
endorsers.push(r.signer_key_fingerprint);
153+
}
154+
}
155+
Ok(endorsers)
156+
}
157+
101158
/// Verify every PCFSIG_SIG partition in `container` and return one report
102159
/// each. Returns an empty vector if the container has no signatures.
103160
pub fn verify_all<S: Read + Write + Seek>(

0 commit comments

Comments
 (0)