Skip to main content

bynk/registry/
protocol.rs

1//! The registry protocol's data (packaging slice 4a, #2274, decision D), as
2//! `bynk` uses it: the pure data and payloads live in `bynk-registry-core`
3//! (first-party registry R2, #2336, decision B), shared with the hosted
4//! registry's wasm module; this module adds ed25519 signing and verification
5//! over `ring` (already in the build through rustls), and [`ConfigTrust`], the
6//! signer walks with `ring` as their verifier.
7
8use bynk_project::lock::{base64_decode, base64_encode};
9use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
10
11pub use bynk_registry_core::protocol::{
12    Config, IndexLine, KeyEntry, PROTOCOL, PROTOCOL_DELEGATION, Requirement, Signers,
13    WITHDRAWN_MAX, canonical, delegation_payload, fingerprint, key_bytes, revocation_payload,
14    rotation_payload,
15};
16
17/// The signer walks of [`Config`], verifying with `ring`.
18pub trait ConfigTrust {
19    /// See [`Config::signers_with`].
20    fn signers(&self, chain: &[String]) -> Signers;
21    /// See [`Config::chain_from_with`].
22    fn chain_from(&self, trusted: &str) -> Option<Vec<String>>;
23    /// See [`Config::follow_chain_with`].
24    fn follow_chain(&self, trusted: &str) -> Option<&str>;
25}
26
27impl ConfigTrust for Config {
28    fn signers(&self, chain: &[String]) -> Signers {
29        self.signers_with(chain, &verify)
30    }
31
32    fn chain_from(&self, trusted: &str) -> Option<Vec<String>> {
33        self.chain_from_with(trusted, &verify)
34    }
35
36    fn follow_chain(&self, trusted: &str) -> Option<&str> {
37        self.follow_chain_with(trusted, &verify)
38    }
39}
40
41/// Whether `sig` (base64) is `key`'s signature over `payload`.
42pub fn verify(key: &str, payload: &[u8], sig: &str) -> bool {
43    let (Some(pk), Some(sig)) = (key_bytes(key), base64_decode(sig)) else {
44        return false;
45    };
46    UnparsedPublicKey::new(&ED25519, pk)
47        .verify(payload, &sig)
48        .is_ok()
49}
50
51/// A registry's signing key, from its PKCS#8 document.
52pub struct SigningKey(Ed25519KeyPair);
53
54impl SigningKey {
55    /// A new key, and its PKCS#8 document (what `bynk registry init` writes).
56    pub fn generate() -> Result<(SigningKey, Vec<u8>), String> {
57        let rng = ring::rand::SystemRandom::new();
58        let doc = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| "could not generate a key")?;
59        let pair =
60            Ed25519KeyPair::from_pkcs8(doc.as_ref()).map_err(|_| "could not read the key")?;
61        Ok((SigningKey(pair), doc.as_ref().to_vec()))
62    }
63
64    /// The key in a PKCS#8 document.
65    pub fn from_pkcs8(doc: &[u8]) -> Result<SigningKey, String> {
66        Ed25519KeyPair::from_pkcs8(doc)
67            .map(SigningKey)
68            .map_err(|_| "the key file isn't an ed25519 PKCS#8 key".to_string())
69    }
70
71    /// `ed25519:<base64>`.
72    pub fn public(&self) -> String {
73        format!("ed25519:{}", base64_encode(self.0.public_key().as_ref()))
74    }
75
76    /// The base64 signature over `payload`.
77    pub fn sign(&self, payload: &[u8]) -> String {
78        base64_encode(self.0.sign(payload).as_ref())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn line(name: &str) -> IndexLine {
87        IndexLine {
88            name: name.into(),
89            version: "1.4.2".into(),
90            checksum: "sha256-abc".into(),
91            deps: vec![Requirement {
92                package: "acme/fx".into(),
93                range: "^2".into(),
94            }],
95            npm: vec![],
96            toolchain: None,
97            yanked: false,
98            withdrawn: None,
99            sig: String::new(),
100        }
101    }
102
103    #[test]
104    fn a_signature_binds_the_name_and_the_registry() {
105        let (key, _) = SigningKey::generate().unwrap();
106        let mut l = line("acme/money");
107        l.sig = key.sign(&l.payload("acme"));
108        assert!(verify(&key.public(), &l.payload("acme"), &l.sig));
109        // Replayed from another registry, or under another name.
110        assert!(!verify(&key.public(), &l.payload("globex"), &l.sig));
111        let mut other = l.clone();
112        other.name = "acme/payments".into();
113        assert!(!verify(&key.public(), &other.payload("acme"), &l.sig));
114        // `yanked` isn't covered: flipping it keeps the signature.
115        let mut yanked = l.clone();
116        yanked.yanked = true;
117        assert!(verify(&key.public(), &yanked.payload("acme"), &l.sig));
118        // #2330: nor is `withdrawn`, which a line renders only when set.
119        let mut withdrawn = yanked.clone();
120        withdrawn.withdrawn = Some("a takedown".into());
121        assert!(verify(&key.public(), &withdrawn.payload("acme"), &l.sig));
122        assert!(!l.render().contains("withdrawn"));
123        assert!(withdrawn.render().contains(r#""withdrawn":"a takedown""#));
124        assert_eq!(IndexLine::parse(&withdrawn.render()).unwrap(), withdrawn);
125        // The line round-trips.
126        assert_eq!(IndexLine::parse(&l.render()).unwrap(), l);
127    }
128
129    #[test]
130    fn a_present_optional_field_must_be_a_string() {
131        use serde_json::json;
132        let base: serde_json::Value = serde_json::from_str(&line("acme/money").render()).unwrap();
133        let with = |k: &str, v: serde_json::Value| {
134            let mut b = base.clone();
135            b[k] = v;
136            IndexLine::parse(&b.to_string())
137        };
138        // `toolchain` is a string or `null`; `withdrawn` a string, or absent.
139        let ok = with("toolchain", json!(">=0.9")).unwrap();
140        assert_eq!(ok.toolchain.as_deref(), Some(">=0.9"));
141        assert_eq!(with("toolchain", json!(null)).unwrap().toolchain, None);
142        let ok = with("withdrawn", json!("a takedown")).unwrap();
143        assert_eq!(ok.withdrawn.as_deref(), Some("a takedown"));
144        let mut absent = base.clone();
145        absent.as_object_mut().unwrap().remove("toolchain");
146        assert_eq!(
147            IndexLine::parse(&absent.to_string()).unwrap().toolchain,
148            None
149        );
150        // A codec-tagged `Option` is refused, not read as absent.
151        let tagged = json!({"kind": "Some", "value": "a takedown"});
152        assert_eq!(
153            with("withdrawn", tagged.clone()).unwrap_err(),
154            "acme/money 1.4.2's `withdrawn` is an object, not a string"
155        );
156        assert_eq!(
157            with("toolchain", json!({"kind": "None"})).unwrap_err(),
158            "acme/money 1.4.2's `toolchain` is an object, not a string or `null`"
159        );
160        assert!(with("toolchain", tagged).is_err());
161        assert_eq!(
162            with("withdrawn", json!(null)).unwrap_err(),
163            "acme/money 1.4.2's `withdrawn` is `null`, not a string"
164        );
165        assert!(with("withdrawn", json!(true)).is_err());
166        assert!(with("toolchain", json!(1)).is_err());
167    }
168
169    #[test]
170    fn the_canonical_payload_is_sorted_and_compact() {
171        let p = String::from_utf8(line("acme/money").payload("acme")).unwrap();
172        assert_eq!(
173            p,
174            r#"{"checksum":"sha256-abc","deps":[{"package":"acme/fx","range":"^2"}],"name":"acme/money","npm":[],"registry":"acme","toolchain":null,"version":"1.4.2"}"#
175        );
176    }
177
178    #[test]
179    fn the_key_chain_is_followed_from_the_trusted_key() {
180        let (k1, _) = SigningKey::generate().unwrap();
181        let (k2, _) = SigningKey::generate().unwrap();
182        let (k3, _) = SigningKey::generate().unwrap();
183        let config = Config {
184            id: "acme".into(),
185            dl: "dl".into(),
186            api: None,
187            token_auth: false,
188            keys: vec![
189                KeyEntry {
190                    key: k1.public(),
191                    sig: None,
192                },
193                KeyEntry {
194                    key: k2.public(),
195                    sig: Some(k1.sign(&rotation_payload("acme", &k2.public()))),
196                },
197                KeyEntry {
198                    key: k3.public(),
199                    sig: Some(k2.sign(&rotation_payload("acme", &k3.public()))),
200                },
201            ],
202            delegations: vec![],
203            revoked: vec![],
204        };
205        let fp1 = fingerprint(&k1.public()).unwrap();
206        assert_eq!(config.follow_chain(&fp1), Some(k3.public().as_str()));
207        // A link signed by the wrong key breaks the chain.
208        let mut broken = config.clone();
209        broken.keys[2].sig = Some(k1.sign(&rotation_payload("acme", &k3.public())));
210        assert_eq!(broken.follow_chain(&fp1), None);
211        // A key not on the chain isn't followed.
212        let (stranger, _) = SigningKey::generate().unwrap();
213        assert_eq!(
214            config.follow_chain(&fingerprint(&stranger.public()).unwrap()),
215            None
216        );
217        assert_eq!(Config::parse(config.render().as_bytes()).unwrap(), config);
218    }
219
220    #[test]
221    fn delegates_sign_lines_until_revoked_and_only_chain_keys_grant_or_revoke() {
222        let (root, _) = SigningKey::generate().unwrap();
223        let (d1, _) = SigningKey::generate().unwrap();
224        let (d2, _) = SigningKey::generate().unwrap();
225        let entry = |k: &SigningKey, sig: String| KeyEntry {
226            key: k.public(),
227            sig: Some(sig),
228        };
229        let mut config = Config {
230            id: "bynk".into(),
231            dl: "dl".into(),
232            api: None,
233            token_auth: false,
234            keys: vec![KeyEntry {
235                key: root.public(),
236                sig: None,
237            }],
238            delegations: vec![
239                entry(&d1, root.sign(&delegation_payload("bynk", &d1.public()))),
240                // A delegate can't delegate.
241                entry(&d2, d1.sign(&delegation_payload("bynk", &d2.public()))),
242            ],
243            revoked: vec![],
244        };
245        let chain = vec![root.public()];
246        let s = config.signers(&chain);
247        assert_eq!(s.keys, vec![root.public(), d1.public()]);
248        assert_eq!(s.ignored.len(), 1, "{:?}", s.ignored);
249
250        // A delegate can't revoke; the root can.
251        config.revoked = vec![entry(
252            &d1,
253            d1.sign(&revocation_payload("bynk", &d1.public())),
254        )];
255        assert!(config.signers(&chain).keys.contains(&d1.public()));
256        config.revoked = vec![entry(
257            &d1,
258            root.sign(&revocation_payload("bynk", &d1.public())),
259        )];
260        let s = config.signers(&chain);
261        assert_eq!(s.keys, vec![root.public()]);
262        assert_eq!(s.revoked, vec![d1.public()]);
263
264        // A revocation naming a chain key is ignored: only delegates can be
265        // revoked.
266        let mut chain_revoked = config.clone();
267        chain_revoked.revoked = vec![entry(
268            &root,
269            root.sign(&revocation_payload("bynk", &root.public())),
270        )];
271        let s = chain_revoked.signers(&chain);
272        assert!(s.revoked.is_empty() && s.keys.contains(&root.public()));
273        assert!(
274            s.ignored.iter().any(|i| i.contains("only delegates")),
275            "{:?}",
276            s.ignored
277        );
278
279        // A delegation is bound to its registry: replayed under another id,
280        // it grants nothing.
281        let mut other = config.clone();
282        other.id = "acme".into();
283        other.revoked.clear();
284        assert_eq!(other.signers(&chain).keys, vec![root.public()]);
285
286        // Protocol 2 is written, and required, exactly when delegation data
287        // is present.
288        let text = config.render();
289        assert!(text.contains("\"protocol\": 2"), "{text}");
290        assert_eq!(Config::parse(text.as_bytes()).unwrap(), config);
291        let downgraded = text.replace("\"protocol\": 2", "\"protocol\": 1");
292        assert!(Config::parse(downgraded.as_bytes()).is_err());
293        config.delegations.clear();
294        config.revoked.clear();
295        assert!(config.render().contains("\"protocol\": 1"));
296    }
297}