Skip to main content

lingxia_update/
signing.rs

1//! Update signing: sign opaque manifest bytes, verify then parse.
2//!
3//! The manifest carries its own authenticated `v`; the transport carries its
4//! version in the endpoint path. Neither belongs in an unsigned envelope field.
5
6use crate::UpdatePackageInfo;
7use crate::error::UpdateError;
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
11use lingxia_app_context::{AppEnv, env};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::fs;
15use std::path::Path;
16
17const MAX_SIGNED_BYTES: usize = 8 * 1024;
18const MAX_SIGNATURES: usize = 2;
19const MAX_PUBLIC_KEYS: usize = 2;
20
21/// Opaque envelope carried on check-update. Providers must not reserialize `signed`.
22///
23/// Carries no scheme identifier. The transport is versioned by its endpoint
24/// path and the manifest by the authenticated `v` inside `signed`; a third
25/// identifier, sitting *outside* the signature where a caller controls it,
26/// would only invite a client to pick its verification by what it was handed.
27#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct UpdateAuthentication {
30    pub signed: String,
31    pub signatures: Vec<String>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct SignRequest<'a> {
36    pub kind: &'a str,
37    pub target_id: &'a str,
38    /// Empty for host packages; the publish channel for lxapps/plugins.
39    pub channel: &'a str,
40    pub platform: &'a str,
41    pub version: &'a str,
42    pub sha256: &'a str,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct UpdateVerifyTarget {
47    pub kind: String,
48    pub target_id: String,
49    pub channel: String,
50    pub platform: String,
51    pub exact_version: Option<String>,
52}
53
54#[derive(Serialize, Deserialize)]
55struct ManifestWire {
56    v: u32,
57    kind: String,
58    #[serde(rename = "targetId")]
59    target_id: String,
60    channel: String,
61    platform: String,
62    version: String,
63    sha256: String,
64}
65
66pub fn archive_sha256_hex(data: &[u8]) -> String {
67    let digest = Sha256::digest(data);
68    let mut out = String::with_capacity(digest.len() * 2);
69    const HEX: &[u8; 16] = b"0123456789abcdef";
70    for byte in digest {
71        out.push(HEX[(byte >> 4) as usize] as char);
72        out.push(HEX[(byte & 0x0f) as usize] as char);
73    }
74    out
75}
76
77pub fn encode_base64url(bytes: &[u8]) -> String {
78    URL_SAFE_NO_PAD.encode(bytes)
79}
80
81pub fn decode_base64url(value: &str) -> Result<Vec<u8>, UpdateError> {
82    URL_SAFE_NO_PAD
83        .decode(value.trim().as_bytes())
84        .map_err(|e| UpdateError::invalid_parameter(format!("invalid base64url: {e}")))
85}
86
87pub fn compact_manifest(req: &SignRequest<'_>) -> Result<Vec<u8>, UpdateError> {
88    let wire = ManifestWire {
89        v: 1,
90        kind: req.kind.to_string(),
91        target_id: req.target_id.to_string(),
92        channel: req.channel.to_string(),
93        platform: req.platform.to_string(),
94        version: req.version.to_string(),
95        sha256: req.sha256.to_string(),
96    };
97    serde_json::to_vec(&wire)
98        .map_err(|e| UpdateError::runtime(format!("encode update manifest: {e}")))
99}
100
101pub fn public_key_base64url(seed: &[u8; 32]) -> String {
102    encode_base64url(SigningKey::from_bytes(seed).verifying_key().as_bytes())
103}
104
105pub fn load_signing_seed_file(path: &Path) -> Result<[u8; 32], UpdateError> {
106    #[cfg(unix)]
107    {
108        use std::os::unix::fs::PermissionsExt;
109        let mode = fs::metadata(path)
110            .map_err(|e| UpdateError::io(format!("read signing key {}: {e}", path.display())))?
111            .permissions()
112            .mode()
113            & 0o777;
114        if mode & 0o077 != 0 {
115            return Err(UpdateError::invalid_parameter(format!(
116                "signing key {} must not be group/other-accessible",
117                path.display()
118            )));
119        }
120    }
121    let text = fs::read_to_string(path)
122        .map_err(|e| UpdateError::io(format!("read signing key {}: {e}", path.display())))?;
123    let bytes = decode_base64url(text.trim())?;
124    bytes
125        .try_into()
126        .map_err(|_| UpdateError::invalid_parameter("signing key must be a 32-byte Ed25519 seed"))
127}
128
129pub fn sign_package(
130    seed: &[u8; 32],
131    req: &SignRequest<'_>,
132) -> Result<UpdateAuthentication, UpdateError> {
133    let manifest = compact_manifest(req)?;
134    let signature = SigningKey::from_bytes(seed).sign(&manifest).to_bytes();
135    Ok(UpdateAuthentication {
136        signed: encode_base64url(&manifest),
137        signatures: vec![encode_base64url(&signature)],
138    })
139}
140
141pub fn env_requires_signature(env: AppEnv) -> bool {
142    env == AppEnv::Prod
143}
144
145/// Whether *this build* accepts an unsigned update, whatever channel is asked
146/// for. Callers cannot pass a channel: that is the whole point — see
147/// [`verify_checked_update`].
148pub fn host_requires_signature() -> bool {
149    env_requires_signature(env())
150}
151
152/// Prod only queries check-update with embedded public keys.
153/// Dev always queries; without keys it does not verify.
154pub fn check_update_enabled(trusted_public_keys: &[String]) -> bool {
155    !host_requires_signature() || !trusted_public_keys.is_empty()
156}
157
158pub fn sign_package_from_key_file(
159    env: AppEnv,
160    key_file: Option<&Path>,
161    req: &SignRequest<'_>,
162) -> Result<Option<UpdateAuthentication>, UpdateError> {
163    let key_file = key_file.filter(|path| !path.as_os_str().is_empty());
164    match (env_requires_signature(env), key_file) {
165        (true, None) => Err(UpdateError::invalid_parameter(format!(
166            "{env} publish requires --update-signing-key-file"
167        ))),
168        (false, None) => Ok(None),
169        (_, Some(path)) => {
170            let seed = load_signing_seed_file(path)?;
171            sign_package(&seed, req).map(Some)
172        }
173    }
174}
175
176pub fn verify_checked_update(
177    mut package: UpdatePackageInfo,
178    target: &UpdateVerifyTarget,
179    trusted_public_keys: &[String],
180) -> Result<UpdatePackageInfo, UpdateError> {
181    // Whether a signature may be waived is a property of *this build*, never of
182    // the request. An App Link query or `lx.navigateToApp({channel})` picks
183    // the channel an lxapp is fetched on, so keying the waiver on
184    // `target.channel` let anyone who can hand a prod device a link ask for
185    // the draft channel and be served an unsigned package.
186    //
187    // `target.channel` still binds the manifest below: a prod host may open
188    // a draft-channel lxapp, but only one a trusted key signed for that
189    // channel.
190    let signature_required = host_requires_signature();
191    if trusted_public_keys.is_empty() && !signature_required {
192        return Ok(package);
193    }
194
195    let Some(auth) = package.authentication.as_ref() else {
196        if signature_required {
197            return Err(UpdateError::invalid_parameter(format!(
198                "{} builds require signed updates ({} channel package is unsigned)",
199                env(),
200                target.channel
201            )));
202        }
203        return Ok(package);
204    };
205
206    if auth.signatures.is_empty() || auth.signatures.len() > MAX_SIGNATURES {
207        return Err(UpdateError::invalid_parameter(
208            "authentication must include 1 or 2 signatures",
209        ));
210    }
211    if trusted_public_keys.is_empty() {
212        return Err(UpdateError::invalid_parameter(
213            "no trusted update public keys are embedded in this build",
214        ));
215    }
216    if trusted_public_keys.len() > MAX_PUBLIC_KEYS {
217        return Err(UpdateError::invalid_parameter(
218            "at most two trusted update public keys are allowed",
219        ));
220    }
221
222    let signed = decode_base64url(&auth.signed)?;
223    if signed.len() > MAX_SIGNED_BYTES {
224        return Err(UpdateError::invalid_parameter(
225            "signed manifest is too large",
226        ));
227    }
228    let keys = decode_public_keys(trusted_public_keys)?;
229    let mut accepted = false;
230    for signature_b64 in &auth.signatures {
231        let signature = decode_signature(signature_b64)?;
232        if keys
233            .iter()
234            .any(|key| key.verify_strict(&signed, &signature).is_ok())
235        {
236            accepted = true;
237            break;
238        }
239    }
240    if !accepted {
241        return Err(UpdateError::invalid_parameter(
242            "update authentication signature is invalid",
243        ));
244    }
245
246    let manifest: ManifestWire = serde_json::from_slice(&signed)
247        .map_err(|e| UpdateError::invalid_parameter(format!("signed manifest is not JSON: {e}")))?;
248    bind_manifest(&manifest, target)?;
249
250    package.version = manifest.version;
251    package.checksum_sha256 = manifest.sha256;
252    Ok(package)
253}
254
255pub fn verify_archive_bytes(data: &[u8], expected_sha256: &str) -> Result<(), UpdateError> {
256    let actual = archive_sha256_hex(data);
257    if actual != expected_sha256 {
258        return Err(UpdateError::invalid_parameter(format!(
259            "archive sha256 mismatch: expected {expected_sha256}, got {actual}"
260        )));
261    }
262    Ok(())
263}
264
265pub fn embedded_update_public_keys() -> Vec<String> {
266    lingxia_app_context::app_config()
267        .map(|config| config.update_trusted_public_keys.clone())
268        .unwrap_or_default()
269}
270
271pub fn host_update_platform() -> &'static str {
272    if cfg!(target_os = "android") {
273        "android"
274    } else if cfg!(target_os = "macos") {
275        "macos"
276    } else if cfg!(target_os = "windows") {
277        "windows"
278    } else if cfg!(target_os = "ios") {
279        "ios"
280    } else if cfg!(all(target_os = "linux", target_env = "ohos")) {
281        "harmony"
282    } else {
283        "any"
284    }
285}
286
287fn bind_manifest(manifest: &ManifestWire, target: &UpdateVerifyTarget) -> Result<(), UpdateError> {
288    if manifest.v != 1 {
289        return Err(UpdateError::invalid_parameter(
290            "signed manifest v must be 1",
291        ));
292    }
293    if manifest.kind != target.kind {
294        return Err(UpdateError::invalid_parameter(
295            "signed kind does not match target",
296        ));
297    }
298    if manifest.target_id != target.target_id {
299        return Err(UpdateError::invalid_parameter(
300            "signed targetId does not match target",
301        ));
302    }
303    if manifest.channel != target.channel {
304        return Err(UpdateError::invalid_parameter(
305            "signed channel does not match target",
306        ));
307    }
308    if manifest.platform != target.platform {
309        return Err(UpdateError::invalid_parameter(
310            "signed platform does not match target",
311        ));
312    }
313    if let Some(expected) = target.exact_version.as_deref()
314        && manifest.version != expected
315    {
316        return Err(UpdateError::invalid_parameter(
317            "signed version does not match requested targetVersion",
318        ));
319    }
320    Ok(())
321}
322
323fn decode_public_keys(keys: &[String]) -> Result<Vec<VerifyingKey>, UpdateError> {
324    keys.iter()
325        .map(|key| {
326            let bytes = decode_fixed(key, 32, "public key")?;
327            let raw: [u8; 32] = bytes
328                .try_into()
329                .map_err(|_| UpdateError::invalid_parameter("public key must be 32 bytes"))?;
330            VerifyingKey::from_bytes(&raw).map_err(|e| {
331                UpdateError::invalid_parameter(format!("invalid Ed25519 public key: {e}"))
332            })
333        })
334        .collect()
335}
336
337fn decode_signature(value: &str) -> Result<Signature, UpdateError> {
338    let bytes = decode_fixed(value, 64, "signature")?;
339    let raw: [u8; 64] = bytes
340        .try_into()
341        .map_err(|_| UpdateError::invalid_parameter("signature must be 64 bytes"))?;
342    Ok(Signature::from_bytes(&raw))
343}
344
345fn decode_fixed(value: &str, expected_len: usize, label: &str) -> Result<Vec<u8>, UpdateError> {
346    let bytes = decode_base64url(value)?;
347    if bytes.len() != expected_len {
348        return Err(UpdateError::invalid_parameter(format!(
349            "{label} must be {expected_len} bytes"
350        )));
351    }
352    Ok(bytes)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    const SEED: [u8; 32] = [7u8; 32];
360    const ARCHIVE: &[u8] = b"lingxia-update-golden-archive";
361
362    fn request(sha256: &str) -> SignRequest<'_> {
363        SignRequest {
364            kind: "lxapp",
365            target_id: "shop",
366            channel: "release",
367            platform: "any",
368            version: "1.2.3",
369            sha256,
370        }
371    }
372
373    fn target() -> UpdateVerifyTarget {
374        UpdateVerifyTarget {
375            kind: "lxapp".into(),
376            target_id: "shop".into(),
377            channel: "release".into(),
378            platform: "any".into(),
379            exact_version: None,
380        }
381    }
382
383    fn package(auth: Option<UpdateAuthentication>, sha256: &str, size: u64) -> UpdatePackageInfo {
384        UpdatePackageInfo {
385            version: "1.2.3".into(),
386            url: "https://cdn.example.com/pkg".into(),
387            checksum_sha256: sha256.into(),
388            size: Some(size),
389            release_notes: None,
390            min_runtime: None,
391            authentication: auth,
392        }
393    }
394
395    #[test]
396    fn matching_key_accepts_signed_artifact() {
397        let sha256 = archive_sha256_hex(ARCHIVE);
398        let size = ARCHIVE.len() as u64;
399        let auth = sign_package(&SEED, &request(&sha256)).unwrap();
400        let keys = [public_key_base64url(&SEED)];
401        let verified =
402            verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).unwrap();
403        assert_eq!(verified.checksum_sha256, sha256);
404        verify_archive_bytes(ARCHIVE, &verified.checksum_sha256).unwrap();
405    }
406
407    #[test]
408    fn test_seed_public_key_is_stable() {
409        assert_eq!(
410            public_key_base64url(&SEED),
411            "6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw"
412        );
413    }
414
415    #[test]
416    fn flipped_archive_byte_rejects() {
417        let sha256 = archive_sha256_hex(ARCHIVE);
418        let size = ARCHIVE.len() as u64;
419        let auth = sign_package(&SEED, &request(&sha256)).unwrap();
420        let keys = [public_key_base64url(&SEED)];
421        let verified =
422            verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).unwrap();
423        let mut tampered = ARCHIVE.to_vec();
424        tampered[0] ^= 0xff;
425        assert!(verify_archive_bytes(&tampered, &verified.checksum_sha256).is_err());
426    }
427
428    #[test]
429    fn flipped_signed_json_field_rejects() {
430        let sha256 = archive_sha256_hex(ARCHIVE);
431        let size = ARCHIVE.len() as u64;
432        let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
433        let original = decode_base64url(&auth.signed).unwrap();
434        let mut value: serde_json::Value = serde_json::from_slice(&original).unwrap();
435        value["version"] = serde_json::json!("9.9.9");
436        auth.signed = encode_base64url(&serde_json::to_vec(&value).unwrap());
437        let keys = [public_key_base64url(&SEED)];
438        assert!(
439            verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).is_err()
440        );
441    }
442
443    #[test]
444    fn flipped_signature_rejects() {
445        let sha256 = archive_sha256_hex(ARCHIVE);
446        let size = ARCHIVE.len() as u64;
447        let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
448        let mut sig = decode_base64url(&auth.signatures[0]).unwrap();
449        sig[0] ^= 0xff;
450        auth.signatures[0] = encode_base64url(&sig);
451        let keys = [public_key_base64url(&SEED)];
452        assert!(
453            verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).is_err()
454        );
455    }
456
457    #[test]
458    fn release_unsigned_rejects() {
459        let sha256 = archive_sha256_hex(ARCHIVE);
460        let size = ARCHIVE.len() as u64;
461        let err = verify_checked_update(package(None, &sha256, size), &target(), &[]).unwrap_err();
462        assert!(err.to_string().contains("require signed updates"), "{err}");
463    }
464
465    #[test]
466    fn publishing_requires_a_signature_in_prod() {
467        assert!(!env_requires_signature(AppEnv::Dev));
468        assert!(env_requires_signature(AppEnv::Prod));
469    }
470
471    #[test]
472    fn check_update_enabled_follows_this_build_and_its_keys() {
473        // Tests run with no `app.json`, so the host env defaults to
474        // prod — the safe default, and the one that makes the assertions
475        // below meaningful.
476        assert!(host_requires_signature());
477        assert!(!check_update_enabled(&[]));
478        assert!(check_update_enabled(&[public_key_base64url(&SEED)]));
479    }
480
481    #[test]
482    fn asking_for_the_draft_channel_does_not_waive_a_prod_build() {
483        // The attack this closes: an App Link query or
484        // `lx.navigateToApp({channel:'draft'})` picks the channel an
485        // lxapp is fetched on. Keying the waiver on that let anyone who could
486        // hand a prod device a link be served an unsigned package.
487        let sha256 = archive_sha256_hex(ARCHIVE);
488        let size = ARCHIVE.len() as u64;
489        let mut dev = target();
490        dev.channel = "draft".into();
491
492        let err = verify_checked_update(package(None, &sha256, size), &dev, &[]).unwrap_err();
493        assert!(err.to_string().contains("require signed updates"), "{err}");
494
495        // Nor with keys embedded: an unsigned package is still refused.
496        let keys = [public_key_base64url(&SEED)];
497        assert!(verify_checked_update(package(None, &sha256, size), &dev, &keys).is_err());
498    }
499
500    #[test]
501    fn a_prod_build_opens_a_draft_lxapp_only_when_it_is_signed_for_it() {
502        // The channel still binds the manifest, so a draft-channel package
503        // is openable on a prod host — but only one a trusted key signed
504        // for the draft channel.
505        let sha256 = archive_sha256_hex(ARCHIVE);
506        let size = ARCHIVE.len() as u64;
507        let mut dev = target();
508        dev.channel = "draft".into();
509        let mut req = request(&sha256);
510        req.channel = "draft";
511        let auth = sign_package(&SEED, &req).unwrap();
512        let keys = [public_key_base64url(&SEED)];
513        verify_checked_update(package(Some(auth), &sha256, size), &dev, &keys).unwrap();
514
515        // A package signed for release does not satisfy a draft request.
516        let release_auth = sign_package(&SEED, &request(&sha256)).unwrap();
517        assert!(
518            verify_checked_update(package(Some(release_auth), &sha256, size), &dev, &keys).is_err()
519        );
520    }
521
522    #[test]
523    fn dev_env_allows_unsigned_updates() {
524        assert!(!env_requires_signature(AppEnv::Dev));
525    }
526
527    #[test]
528    fn draft_invalid_envelope_rejects() {
529        let sha256 = archive_sha256_hex(ARCHIVE);
530        let size = ARCHIVE.len() as u64;
531        let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
532        auth.signatures[0] = encode_base64url(&[0u8; 64]);
533        let mut dev = target();
534        dev.channel = "draft".into();
535        let keys = [public_key_base64url(&SEED)];
536        assert!(verify_checked_update(package(Some(auth), &sha256, size), &dev, &keys).is_err());
537    }
538
539    #[test]
540    fn prod_publish_without_key_file_fails() {
541        let sha256 = archive_sha256_hex(ARCHIVE);
542        let err = sign_package_from_key_file(AppEnv::Prod, None, &request(&sha256)).unwrap_err();
543        assert!(err.to_string().contains("--update-signing-key-file"));
544    }
545
546    #[test]
547    fn prod_draft_publish_without_key_file_fails() {
548        let sha256 = archive_sha256_hex(ARCHIVE);
549        let mut req = request(&sha256);
550        req.channel = "draft";
551        let err = sign_package_from_key_file(AppEnv::Prod, None, &req).unwrap_err();
552        assert!(err.to_string().contains("prod publish"));
553    }
554
555    #[test]
556    fn dev_publish_without_key_file_is_unsigned() {
557        let sha256 = archive_sha256_hex(ARCHIVE);
558        assert!(
559            sign_package_from_key_file(AppEnv::Dev, None, &request(&sha256),)
560                .unwrap()
561                .is_none()
562        );
563    }
564}