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