Skip to main content

bynk_registry_core/
protocol.rs

1//! The registry protocol's data (packaging slice 4a, #2274, decision D; track
2//! `design/tracks/packaging.md` §6.1): a registry's `config.json`, its index
3//! lines, and the canonical payloads their signatures cover.
4//!
5//! I/O- and crypto-free (first-party registry R2, #2336, decision B), so the
6//! client, the reference server and the hosted registry's wasm module build
7//! the very same bytes. Signature *checks* take a `verify` function: `bynk`
8//! passes one over `ring`, and the hosted registry verifies with `bynk.crypto`.
9//! A key is written `ed25519:<base64 of the 32-byte public key>`, and its
10//! fingerprint `ed25519-fp:<base64 of sha256(public key)>`.
11
12use std::collections::BTreeMap;
13
14use bynk_project::lock::{base64_decode, base64_encode};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17
18/// The protocol version a registry without delegation speaks.
19pub const PROTOCOL: u64 = 1;
20
21/// #2316 (D): the protocol version a `config.json` with `delegations` or
22/// `revoked` must declare; this toolchain reads both.
23pub const PROTOCOL_DELEGATION: u64 = 2;
24
25/// A registry's `config.json`.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Config {
28    /// The registry's stable identity, which its signed lines bind.
29    pub id: String,
30    /// The archive base: absolute, or relative to the index.
31    pub dl: String,
32    /// #2287: the publish API's base, absolute or relative to the index; a
33    /// registry without one takes no publishes over HTTP.
34    pub api: Option<String>,
35    /// Whether requests carry the registry's token.
36    pub token_auth: bool,
37    /// The key-rotation chain, oldest first.
38    pub keys: Vec<KeyEntry>,
39    /// #2316: keys a chain key delegated to sign index lines (and nothing
40    /// else), each `sig` a chain key's signature over [`delegation_payload`].
41    pub delegations: Vec<KeyEntry>,
42    /// #2316: delegates a chain key revoked, each `sig` a chain key's
43    /// signature over [`revocation_payload`].
44    pub revoked: Vec<KeyEntry>,
45}
46
47/// One link of the key-rotation chain: a key, and (after the first) the
48/// previous key's signature over `{ "key": …, "registry": id }`.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct KeyEntry {
51    pub key: String,
52    pub sig: Option<String>,
53}
54
55impl Config {
56    /// Parse `config.json`.
57    pub fn parse(bytes: &[u8]) -> Result<Config, String> {
58        let v: Value =
59            serde_json::from_slice(bytes).map_err(|e| format!("`config.json` isn't JSON: {e}"))?;
60        let protocol = v.get("protocol").and_then(Value::as_u64);
61        if !matches!(protocol, Some(PROTOCOL | PROTOCOL_DELEGATION)) {
62            return Err(format!(
63                "`config.json` speaks protocol {}, and this toolchain speaks {PROTOCOL} and {PROTOCOL_DELEGATION}",
64                protocol.map_or("(none)".to_string(), |p| p.to_string())
65            ));
66        }
67        let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
68        let id = text("id").ok_or("`config.json` has no `id`")?;
69        let dl = text("dl").unwrap_or_else(|| "dl".to_string());
70        let api = text("api");
71        let token_auth = match text("auth").as_deref() {
72            None | Some("none") => false,
73            Some("token") => true,
74            Some(other) => return Err(format!("`config.json`'s `auth` is `{other}`")),
75        };
76        let entries = |field: &str, required: bool| -> Result<Vec<KeyEntry>, String> {
77            let Some(list) = v.get(field) else {
78                return if required {
79                    Err(format!("`config.json` has no `{field}`"))
80                } else {
81                    Ok(Vec::new())
82                };
83            };
84            list.as_array()
85                .ok_or(format!("`config.json`'s `{field}` isn't a list"))?
86                .iter()
87                .map(|k| {
88                    Some(KeyEntry {
89                        key: k.get("key")?.as_str()?.to_owned(),
90                        sig: k.get("sig").and_then(Value::as_str).map(str::to_owned),
91                    })
92                })
93                .collect::<Option<Vec<_>>>()
94                .ok_or(format!("a `config.json` `{field}` entry has no `key`"))
95        };
96        let keys = entries("keys", true)?;
97        if keys.is_empty() {
98            return Err("`config.json` lists no key".to_string());
99        }
100        let delegations = entries("delegations", false)?;
101        let revoked = entries("revoked", false)?;
102        if (!delegations.is_empty() || !revoked.is_empty()) && protocol != Some(PROTOCOL_DELEGATION)
103        {
104            return Err(format!(
105                "`config.json` lists `delegations` or `revoked`, which need `\"protocol\": {PROTOCOL_DELEGATION}`"
106            ));
107        }
108        Ok(Config {
109            id,
110            dl,
111            api,
112            token_auth,
113            keys,
114            delegations,
115            revoked,
116        })
117    }
118
119    /// `config.json`'s text.
120    pub fn render(&self) -> String {
121        let list = |entries: &[KeyEntry]| -> Vec<Value> {
122            entries
123                .iter()
124                .map(|k| match &k.sig {
125                    Some(sig) => json!({ "key": k.key, "sig": sig }),
126                    None => json!({ "key": k.key }),
127                })
128                .collect()
129        };
130        let keys = list(&self.keys);
131        let delegating = !self.delegations.is_empty() || !self.revoked.is_empty();
132        let mut v = json!({
133            "protocol": if delegating { PROTOCOL_DELEGATION } else { PROTOCOL },
134            "id": self.id,
135            "dl": self.dl,
136            "auth": if self.token_auth { "token" } else { "none" },
137            "keys": keys,
138        });
139        if let Some(api) = &self.api {
140            v["api"] = json!(api);
141        }
142        if !self.delegations.is_empty() {
143            v["delegations"] = json!(list(&self.delegations));
144        }
145        if !self.revoked.is_empty() {
146            v["revoked"] = json!(list(&self.revoked));
147        }
148        serde_json::to_string_pretty(&v).expect("JSON") + "\n"
149    }
150
151    /// #2316 (B): the keys that may sign an index line, given the chain
152    /// keys a client trusts (`chain`, from [`Config::follow_chain_with`]'s walk):
153    /// those chain keys, plus every delegate whose delegation a chain key
154    /// signed and no chain-key-signed revocation names. `revoked` lists the
155    /// delegates a valid revocation names; `ignored`, the delegations and
156    /// revocations whose own signature doesn't verify (each is ignored).
157    pub fn signers_with(&self, chain: &[String], verify: Verify<'_>) -> Signers {
158        let by_chain = |payload: &[u8], sig: Option<&str>| {
159            sig.is_some_and(|sig| chain.iter().any(|k| verify(k, payload, sig)))
160        };
161        let mut out = Signers {
162            keys: chain.to_vec(),
163            ..Signers::default()
164        };
165        for r in &self.revoked {
166            if self.keys.iter().any(|k| k.key == r.key) {
167                out.ignored.push(format!(
168                    "a revocation of the chain key {} (only delegates can be revoked)",
169                    r.key
170                ));
171            } else if by_chain(&revocation_payload(&self.id, &r.key), r.sig.as_deref()) {
172                out.revoked.push(r.key.clone());
173            } else {
174                out.ignored.push(format!(
175                    "a revocation of {} that no trusted key signed",
176                    r.key
177                ));
178            }
179        }
180        for d in &self.delegations {
181            if !by_chain(&delegation_payload(&self.id, &d.key), d.sig.as_deref()) {
182                out.ignored.push(format!(
183                    "a delegation to {} that no trusted key signed",
184                    d.key
185                ));
186            } else if !out.revoked.contains(&d.key) && !out.keys.contains(&d.key) {
187                out.keys.push(d.key.clone());
188            }
189        }
190        out
191    }
192
193    /// The chain keys reached from `trusted` (a fingerprint), oldest first:
194    /// the set a client accepts, and the set that may delegate and revoke.
195    pub fn chain_from_with(&self, trusted: &str, verify: Verify<'_>) -> Option<Vec<String>> {
196        self.follow_chain_with(trusted, verify)?;
197        let start = self
198            .keys
199            .iter()
200            .position(|k| fingerprint(&k.key).as_deref() == Some(trusted))?;
201        Some(self.keys[start..].iter().map(|k| k.key.clone()).collect())
202    }
203
204    /// The newest key the chain reaches from `trusted` (a key fingerprint),
205    /// verifying each link; `None` when `trusted` isn't on the chain or a
206    /// link doesn't verify (`bynk.registry.key_mismatch`).
207    pub fn follow_chain_with(&self, trusted: &str, verify: Verify<'_>) -> Option<&str> {
208        let start = self
209            .keys
210            .iter()
211            .position(|k| fingerprint(&k.key).as_deref() == Some(trusted))?;
212        for i in start + 1..self.keys.len() {
213            let prev = &self.keys[i - 1].key;
214            let link = &self.keys[i];
215            let sig = link.sig.as_deref()?;
216            if !verify(prev, &rotation_payload(&self.id, &link.key), sig) {
217                return None;
218            }
219        }
220        Some(self.keys.last()?.key.as_str())
221    }
222}
223
224/// The payload a rotation link's signature covers.
225pub fn rotation_payload(registry: &str, key: &str) -> Vec<u8> {
226    canonical(&json!({ "key": key, "registry": registry }))
227}
228
229/// #2316: the payload a delegation's signature covers.
230pub fn delegation_payload(registry: &str, key: &str) -> Vec<u8> {
231    canonical(&json!({ "delegate": key, "registry": registry }))
232}
233
234/// #2316: the payload a revocation's signature covers.
235pub fn revocation_payload(registry: &str, key: &str) -> Vec<u8> {
236    canonical(&json!({ "registry": registry, "revoke": key }))
237}
238
239/// #2316: who may sign a registry's index lines.
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct Signers {
242    /// The trusted chain keys, then the valid, unrevoked delegates.
243    pub keys: Vec<String>,
244    /// Delegates a valid revocation names.
245    pub revoked: Vec<String>,
246    /// Delegations and revocations ignored because no trusted key signed them.
247    pub ignored: Vec<String>,
248}
249
250/// A dependency or npm requirement in an index line.
251pub use crate::admit::Requirement;
252
253/// Whether `sig` (base64) is `key`'s signature over `payload`: the check the
254/// signer walks below are given.
255pub type Verify<'v> = &'v dyn Fn(&str, &[u8], &str) -> bool;
256
257/// One version line of a package's index file.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct IndexLine {
260    pub name: String,
261    pub version: String,
262    /// The canonical-tree checksum (`sha256-<base64>`), as the store names it.
263    pub checksum: String,
264    pub deps: Vec<Requirement>,
265    pub npm: Vec<Requirement>,
266    /// The minimum `bynk` this version needs, when it says.
267    pub toolchain: Option<String>,
268    pub yanked: bool,
269    /// #2330 (A): why the registry withdrew this version — a takedown, after
270    /// which its archive is gone. Unsigned, like `yanked`, and a withdrawn
271    /// line is also yanked; an older client ignores the field.
272    pub withdrawn: Option<String>,
273    pub sig: String,
274}
275
276/// #2330 (A): the longest `withdrawn` reason, in characters.
277pub const WITHDRAWN_MAX: usize = 500;
278
279impl IndexLine {
280    /// The canonical payload the signature covers: every field but `sig`,
281    /// `yanked` and `withdrawn`, plus the origin registry's `id` — so a line can't be
282    /// replayed under another name or from another registry.
283    pub fn payload(&self, registry: &str) -> Vec<u8> {
284        let reqs = |rs: &[Requirement]| -> Value {
285            Value::Array(
286                rs.iter()
287                    .map(|r| json!({ "package": r.package, "range": r.range }))
288                    .collect(),
289            )
290        };
291        let mut obj = BTreeMap::new();
292        obj.insert("checksum", json!(self.checksum));
293        obj.insert("deps", reqs(&self.deps));
294        obj.insert("name", json!(self.name));
295        obj.insert("npm", reqs(&self.npm));
296        obj.insert("registry", json!(registry));
297        obj.insert("toolchain", json!(self.toolchain));
298        obj.insert("version", json!(self.version));
299        canonical(&serde_json::to_value(obj).expect("JSON"))
300    }
301
302    /// The line as one line of JSON.
303    pub fn render(&self) -> String {
304        let reqs = |rs: &[Requirement]| -> Vec<Value> {
305            rs.iter()
306                .map(|r| json!({ "package": r.package, "range": r.range }))
307                .collect()
308        };
309        let mut v = json!({
310            "name": self.name,
311            "version": self.version,
312            "checksum": self.checksum,
313            "deps": reqs(&self.deps),
314            "npm": reqs(&self.npm),
315            "toolchain": self.toolchain,
316            "yanked": self.yanked,
317            "sig": self.sig,
318        });
319        if let Some(reason) = &self.withdrawn {
320            v["withdrawn"] = json!(reason);
321        }
322        serde_json::to_string(&v).expect("JSON")
323    }
324
325    /// Parse one index line.
326    pub fn parse(line: &str) -> Result<IndexLine, String> {
327        let v: Value =
328            serde_json::from_str(line).map_err(|e| format!("an index line isn't JSON: {e}"))?;
329        let text = |k: &str| {
330            v.get(k)
331                .and_then(Value::as_str)
332                .map(str::to_owned)
333                .ok_or(format!("an index line has no `{k}`"))
334        };
335        let reqs = |k: &str| -> Result<Vec<Requirement>, String> {
336            match v.get(k) {
337                None | Some(Value::Null) => Ok(Vec::new()),
338                Some(Value::Array(items)) => items
339                    .iter()
340                    .map(|r| {
341                        Some(Requirement {
342                            package: r.get("package")?.as_str()?.to_owned(),
343                            range: r.get("range")?.as_str()?.to_owned(),
344                        })
345                    })
346                    .collect::<Option<Vec<_>>>()
347                    .ok_or(format!("an index line's `{k}` is malformed")),
348                Some(_) => Err(format!("an index line's `{k}` isn't a list")),
349            }
350        };
351        let name = text("name")?;
352        let version = text("version")?;
353        // An optional field that is present must be a string (`toolchain` may
354        // also be `null`): anything else, such as a codec's tagged
355        // `{"kind": "Some", …}`, is refused rather than read as absent — a
356        // misread `withdrawn` would leave a takedown looking live.
357        let optional = |k: &str, null: bool| -> Result<Option<String>, String> {
358            match v.get(k) {
359                None => Ok(None),
360                Some(Value::Null) if null => Ok(None),
361                Some(Value::String(s)) => Ok(Some(s.clone())),
362                Some(other) => Err(format!(
363                    "{name} {version}'s `{k}` is {}, not a string{}",
364                    json_kind(other),
365                    if null { " or `null`" } else { "" }
366                )),
367            }
368        };
369        Ok(IndexLine {
370            checksum: text("checksum")?,
371            deps: reqs("deps")?,
372            npm: reqs("npm")?,
373            toolchain: optional("toolchain", true)?,
374            yanked: v.get("yanked").and_then(Value::as_bool).unwrap_or(false),
375            withdrawn: optional("withdrawn", false)?,
376            sig: text("sig")?,
377            name,
378            version,
379        })
380    }
381}
382
383/// What kind of JSON value `v` is, for an error.
384fn json_kind(v: &Value) -> &'static str {
385    match v {
386        Value::Null => "`null`",
387        Value::Bool(_) => "a boolean",
388        Value::Number(_) => "a number",
389        Value::String(_) => "a string",
390        Value::Array(_) => "a list",
391        Value::Object(_) => "an object",
392    }
393}
394
395/// `value` as canonical JSON: object keys in byte-wise order (serde_json's
396/// map is ordered unless `preserve_order` is on, so keys are re-sorted here
397/// regardless), no insignificant whitespace.
398pub fn canonical(value: &Value) -> Vec<u8> {
399    fn sort(v: &Value) -> Value {
400        match v {
401            Value::Object(map) => {
402                let sorted: BTreeMap<&String, Value> =
403                    map.iter().map(|(k, v)| (k, sort(v))).collect();
404                let mut out = serde_json::Map::new();
405                for (k, v) in sorted {
406                    out.insert(k.clone(), v);
407                }
408                Value::Object(out)
409            }
410            Value::Array(items) => Value::Array(items.iter().map(sort).collect()),
411            other => other.clone(),
412        }
413    }
414    serde_json::to_vec(&sort(value)).expect("JSON")
415}
416
417/// The raw public key of `ed25519:<base64>`.
418pub fn key_bytes(key: &str) -> Option<Vec<u8>> {
419    let bytes = base64_decode(key.strip_prefix("ed25519:")?)?;
420    (bytes.len() == 32).then_some(bytes)
421}
422
423/// `ed25519-fp:<base64 of sha256(public key)>`.
424pub fn fingerprint(key: &str) -> Option<String> {
425    let bytes = key_bytes(key)?;
426    let digest = Sha256::digest(&bytes);
427    Some(format!("ed25519-fp:{}", base64_encode(&digest)))
428}