From 9c0706e89c9be2996d89ee05971b735c65ad6ac0 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Wed, 2 Sep 2026 14:04:18 +0300 Subject: [PATCH 01/32] chore(protected-content): prepare provisioning dependencies and document the operator surface Promote custody-provider from dev-dependency to dependency so operator provisioning calls the same provision_state_root the provider binary uses; refresh object-provider's lockfile and build-script permissions for the shared-artifacts layout; document the elastos protected-content-config command surface in docs/PROTECTED_CONTENT.md. --- capsules/object-provider/Cargo.lock | 265 ++++++++++++++++++++--- docs/PROTECTED_CONTENT.md | 10 + elastos/crates/elastos-server/Cargo.toml | 2 +- scripts/build/build-llama-server.sh | 0 scripts/build/build-vm-smoke-rootfs.sh | 0 scripts/build/clean.sh | 0 6 files changed, 245 insertions(+), 32 deletions(-) mode change 100644 => 100755 scripts/build/build-llama-server.sh mode change 100644 => 100755 scripts/build/build-vm-smoke-rootfs.sh mode change 100644 => 100755 scripts/build/clean.sh diff --git a/capsules/object-provider/Cargo.lock b/capsules/object-provider/Cargo.lock index c8edcb267..4a67c0033 100644 --- a/capsules/object-provider/Cargo.lock +++ b/capsules/object-provider/Cargo.lock @@ -52,7 +52,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -64,6 +64,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.2.17", + "zeroize", ] [[package]] @@ -78,6 +79,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -140,7 +142,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -151,7 +153,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -494,7 +496,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -832,6 +834,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1064,19 +1072,35 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1087,6 +1111,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", "rand_core 0.10.1", ] @@ -1107,6 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1154,6 +1180,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "custody-provider" +version = "0.1.0" +dependencies = [ + "ed25519-dalek 2.2.0", + "elastos-protected-content-contracts", + "elastos-protected-content-custody", + "elastos-protected-content-provider-contracts", + "elastos-protected-content-rights", + "getrandom 0.2.17", + "nix", + "serde", + "serde_json", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -1220,7 +1263,7 @@ dependencies = [ "asn1-rs", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "rusticata-macros", ] @@ -1424,7 +1467,7 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", + "elliptic-curve 0.13.8", "rfc6979", "signature 2.2.0", "spki 0.7.3", @@ -1631,6 +1674,32 @@ dependencies = [ "x-wing", ] +[[package]] +name = "elastos-protected-content-custody" +version = "0.7.0" +dependencies = [ + "aes", + "aes-gcm", + "ctr", + "ed25519-dalek 2.2.0", + "elastos-protected-content-contracts", + "elastos-protected-content-provider-contracts", + "ghash", + "hkdf", + "hpke", + "nix", + "rand 0.10.2", + "rand 0.9.5", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "vsss-rs", + "x-wing", + "zeroize", +] + [[package]] name = "elastos-protected-content-provider-contracts" version = "0.7.0" @@ -1712,6 +1781,7 @@ dependencies = [ "bs58", "cid", "clap", + "custody-provider", "data-encoding", "dirs", "distributed-topic-tracker", @@ -1813,20 +1883,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct 0.2.0", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", - "generic-array", - "group", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", "hkdf", "pem-rfc7468 0.7.0", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4" +dependencies = [ + "elliptic-curve 0.14.1", + "heapless 0.9.3", + "serde", + "serdect", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1872,7 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1897,6 +1998,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2170,6 +2281,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2219,6 +2340,7 @@ checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", "polyval", + "zeroize", ] [[package]] @@ -2250,11 +2372,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.19" @@ -2294,6 +2427,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2322,13 +2464,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin 0.9.9", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2446,7 +2598,7 @@ dependencies = [ "aes-gcm", "chacha20poly1305", "digest 0.10.7", - "generic-array", + "generic-array 0.14.7", "hkdf", "hmac", "p256", @@ -2515,7 +2667,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2757,7 +2911,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -3166,7 +3320,7 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "once_cell", "sha2 0.10.9", "signature 2.2.0", @@ -3433,6 +3587,7 @@ dependencies = [ "module-lattice", "rand_core 0.10.1", "sha3 0.11.0", + "zeroize", ] [[package]] @@ -3444,6 +3599,7 @@ dependencies = [ "ctutils", "hybrid-array", "num-traits", + "zeroize", ] [[package]] @@ -3773,7 +3929,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3786,6 +3942,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3993,7 +4159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -4233,7 +4399,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "postcard-derive", "serde", ] @@ -4290,7 +4456,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -4401,7 +4567,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4745,7 +4911,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4804,7 +4970,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4875,12 +5041,26 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.1", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5212,7 +5392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5420,10 +5600,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5986,6 +6166,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsss-rs" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91" +dependencies = [ + "crypto-bigint 0.7.5", + "elliptic-curve 0.14.1", + "elliptic-curve-tools", + "ff 0.14.0", + "generic-array 1.4.5", + "hex", + "hybrid-array", + "num-bigint 0.5.1", + "num-traits", + "rand_core 0.10.1", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6512,7 +6713,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7043,6 +7244,7 @@ dependencies = [ "sha3 0.12.0", "shake", "x25519-dalek 3.0.0", + "zeroize", ] [[package]] @@ -7065,6 +7267,7 @@ checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ "curve25519-dalek 5.0.0", "rand_core 0.10.1", + "zeroize", ] [[package]] diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index f3b362c36..004c79198 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -178,6 +178,16 @@ configuration passed admission. Runtime startup, signed custody validation, live Chain evidence, replication and repair, and mint-buy-play remain active proof. +Operator provisioning of that configuration is one explicit command surface, +`elastos protected-content-config`. It creates the policy authority key, +provisions each custody host's inactive state root and exports its node +descriptor, prints the Runtime operation issuer custody hosts must trust, +assembles and signs the owner-only 2-of-3 custody composition from three node +descriptors, and installs the private multi-source Chain configuration. A +generated composition or Chain configuration is proven against the Runtime +loader before success is reported, and an installed composition can be +re-verified in place. The installer never creates this operator state. + The macOS and Linux source-home restart helpers validate the installation receipt and current clean source before any stop or migration. They select only the stable installed Runtime, own one exact PID file, stop only the proven diff --git a/elastos/crates/elastos-server/Cargo.toml b/elastos/crates/elastos-server/Cargo.toml index 5a25bb03c..b9601c07b 100644 --- a/elastos/crates/elastos-server/Cargo.toml +++ b/elastos/crates/elastos-server/Cargo.toml @@ -114,6 +114,7 @@ distributed-topic-tracker = "=0.3.5" futures-lite = "2.6" getrandom = "0.2" data-encoding = "2.6" +custody-provider = { path = "../../../capsules/custody-provider" } [dev-dependencies] tokio = { version = "1.0", features = ["full", "test-util"] } @@ -123,7 +124,6 @@ wasm-encoder = "=0.236.1" elastos-auth = { path = "../elastos-auth" } elastos-protected-content-custody = { path = "../elastos-protected-content-custody" } x-wing = { version = "=0.1.0", features = ["hazmat", "zeroize"] } -custody-provider = { path = "../../../capsules/custody-provider" } [features] default = [] diff --git a/scripts/build/build-llama-server.sh b/scripts/build/build-llama-server.sh old mode 100644 new mode 100755 diff --git a/scripts/build/build-vm-smoke-rootfs.sh b/scripts/build/build-vm-smoke-rootfs.sh old mode 100644 new mode 100755 diff --git a/scripts/build/clean.sh b/scripts/build/clean.sh old mode 100644 new mode 100755 From 4d688cc5aef73dc0d2f88e81b4c4a4fb34744720 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Wed, 2 Sep 2026 14:04:19 +0300 Subject: [PATCH 02/32] feat(protected-content): provision installed custody and chain prerequisites One explicit operator command surface, elastos protected-content-config: create the policy authority key; provision each custody host's inactive state root (the owner-only directory boot registration requires but nothing created) and export its node descriptor; print the Runtime issuer custody hosts must trust; assemble and sign the owner-only 2-of-3 custody composition from three descriptors with canonical ordering, derived operator/failure-domain ids, and random owner state roots; and install the private multi-source Chain configuration with the deployment-proven Base gateway and selector defaults, enforcing the documented 2..=5 distinct-origin evidence rule. Generated files are proven against the Runtime loaders before success is reported, and a group-readable data root stays provisionable since owner-only modes are enforced from protected-content down. Boot now wires reconcile_runtime_custody_viewers_after_decrypt_registration into the decrypt-registration success arm through a warn-and-continue hook, settling viewer records stranded before decrypt came up, and names each absent protected-content provider binary instead of skipping it silently. --- .../src/collaboration_config.rs | 13 +- elastos/crates/elastos-server/src/lib.rs | 1 + elastos/crates/elastos-server/src/main.rs | 9 + .../src/protected_content_config.rs | 1568 +++++++++++++++++ .../src/protected_content_runtime.rs | 66 +- .../src/protected_content_runtime/tests.rs | 150 ++ .../crates/elastos-server/src/server_infra.rs | 31 +- 7 files changed, 1806 insertions(+), 32 deletions(-) create mode 100644 elastos/crates/elastos-server/src/protected_content_config.rs diff --git a/elastos/crates/elastos-server/src/collaboration_config.rs b/elastos/crates/elastos-server/src/collaboration_config.rs index 40b5481c2..ff90a552c 100644 --- a/elastos/crates/elastos-server/src/collaboration_config.rs +++ b/elastos/crates/elastos-server/src/collaboration_config.rs @@ -282,7 +282,11 @@ fn read_authority_key(path: &Path) -> anyhow::Result { Ok(SigningKey::from_bytes(&key)) } -fn read_owner_only_file(path: &Path, max_bytes: usize, label: &str) -> anyhow::Result> { +pub(crate) fn read_owner_only_file( + path: &Path, + max_bytes: usize, + label: &str, +) -> anyhow::Result> { validate_owner_only_parent(path)?; let metadata = fs::symlink_metadata(path).with_context(|| format!("failed to inspect {label}"))?; @@ -312,7 +316,7 @@ fn read_owner_only_file(path: &Path, max_bytes: usize, label: &str) -> anyhow::R Ok(bytes) } -fn create_owner_only_file(path: &Path, bytes: &[u8], label: &str) -> anyhow::Result<()> { +pub(crate) fn create_owner_only_file(path: &Path, bytes: &[u8], label: &str) -> anyhow::Result<()> { let parent = validate_owner_only_parent(path)?; if fs::symlink_metadata(path).is_ok() { anyhow::bail!("{label} output already exists"); @@ -400,7 +404,10 @@ fn sha256_label(bytes: &[u8]) -> String { format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } -fn write_receipt(output: &mut dyn Write, receipt: &impl Serialize) -> anyhow::Result<()> { +pub(crate) fn write_receipt( + output: &mut dyn Write, + receipt: &impl Serialize, +) -> anyhow::Result<()> { serde_json::to_writer(&mut *output, receipt)?; output.write_all(b"\n")?; Ok(()) diff --git a/elastos/crates/elastos-server/src/lib.rs b/elastos/crates/elastos-server/src/lib.rs index 78a0b6db3..6a6570eca 100644 --- a/elastos/crates/elastos-server/src/lib.rs +++ b/elastos/crates/elastos-server/src/lib.rs @@ -50,6 +50,7 @@ pub mod local_http; pub mod notifications; pub mod operator_control; pub mod ownership; +pub mod protected_content_config; pub mod protected_content_runtime; pub mod provider_resource; pub mod resource_bridge; diff --git a/elastos/crates/elastos-server/src/main.rs b/elastos/crates/elastos-server/src/main.rs index 4baf44833..5653e165a 100755 --- a/elastos/crates/elastos-server/src/main.rs +++ b/elastos/crates/elastos-server/src/main.rs @@ -312,6 +312,10 @@ enum Commands { #[command(subcommand)] CollaborationConfig(elastos_server::collaboration_config::CollaborationConfigCommand), + /// Offline operator provisioning for protected-content custody and chain prerequisites + #[command(subcommand)] + ProtectedContentConfig(elastos_server::protected_content_config::ProtectedContentConfigCommand), + /// Show and manage the local DID-backed profile #[command(subcommand)] Identity(IdentityCommand), @@ -1302,6 +1306,11 @@ async fn main() -> anyhow::Result<()> { elastos_server::collaboration_config::run_collaboration_config_command(cmd).await?; } + Commands::ProtectedContentConfig(cmd) => { + elastos_server::protected_content_config::run_protected_content_config_command(cmd) + .await?; + } + Commands::Identity(cmd) => { return identity_cmd::run_identity(cmd).await; } diff --git a/elastos/crates/elastos-server/src/protected_content_config.rs b/elastos/crates/elastos-server/src/protected_content_config.rs new file mode 100644 index 000000000..9cda57611 --- /dev/null +++ b/elastos/crates/elastos-server/src/protected_content_config.rs @@ -0,0 +1,1568 @@ +//! Offline operator provisioning for the protected-content installed +//! prerequisites: the policy authority key, per-host custody node state, +//! the signed owner-only 2-of-3 custody composition, and the private +//! Chain provider configuration. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use base64::Engine as _; +use clap::Subcommand; +use ed25519_dalek::{Signer as _, SigningKey}; +use elastos_protected_content_contracts::{ + CanonicalContract, CustodyApprovedSuitesV1, CustodyCommitteeAuthorizationStatementV1, + CustodyEpochIssuerKeyV1, CustodyEpochStatementV1, CustodyNodeIdentityV1, + CustodyPoolFailureDomainIdV1, CustodyPoolMemberStateV1, CustodyPoolMemberV1, + CustodyPoolOperatorIdV1, CustodyPoolStatementV1, NodeCustodyPublicKeyV1, NodePublicKey, + RuntimeOperationIssuerKeyV1, ShareCoordinateV1, SignedCustodyCommitteeAuthorizationV1, + SignedCustodyEpochV1, SignedCustodyPoolV1, ThresholdV1, CUSTODY_X_WING_AES256GCM_SUITE_ID_V1, + PQ_HYBRID_WRAP_PUBLIC_KEY_BYTES, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::collaboration_config::{create_owner_only_file, read_owner_only_file, write_receipt}; +use crate::protected_content_runtime::{ + decode_canonical_peer_did, inactive_custody_state_root, protected_content_root, + runtime_custody_composition_config_path, validate_owner_only_directory, + RuntimeCustodyCompositionConfigFile, RuntimeCustodyRouteBindingConfig, + RuntimeCustodyRouteTransportConfig, CUSTODY_COMPOSITION_SCHEMA_V1, +}; + +const POLICY_AUTHORITY_KEY_RECEIPT_SCHEMA: &str = + "elastos.protected-content.policy-authority-key-created/v1"; +const CUSTODY_NODE_PROVISION_RECEIPT_SCHEMA: &str = + "elastos.protected-content.custody-node-provisioned/v1"; +pub(crate) const CUSTODY_NODE_DESCRIPTOR_SCHEMA_V1: &str = + "elastos.protected-content.custody-node-descriptor/v1"; +const CUSTODY_COMPOSITION_RECEIPT_SCHEMA: &str = + "elastos.protected-content.custody-composition-generated/v1"; +const CUSTODY_COMPOSITION_VERIFIED_RECEIPT_SCHEMA: &str = + "elastos.protected-content.custody-composition-verified/v1"; +const RUNTIME_ISSUER_RECEIPT_SCHEMA: &str = "elastos.protected-content.runtime-issuer/v1"; +/// Mirrors the Runtime loader's byte cap for the composition file. +const MAX_CUSTODY_COMPOSITION_FILE_BYTES: usize = 64 * 1024; +const CUSTODY_OPERATOR_ID_DOMAIN: &str = "elastos.protected-content.custody-operator-id/v1"; +const CUSTODY_FAILURE_DOMAIN_ID_DOMAIN: &str = + "elastos.protected-content.custody-failure-domain-id/v1"; +const MAX_CUSTODY_NODE_LABEL_BYTES: usize = 64; +const MAX_CUSTODY_NODE_DESCRIPTOR_BYTES: usize = 8 * 1024; +/// The composition validators hard-code a 2-of-3 committee; the ceremony keeps +/// the threshold in one place so a future k-of-n only changes this pair. +const CUSTODY_THRESHOLD_REQUIRED: u8 = 2; +const CUSTODY_THRESHOLD_TOTAL: u8 = 3; +const CHAIN_CONFIG_RECEIPT_SCHEMA: &str = + "elastos.protected-content.chain-provider-config-generated/v1"; +/// Facts proven in the deployment review for Base (chain id 8453); see +/// docs/CHAIN_PROVIDER.md ("has_access_by_content_id") and docs/PROTECTED_CONTENT.md. +const PROVEN_BASE_AUTHORITY_GATEWAY: &str = "0x09dbe796f40eceffeaccf243c3d758c4c1d8d87d"; +const PROVEN_HAS_ACCESS_SELECTOR: &str = "0x54d42821"; +/// Evidence corroboration needs 2..=5 independent sources; documented as the +/// product contract in docs/PROTECTED_CONTENT.md and enforced again by the +/// chain-provider capsule at Init. +const MIN_EVIDENCE_RPC_SOURCES: usize = 2; +const MAX_EVIDENCE_RPC_SOURCES: usize = 5; + +#[derive(Debug, Subcommand)] +pub enum ProtectedContentConfigCommand { + /// Create a new dedicated 32-byte protected-content policy authority key. + CreatePolicyAuthorityKey { + #[arg(long)] + key: PathBuf, + }, + /// Provision this host's inactive custody provider state root and export + /// its custody node descriptor for the composition ceremony. + ProvisionCustodyNode { + #[arg(long)] + data_dir: PathBuf, + /// Runtime operation issuer this custody node will trust (0x + 64 lowercase hex). + #[arg(long)] + trusted_runtime_issuer: String, + /// Operator label for this node (distinct across the three nodes). + #[arg(long)] + operator: String, + /// Failure-domain label for this node (distinct across the three nodes). + #[arg(long)] + failure_domain: String, + /// Carrier peer DID route to this node; omit for the single local route. + #[arg(long)] + transport_peer_did: Option, + #[arg(long)] + output: PathBuf, + }, + /// Assemble and sign the owner-only 2-of-3 custody composition from three + /// custody node descriptors, installing it into the Runtime data dir. + GenerateCustodyComposition { + #[arg(long)] + authority_key: PathBuf, + /// Custody node descriptor path; pass exactly three. + #[arg(long = "node")] + nodes: Vec, + #[arg(long)] + data_dir: PathBuf, + /// Days the pool members stay active from now. + #[arg(long, default_value_t = 365)] + valid_days: u16, + }, + /// Purely verify the installed custody composition through the Runtime loader. + VerifyCustodyComposition { + #[arg(long)] + data_dir: PathBuf, + }, + /// Print the Runtime operation issuer custody nodes must trust for this data dir. + ShowRuntimeIssuer { + #[arg(long)] + data_dir: PathBuf, + }, + /// Install the private multi-source Chain provider configuration for the + /// protected-content rights, mint, and market methods. + GenerateChainConfig { + #[arg(long)] + data_dir: PathBuf, + #[arg(long, default_value = "base-mainnet")] + id: String, + #[arg(long, default_value = "Base")] + display_name: String, + #[arg(long, default_value_t = 8453)] + chain_id: u64, + #[arg(long, default_value = "ETH")] + native_symbol: String, + #[arg(long, default_value_t = true)] + mainnet: bool, + /// Primary JSON-RPC endpoint for transactions and reads. + #[arg(long)] + rpc_url: String, + /// Independent evidence JSON-RPC endpoint (repeat 2..=5 times, distinct origins). + #[arg(long = "evidence-rpc-url")] + evidence_rpc_urls: Vec, + /// Rights gateway contract proven for this deployment. + #[arg(long, default_value = PROVEN_BASE_AUTHORITY_GATEWAY)] + rights_contract: String, + /// has_access_by_content_id selector proven for this deployment. + #[arg(long, default_value = PROVEN_HAS_ACCESS_SELECTOR)] + rights_selector: String, + #[arg(long, default_value = PROVEN_BASE_AUTHORITY_GATEWAY)] + authority_gateway_contract: String, + #[arg(long)] + mint_ledger: String, + #[arg(long)] + mint_pay_token: String, + #[arg(long)] + mint_asset_created_emitter: String, + }, +} + +#[derive(Serialize)] +struct PolicyAuthorityKeyReceipt { + schema: &'static str, + created: bool, +} + +#[derive(Serialize)] +struct CustodyNodeProvisionReceipt { + schema: &'static str, + node_public_key_hex: String, + descriptor_sha256: String, + created: bool, +} + +#[derive(Serialize)] +struct CustodyCompositionReceipt { + schema: &'static str, + node_public_key_hexes: Vec, + config_sha256: String, + created: bool, +} + +#[derive(Serialize)] +struct CustodyCompositionVerifiedReceipt { + schema: &'static str, + node_public_key_hexes: Vec, + config_sha256: String, + verified: bool, +} + +#[derive(Serialize)] +struct RuntimeIssuerReceipt { + schema: &'static str, + trusted_runtime_issuer: String, +} + +#[derive(Serialize)] +struct ChainConfigReceipt { + schema: &'static str, + network_id: String, + chain_id: u64, + evidence_rpc_sources: usize, + config_sha256: String, + created: bool, +} + +/// One custody node's public identity plus its operator-declared placement, +/// produced on the custody host and consumed by the composition ceremony. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CustodyNodeDescriptorV1 { + pub(crate) schema: String, + pub(crate) node_public_key_hex: String, + pub(crate) node_custody_public_key_hex: String, + pub(crate) operator: String, + pub(crate) failure_domain: String, + pub(crate) transport: CustodyNodeDescriptorTransportV1, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum CustodyNodeDescriptorTransportV1 { + Local, + CarrierPeerDid { peer_did: String }, +} + +pub async fn run_protected_content_config_command( + command: ProtectedContentConfigCommand, +) -> anyhow::Result<()> { + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + run_protected_content_config_command_with_writer(command, &mut output).await +} + +async fn run_protected_content_config_command_with_writer( + command: ProtectedContentConfigCommand, + output: &mut dyn Write, +) -> anyhow::Result<()> { + match command { + ProtectedContentConfigCommand::CreatePolicyAuthorityKey { key } => { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes) + .context("OS randomness unavailable for protected-content policy authority key")?; + create_owner_only_file(&key, &bytes, "protected-content policy authority key")?; + write_receipt( + output, + &PolicyAuthorityKeyReceipt { + schema: POLICY_AUTHORITY_KEY_RECEIPT_SCHEMA, + created: true, + }, + ) + } + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir, + trusted_runtime_issuer, + operator, + failure_domain, + transport_peer_did, + output: output_path, + } => { + let issuer = parse_runtime_issuer_hex(&trusted_runtime_issuer)?; + let operator = validated_custody_node_label(&operator, "operator")?; + let failure_domain = validated_custody_node_label(&failure_domain, "failure domain")?; + let transport = match transport_peer_did { + None => CustodyNodeDescriptorTransportV1::Local, + Some(peer_did) => CustodyNodeDescriptorTransportV1::CarrierPeerDid { + peer_did: decode_canonical_peer_did(&peer_did)?, + }, + }; + let inactive_root = provisioned_inactive_custody_root(&data_dir)?; + let provisioned = custody_provider::provision_state_root(&inactive_root, issuer) + .map_err(|error| { + anyhow::anyhow!("custody provider state provisioning failed: {error:?}") + })?; + let descriptor = CustodyNodeDescriptorV1 { + schema: CUSTODY_NODE_DESCRIPTOR_SCHEMA_V1.to_string(), + node_public_key_hex: format!( + "0x{}", + hex::encode(provisioned.node_public_key.as_bytes()) + ), + node_custody_public_key_hex: format!( + "0x{}", + hex::encode(provisioned.node_custody_public_key.as_bytes()) + ), + operator, + failure_domain, + transport, + }; + let descriptor_bytes = serde_json::to_vec(&serde_json::to_value(&descriptor)?)?; + create_owner_only_file(&output_path, &descriptor_bytes, "custody node descriptor")?; + write_receipt( + output, + &CustodyNodeProvisionReceipt { + schema: CUSTODY_NODE_PROVISION_RECEIPT_SCHEMA, + node_public_key_hex: descriptor.node_public_key_hex, + descriptor_sha256: format!( + "sha256:{}", + hex::encode(Sha256::digest(&descriptor_bytes)) + ), + created: true, + }, + ) + } + ProtectedContentConfigCommand::GenerateCustodyComposition { + authority_key, + nodes, + data_dir, + valid_days, + } => { + let authority = read_policy_authority_key(&authority_key)?; + let descriptors = read_distinct_custody_node_descriptors(&nodes)?; + let config = signed_custody_composition_config(&authority, descriptors, valid_days)?; + let config_bytes = serde_json::to_vec(&serde_json::to_value(&config)?)?; + + validate_existing_data_dir(&data_dir)?; + ensure_owner_only_dir(&protected_content_root(&data_dir), "protected-content root")?; + let config_path = runtime_custody_composition_config_path(&data_dir); + create_owner_only_file(&config_path, &config_bytes, "custody composition")?; + + // The Runtime loader is the single validation authority; prove the + // installed file end to end before reporting success. + if let Err(error) = crate::protected_content_runtime::load_runtime_custody_composition( + &data_dir, + std::sync::Arc::new(elastos_runtime::provider::ProviderRegistry::new()), + ) { + let _ = std::fs::remove_file(&config_path); + return Err(error.context("generated custody composition failed loader validation")); + } + + write_receipt( + output, + &CustodyCompositionReceipt { + schema: CUSTODY_COMPOSITION_RECEIPT_SCHEMA, + node_public_key_hexes: config + .routes + .iter() + .map(|route| { + Ok(format!( + "0x{}", + hex::encode(decode_standard_base64(&route.node_public_key_base64)?) + )) + }) + .collect::>>()?, + config_sha256: format!("sha256:{}", hex::encode(Sha256::digest(&config_bytes))), + created: true, + }, + ) + } + ProtectedContentConfigCommand::VerifyCustodyComposition { data_dir } => { + crate::protected_content_runtime::load_runtime_custody_composition( + &data_dir, + std::sync::Arc::new(elastos_runtime::provider::ProviderRegistry::new()), + )? + .context("custody composition is missing; generate and install it first")?; + let config_bytes = read_owner_only_file( + &runtime_custody_composition_config_path(&data_dir), + MAX_CUSTODY_COMPOSITION_FILE_BYTES, + "custody composition", + )?; + let config: RuntimeCustodyCompositionConfigFile = + serde_json::from_slice(&config_bytes)?; + write_receipt( + output, + &CustodyCompositionVerifiedReceipt { + schema: CUSTODY_COMPOSITION_VERIFIED_RECEIPT_SCHEMA, + node_public_key_hexes: config + .routes + .iter() + .map(|route| { + Ok(format!( + "0x{}", + hex::encode(decode_standard_base64(&route.node_public_key_base64)?) + )) + }) + .collect::>>()?, + config_sha256: format!("sha256:{}", hex::encode(Sha256::digest(&config_bytes))), + verified: true, + }, + ) + } + ProtectedContentConfigCommand::ShowRuntimeIssuer { data_dir } => { + let device_key_path = elastos_identity::device_key_path(&data_dir); + if std::fs::symlink_metadata(&device_key_path).is_err() { + anyhow::bail!( + "Runtime device key is missing at {}; start the Runtime on this data dir once, then retry", + device_key_path.display() + ); + } + let device_key = elastos_identity::load_or_create_device_key(&data_dir)?; + let issuer = crate::protected_content_runtime::derive_protected_content_runtime_issuer( + &device_key, + )?; + write_receipt( + output, + &RuntimeIssuerReceipt { + schema: RUNTIME_ISSUER_RECEIPT_SCHEMA, + trusted_runtime_issuer: format!("0x{}", hex::encode(issuer.as_bytes())), + }, + ) + } + ProtectedContentConfigCommand::GenerateChainConfig { + data_dir, + id, + display_name, + chain_id, + native_symbol, + mainnet, + rpc_url, + evidence_rpc_urls, + rights_contract, + rights_selector, + authority_gateway_contract, + mint_ledger, + mint_pay_token, + mint_asset_created_emitter, + } => { + validate_private_rpc_url(&rpc_url, "rpc url")?; + validate_evidence_rpc_urls(&evidence_rpc_urls)?; + let rights_contract = validated_contract_address(&rights_contract, "rights contract")?; + let authority_gateway_contract = validated_contract_address( + &authority_gateway_contract, + "authority gateway contract", + )?; + let mint_ledger = validated_contract_address(&mint_ledger, "mint ledger")?; + let mint_pay_token = validated_contract_address(&mint_pay_token, "mint pay token")?; + let mint_asset_created_emitter = validated_contract_address( + &mint_asset_created_emitter, + "mint asset-created emitter", + )?; + validated_selector(&rights_selector)?; + + let network = serde_json::json!({ + "id": id, + "display_name": display_name, + "kind": "evm_json_rpc", + "chain_id": chain_id, + "native_symbol": native_symbol, + "provider": "operator", + "mainnet": mainnet, + "explorer_url": null, + "rpc_url": rpc_url, + "rights_methods": [{ + "id": "has_access_by_content_id", + "contract": rights_contract, + "abi": "has_access_by_content_id_address_bytes16", + "selector": rights_selector, + "protected_content_policies": [{ + "action": "view", + "evidence_rpc_urls": evidence_rpc_urls, + }], + }], + "protected_content_creator_mint": { + "ledger": mint_ledger, + "pay_token": mint_pay_token, + "asset_created_emitter": mint_asset_created_emitter, + "abi": "elacity_mint_v1", + }, + "protected_content_market": { + "authority_gateway_contract": authority_gateway_contract, + "evidence_rpc_urls": evidence_rpc_urls, + }, + }); + let config_bytes = serde_json::to_vec(&serde_json::json!({ + "schema": crate::protected_content_runtime::PROTECTED_CONTENT_CHAIN_PROVIDER_CONFIG_SCHEMA_V1, + "protected_content_network": network, + }))?; + + validate_existing_data_dir(&data_dir)?; + ensure_owner_only_dir(&protected_content_root(&data_dir), "protected-content root")?; + let config_path = protected_content_root(&data_dir).join("chain-provider.json"); + create_owner_only_file( + &config_path, + &config_bytes, + "protected-content chain config", + )?; + + // Prove the installed file passes the Runtime loader before + // reporting success; the chain-provider capsule re-validates the + // network body at Init and stays the live admission authority. + if let Err(error) = + crate::protected_content_runtime::load_runtime_protected_content_chain_provider_config( + &data_dir, + ) + { + let _ = std::fs::remove_file(&config_path); + return Err(error.context("generated chain config failed loader validation")); + } + + write_receipt( + output, + &ChainConfigReceipt { + schema: CHAIN_CONFIG_RECEIPT_SCHEMA, + network_id: id, + chain_id, + evidence_rpc_sources: evidence_rpc_urls.len(), + config_sha256: format!("sha256:{}", hex::encode(Sha256::digest(&config_bytes))), + created: true, + }, + ) + } + } +} + +/// Doc-backed shape rule for private chain endpoints (see the chain-provider +/// capsule's admission checks): https, or http against loopback only, and no +/// credentials in the URL. +fn validate_private_rpc_url(value: &str, label: &str) -> anyhow::Result { + let parsed = url::Url::parse(value).with_context(|| format!("{label} is not a valid URL"))?; + let loopback_http = parsed.scheme() == "http" + && matches!(parsed.host_str(), Some("127.0.0.1") | Some("localhost")); + if parsed.scheme() != "https" && !loopback_http { + anyhow::bail!("{label} must use https (or http against 127.0.0.1/localhost)"); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + anyhow::bail!("{label} must not embed credentials"); + } + Ok(parsed) +} + +fn validate_evidence_rpc_urls(urls: &[String]) -> anyhow::Result<()> { + if !(MIN_EVIDENCE_RPC_SOURCES..=MAX_EVIDENCE_RPC_SOURCES).contains(&urls.len()) { + anyhow::bail!( + "evidence corroboration requires {MIN_EVIDENCE_RPC_SOURCES}..={MAX_EVIDENCE_RPC_SOURCES} independent evidence RPC sources" + ); + } + let mut seen_urls = std::collections::BTreeSet::new(); + let mut seen_origins = std::collections::BTreeSet::new(); + for value in urls { + let parsed = validate_private_rpc_url(value, "evidence rpc url")?; + if !seen_urls.insert(parsed.to_string()) { + anyhow::bail!("evidence RPC sources must be distinct URLs"); + } + if !seen_origins.insert(parsed.origin().ascii_serialization()) { + anyhow::bail!("evidence RPC sources must come from distinct origins"); + } + } + Ok(()) +} + +fn validated_contract_address(value: &str, label: &str) -> anyhow::Result { + let hex_part = value + .strip_prefix("0x") + .with_context(|| format!("{label} must start with 0x"))?; + if hex_part.len() != 40 + || !hex_part + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + anyhow::bail!("{label} must be 0x plus 40 lowercase hex characters"); + } + Ok(value.to_string()) +} + +fn validated_selector(value: &str) -> anyhow::Result<()> { + let hex_part = value + .strip_prefix("0x") + .context("selector must start with 0x")?; + if hex_part.len() != 8 + || !hex_part + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + anyhow::bail!("selector must be 0x plus 8 lowercase hex characters"); + } + Ok(()) +} + +struct ValidatedCustodyNodeDescriptor { + node_public_key: NodePublicKey, + custody_public_key: NodeCustodyPublicKeyV1, + operator: String, + failure_domain: String, + transport: RuntimeCustodyRouteTransportConfig, +} + +fn read_policy_authority_key(path: &Path) -> anyhow::Result { + let bytes = read_owner_only_file(path, 32, "protected-content policy authority key")?; + let key: [u8; 32] = bytes.try_into().map_err(|_| { + anyhow::anyhow!("protected-content policy authority key must contain exactly 32 bytes") + })?; + Ok(SigningKey::from_bytes(&key)) +} + +fn read_distinct_custody_node_descriptors( + paths: &[PathBuf], +) -> anyhow::Result> { + if paths.len() != usize::from(CUSTODY_THRESHOLD_TOTAL) { + anyhow::bail!( + "custody composition requires exactly {CUSTODY_THRESHOLD_TOTAL} --node descriptors" + ); + } + let mut descriptors = paths + .iter() + .map(|path| read_custody_node_descriptor(path)) + .collect::>>()?; + require_distinct( + descriptors + .iter() + .map(|d| d.node_public_key.as_bytes().to_vec()), + "node public keys", + )?; + require_distinct( + descriptors + .iter() + .map(|d| d.custody_public_key.as_bytes().to_vec()), + "node custody public keys", + )?; + require_distinct( + descriptors.iter().map(|d| d.operator.clone().into_bytes()), + "operator labels", + )?; + require_distinct( + descriptors + .iter() + .map(|d| d.failure_domain.clone().into_bytes()), + "failure-domain labels", + )?; + let local_routes = descriptors + .iter() + .filter(|d| matches!(d.transport, RuntimeCustodyRouteTransportConfig::Local)) + .count(); + if local_routes > 1 { + anyhow::bail!("custody composition allows at most one local transport route"); + } + require_distinct( + descriptors.iter().filter_map(|d| match &d.transport { + RuntimeCustodyRouteTransportConfig::CarrierPeerDid { peer_did } => { + Some(peer_did.clone().into_bytes()) + } + RuntimeCustodyRouteTransportConfig::Local => None, + }), + "carrier peer DIDs", + )?; + // The epoch statement orders nodes by node public key; keep every derived + // artifact (members, routes) in that one canonical order. + descriptors.sort_by(|a, b| { + a.node_public_key + .as_bytes() + .cmp(b.node_public_key.as_bytes()) + }); + Ok(descriptors) +} + +fn read_custody_node_descriptor(path: &Path) -> anyhow::Result { + let bytes = read_owner_only_file( + path, + MAX_CUSTODY_NODE_DESCRIPTOR_BYTES, + "custody node descriptor", + )?; + let descriptor: CustodyNodeDescriptorV1 = serde_json::from_slice(&bytes) + .with_context(|| format!("invalid custody node descriptor at {}", path.display()))?; + if serde_json::to_vec(&serde_json::to_value(&descriptor)?)? != bytes { + anyhow::bail!( + "custody node descriptor at {} is not canonical JSON", + path.display() + ); + } + if descriptor.schema != CUSTODY_NODE_DESCRIPTOR_SCHEMA_V1 { + anyhow::bail!( + "custody node descriptor at {} carries an unsupported schema", + path.display() + ); + } + let node_public_key = NodePublicKey::new(parse_prefixed_hex::<32>( + &descriptor.node_public_key_hex, + "node public key", + )?) + .map_err(|error| anyhow::anyhow!("custody node public key is invalid: {error:?}"))?; + let custody_public_key = NodeCustodyPublicKeyV1::new(parse_prefixed_hex::< + PQ_HYBRID_WRAP_PUBLIC_KEY_BYTES, + >( + &descriptor.node_custody_public_key_hex, + "node custody public key", + )?) + .map_err(|error| anyhow::anyhow!("custody node custody public key is invalid: {error:?}"))?; + let operator = validated_custody_node_label(&descriptor.operator, "operator")?; + let failure_domain = + validated_custody_node_label(&descriptor.failure_domain, "failure domain")?; + let transport = match descriptor.transport { + CustodyNodeDescriptorTransportV1::Local => RuntimeCustodyRouteTransportConfig::Local, + CustodyNodeDescriptorTransportV1::CarrierPeerDid { peer_did } => { + RuntimeCustodyRouteTransportConfig::CarrierPeerDid { + peer_did: decode_canonical_peer_did(&peer_did)?, + } + } + }; + Ok(ValidatedCustodyNodeDescriptor { + node_public_key, + custody_public_key, + operator, + failure_domain, + transport, + }) +} + +fn signed_custody_composition_config( + authority: &SigningKey, + descriptors: Vec, + valid_days: u16, +) -> anyhow::Result { + let issuer = CustodyEpochIssuerKeyV1::new(authority.verifying_key().to_bytes()) + .map_err(|error| anyhow::anyhow!("policy authority key is invalid: {error:?}"))?; + let suites = CustodyApprovedSuitesV1::new( + CUSTODY_X_WING_AES256GCM_SUITE_ID_V1, + CUSTODY_X_WING_AES256GCM_SUITE_ID_V1, + CUSTODY_X_WING_AES256GCM_SUITE_ID_V1, + ) + .map_err(|error| anyhow::anyhow!("approved custody suites are invalid: {error:?}"))?; + + let node_identities = descriptors + .iter() + .enumerate() + .map(|(index, descriptor)| { + let coordinate = ShareCoordinateV1::new(u8::try_from(index + 1)?) + .map_err(|error| anyhow::anyhow!("share coordinate is invalid: {error:?}"))?; + CustodyNodeIdentityV1::new( + descriptor.node_public_key, + descriptor.custody_public_key, + coordinate, + ) + .map_err(|error| anyhow::anyhow!("custody node identity is invalid: {error:?}")) + }) + .collect::>>()?; + let epoch_statement = CustodyEpochStatementV1::new( + issuer, + suites.clone(), + ThresholdV1::new(CUSTODY_THRESHOLD_REQUIRED, CUSTODY_THRESHOLD_TOTAL) + .map_err(|error| anyhow::anyhow!("custody threshold is invalid: {error:?}"))?, + node_identities, + ) + .map_err(|error| anyhow::anyhow!("custody epoch statement is invalid: {error:?}"))?; + let signed_epoch = SignedCustodyEpochV1::new( + epoch_statement.clone(), + authority + .sign(&epoch_statement.canonical_bytes().map_err(|error| { + anyhow::anyhow!("custody epoch statement cannot be encoded: {error:?}") + })?) + .to_bytes() + .to_vec(), + ) + .map_err(|error| anyhow::anyhow!("signed custody epoch is invalid: {error:?}"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_secs(); + let active_window = ( + now.saturating_sub(60), + now.checked_add(u64::from(valid_days) * 86_400) + .context("custody validity window overflows")?, + ); + let members = descriptors + .iter() + .map(|descriptor| { + CustodyPoolMemberV1::new( + descriptor.node_public_key, + descriptor.custody_public_key, + CustodyPoolOperatorIdV1::new(custody_label_id( + CUSTODY_OPERATOR_ID_DOMAIN, + &descriptor.operator, + )), + CustodyPoolFailureDomainIdV1::new(custody_label_id( + CUSTODY_FAILURE_DOMAIN_ID_DOMAIN, + &descriptor.failure_domain, + )), + suites.clone(), + active_window, + CustodyPoolMemberStateV1::Active, + ) + .map_err(|error| anyhow::anyhow!("custody pool member is invalid: {error:?}")) + }) + .collect::>>()?; + let pool_statement = CustodyPoolStatementV1::new(issuer, members) + .map_err(|error| anyhow::anyhow!("custody pool statement is invalid: {error:?}"))?; + let signed_pool = SignedCustodyPoolV1::new( + pool_statement.clone(), + authority + .sign(&pool_statement.canonical_bytes().map_err(|error| { + anyhow::anyhow!("custody pool statement cannot be encoded: {error:?}") + })?) + .to_bytes() + .to_vec(), + ) + .map_err(|error| anyhow::anyhow!("signed custody pool is invalid: {error:?}"))?; + + let authorization_statement = CustodyCommitteeAuthorizationStatementV1::new( + issuer, + signed_pool + .pool_identity() + .map_err(|error| anyhow::anyhow!("custody pool identity is invalid: {error:?}"))?, + signed_epoch + .epoch_identity() + .map_err(|error| anyhow::anyhow!("custody epoch identity is invalid: {error:?}"))?, + ) + .map_err(|error| { + anyhow::anyhow!("custody committee authorization statement is invalid: {error:?}") + })?; + let signed_authorization = SignedCustodyCommitteeAuthorizationV1::new( + authorization_statement.clone(), + authority + .sign(&authorization_statement.canonical_bytes().map_err(|error| { + anyhow::anyhow!("committee authorization cannot be encoded: {error:?}") + })?) + .to_bytes() + .to_vec(), + ) + .map_err(|error| anyhow::anyhow!("signed committee authorization is invalid: {error:?}"))?; + + let routes = descriptors + .iter() + .map(|descriptor| { + Ok(RuntimeCustodyRouteBindingConfig { + node_public_key_base64: encode_standard_base64( + descriptor.node_public_key.as_bytes(), + ), + owner_state_root_base64: encode_standard_base64(&random_owner_state_root()?), + transport: descriptor.transport.clone(), + }) + }) + .collect::>>()?; + + Ok(RuntimeCustodyCompositionConfigFile { + schema: CUSTODY_COMPOSITION_SCHEMA_V1.to_string(), + expected_policy_authority_base64: encode_standard_base64( + &authority.verifying_key().to_bytes(), + ), + expected_committee_authorization_identity_base64: canonical_contract_base64( + &signed_authorization + .authorization_identity() + .map_err(|error| { + anyhow::anyhow!("committee authorization identity is invalid: {error:?}") + })?, + )?, + signed_pool_base64: canonical_contract_base64(&signed_pool)?, + signed_epoch_base64: canonical_contract_base64(&signed_epoch)?, + signed_committee_authorization_base64: canonical_contract_base64(&signed_authorization)?, + routes, + }) +} + +fn require_distinct(values: impl Iterator>, label: &str) -> anyhow::Result<()> { + let mut seen = std::collections::BTreeSet::new(); + for value in values { + if !seen.insert(value) { + anyhow::bail!("custody composition requires distinct {label} across nodes"); + } + } + Ok(()) +} + +fn custody_label_id(domain: &str, label: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + hasher.update([0u8]); + hasher.update(label.as_bytes()); + hasher.finalize().into() +} + +fn random_owner_state_root() -> anyhow::Result<[u8; 32]> { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).context("OS randomness unavailable for owner state root")?; + if bytes == [0u8; 32] { + anyhow::bail!("owner state root randomness returned all zeroes"); + } + Ok(bytes) +} + +fn parse_prefixed_hex(value: &str, label: &str) -> anyhow::Result<[u8; LEN]> { + let hex_part = value + .strip_prefix("0x") + .with_context(|| format!("{label} must start with 0x"))?; + if hex_part.len() != LEN * 2 + || !hex_part + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + anyhow::bail!( + "{label} must be 0x plus {} lowercase hex characters", + LEN * 2 + ); + } + hex::decode(hex_part)? + .try_into() + .map_err(|_| anyhow::anyhow!("{label} must encode {LEN} bytes")) +} + +fn encode_standard_base64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn decode_standard_base64(value: &str) -> anyhow::Result> { + base64::engine::general_purpose::STANDARD + .decode(value) + .context("invalid base64 value") +} + +fn canonical_contract_base64(value: &T) -> anyhow::Result { + Ok(encode_standard_base64(&value.canonical_bytes().map_err( + |error| anyhow::anyhow!("canonical contract encoding failed: {error:?}"), + )?)) +} + +/// Create (if missing) and validate the owner-only directory chain that must +/// exist before the inactive custody provider state root can be provisioned +/// or registered at boot. +fn provisioned_inactive_custody_root(data_dir: &Path) -> anyhow::Result { + validate_existing_data_dir(data_dir)?; + let protected_root = protected_content_root(data_dir); + ensure_owner_only_dir(&protected_root, "protected-content root")?; + let inactive_root = inactive_custody_state_root(data_dir); + let custody_dir = inactive_root + .parent() + .context("inactive custody provider root has no parent directory")?; + ensure_owner_only_dir(custody_dir, "custody provider root")?; + Ok(inactive_root) +} + +/// The Runtime enforces owner-only modes from `protected-content` down; the +/// data root itself only needs to be a real directory. +fn validate_existing_data_dir(data_dir: &Path) -> anyhow::Result<()> { + let metadata = std::fs::symlink_metadata(data_dir) + .with_context(|| format!("data dir {} is unavailable", data_dir.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + anyhow::bail!("data dir {} must be a real directory", data_dir.display()); + } + Ok(()) +} + +fn ensure_owner_only_dir(path: &Path, label: &str) -> anyhow::Result<()> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .with_context(|| format!("failed to create {label}"))?; + } + #[cfg(not(unix))] + anyhow::bail!("{label} provisioning requires a Unix host"); + } + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {label}")); + } + Ok(_) => {} + } + validate_owner_only_directory(path, label) +} + +fn parse_runtime_issuer_hex(value: &str) -> anyhow::Result { + let hex_part = value + .strip_prefix("0x") + .context("trusted runtime issuer must start with 0x")?; + if hex_part.len() != 64 + || !hex_part + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + anyhow::bail!("trusted runtime issuer must be 0x plus 64 lowercase hex characters"); + } + let bytes: [u8; 32] = hex::decode(hex_part)? + .try_into() + .map_err(|_| anyhow::anyhow!("trusted runtime issuer must encode 32 bytes"))?; + RuntimeOperationIssuerKeyV1::new(bytes) + .map_err(|_| anyhow::anyhow!("trusted runtime issuer is not a valid operation issuer key")) +} + +fn validated_custody_node_label(value: &str, label: &str) -> anyhow::Result { + if value.is_empty() || value.trim() != value || value.len() > MAX_CUSTODY_NODE_LABEL_BYTES { + anyhow::bail!( + "custody node {label} label must be 1..={MAX_CUSTODY_NODE_LABEL_BYTES} bytes without surrounding whitespace" + ); + } + Ok(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_policy_authority_key_creates_owner_only_key_and_receipt() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("policy-authority.key"); + let mut out = Vec::new(); + + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::CreatePolicyAuthorityKey { + key: key_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + + let metadata = std::fs::symlink_metadata(&key_path).unwrap(); + assert_eq!(metadata.len(), 32); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.policy-authority-key-created/v1" + ); + assert_eq!(receipt["created"], true); + } + + #[cfg(unix)] + fn owner_only_dir(path: &std::path::Path) { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new().mode(0o700).create(path).unwrap(); + } + + /// Custody provisioning validates every ancestor's owner and mode, and the + /// std temp root is structurally unsafe on some hosts (see + /// `safe_ancestor_tempdir` in the protected-content runtime tests); anchor + /// fixtures under the build target dir instead. + #[cfg(unix)] + fn safe_ancestor_tempdir() -> tempfile::TempDir { + let root = option_env!("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target"))); + let base = root.join("protected-content-config-test-fixtures"); + std::fs::create_dir_all(&base).unwrap(); + // Custody provisioning also rejects `..` path components, so hand it a + // canonical base. + tempfile::tempdir_in(base.canonicalize().unwrap()).unwrap() + } + + #[cfg(unix)] + fn test_runtime_issuer_hex() -> String { + use elastos_runtime::signature::SigningKey; + let key = SigningKey::from_bytes(&[0x42; 32]); + format!("0x{}", hex::encode(key.verifying_key().to_bytes())) + } + + #[cfg(unix)] + #[tokio::test] + async fn provision_custody_node_provisions_inactive_root_and_writes_descriptor() { + use std::os::unix::fs::PermissionsExt; + + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + let descriptor_path = dir.path().join("node-a.descriptor.json"); + let mut out = Vec::new(); + + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir: data_dir.clone(), + trusted_runtime_issuer: test_runtime_issuer_hex(), + operator: "operator-a".to_string(), + failure_domain: "failure-domain-a".to_string(), + transport_peer_did: None, + output: descriptor_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + + let inactive_root = data_dir.join("protected-content/custody-provider/inactive"); + let metadata = std::fs::symlink_metadata(&inactive_root).unwrap(); + assert!(metadata.is_dir()); + assert_eq!(metadata.permissions().mode() & 0o777, 0o700); + + let descriptor_bytes = std::fs::read(&descriptor_path).unwrap(); + let descriptor: serde_json::Value = serde_json::from_slice(&descriptor_bytes).unwrap(); + assert_eq!( + descriptor["schema"], + "elastos.protected-content.custody-node-descriptor/v1" + ); + let node_public_key = descriptor["node_public_key_hex"].as_str().unwrap(); + assert_eq!(node_public_key.len(), 2 + 64); + assert!(node_public_key.starts_with("0x")); + let custody_public_key = descriptor["node_custody_public_key_hex"].as_str().unwrap(); + assert_eq!(custody_public_key.len(), 2 + 1216 * 2); + assert_eq!(descriptor["operator"], "operator-a"); + assert_eq!(descriptor["failure_domain"], "failure-domain-a"); + assert_eq!(descriptor["transport"]["kind"], "local"); + + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.custody-node-provisioned/v1" + ); + assert_eq!(receipt["node_public_key_hex"], node_public_key); + assert_eq!(receipt["created"], true); + } + + /// The Runtime validates owner-only modes from `protected-content` down, + /// not on the data root itself; a 0755 data dir must stay provisionable. + #[cfg(unix)] + #[tokio::test] + async fn provision_custody_node_accepts_a_group_readable_data_dir() { + use std::os::unix::fs::PermissionsExt; + + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + std::fs::create_dir(&data_dir).unwrap(); + std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir: data_dir.clone(), + trusted_runtime_issuer: test_runtime_issuer_hex(), + operator: "operator-a".to_string(), + failure_domain: "failure-domain-a".to_string(), + transport_peer_did: None, + output: dir.path().join("node.descriptor.json"), + }, + &mut out, + ) + .await + .unwrap(); + + let protected_root = data_dir.join("protected-content"); + let metadata = std::fs::symlink_metadata(&protected_root).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o700); + } + + #[cfg(unix)] + #[tokio::test] + async fn provision_custody_node_reprovision_keeps_the_same_node_identity() { + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + let issuer = test_runtime_issuer_hex(); + + let mut first_out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir: data_dir.clone(), + trusted_runtime_issuer: issuer.clone(), + operator: "operator-a".to_string(), + failure_domain: "failure-domain-a".to_string(), + transport_peer_did: None, + output: dir.path().join("first.descriptor.json"), + }, + &mut first_out, + ) + .await + .unwrap(); + + let mut second_out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir: data_dir.clone(), + trusted_runtime_issuer: issuer, + operator: "operator-a".to_string(), + failure_domain: "failure-domain-a".to_string(), + transport_peer_did: None, + output: dir.path().join("second.descriptor.json"), + }, + &mut second_out, + ) + .await + .unwrap(); + + let first: serde_json::Value = serde_json::from_slice(&first_out).unwrap(); + let second: serde_json::Value = serde_json::from_slice(&second_out).unwrap(); + assert_eq!(first["node_public_key_hex"], second["node_public_key_hex"]); + } + + /// Provision three distinct custody hosts and return their descriptor paths. + #[cfg(unix)] + async fn provision_three_node_descriptors( + dir: &std::path::Path, + issuer_hex: &str, + transports: [Option; 3], + ) -> Vec { + let mut descriptors = Vec::new(); + for (index, transport_peer_did) in transports.into_iter().enumerate() { + let host_data_dir = dir.join(format!("host-{index}/data")); + std::fs::create_dir_all(host_data_dir.parent().unwrap()).unwrap(); + owner_only_dir(&host_data_dir); + let descriptor_path = dir.join(format!("node-{index}.descriptor.json")); + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ProvisionCustodyNode { + data_dir: host_data_dir, + trusted_runtime_issuer: issuer_hex.to_string(), + operator: format!("operator-{index}"), + failure_domain: format!("failure-domain-{index}"), + transport_peer_did, + output: descriptor_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + descriptors.push(descriptor_path); + } + descriptors + } + + #[cfg(unix)] + fn test_peer_did(seed: u8) -> String { + use elastos_runtime::signature::SigningKey; + crate::crypto::encode_did_key(&SigningKey::from_bytes(&[seed; 32]).verifying_key()).unwrap() + } + + #[cfg(unix)] + #[tokio::test] + async fn generate_custody_composition_output_passes_the_runtime_loader() { + use elastos_runtime::provider::ProviderRegistry; + use std::sync::Arc; + + let dir = safe_ancestor_tempdir(); + let runtime_data_dir = dir.path().join("runtime/data"); + std::fs::create_dir_all(runtime_data_dir.parent().unwrap()).unwrap(); + owner_only_dir(&runtime_data_dir); + + let authority_key_path = dir.path().join("policy-authority.key"); + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::CreatePolicyAuthorityKey { + key: authority_key_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + + let descriptors = provision_three_node_descriptors( + dir.path(), + &test_runtime_issuer_hex(), + [None, Some(test_peer_did(0x51)), Some(test_peer_did(0x52))], + ) + .await; + + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::GenerateCustodyComposition { + authority_key: authority_key_path, + nodes: descriptors, + data_dir: runtime_data_dir.clone(), + valid_days: 365, + }, + &mut out, + ) + .await + .unwrap(); + + // The loader's [_; 3] node array plus signature validation make this + // the whole done-check: parse, canonical bytes, trust anchors, routes. + crate::protected_content_runtime::load_runtime_custody_composition( + &runtime_data_dir, + Arc::new(ProviderRegistry::new()), + ) + .unwrap() + .expect("generated composition must load"); + + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.custody-composition-generated/v1" + ); + assert_eq!(receipt["created"], true); + assert_eq!( + receipt["node_public_key_hexes"].as_array().unwrap().len(), + 3 + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn generate_custody_composition_rejects_duplicate_operator_labels() { + let dir = safe_ancestor_tempdir(); + let runtime_data_dir = dir.path().join("runtime/data"); + std::fs::create_dir_all(runtime_data_dir.parent().unwrap()).unwrap(); + owner_only_dir(&runtime_data_dir); + + let authority_key_path = dir.path().join("policy-authority.key"); + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::CreatePolicyAuthorityKey { + key: authority_key_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + + let descriptors = provision_three_node_descriptors( + dir.path(), + &test_runtime_issuer_hex(), + [None, Some(test_peer_did(0x51)), Some(test_peer_did(0x52))], + ) + .await; + // Rewrite the third descriptor with the first one's operator label. + let mut third: serde_json::Value = + serde_json::from_slice(&std::fs::read(&descriptors[2]).unwrap()).unwrap(); + third["operator"] = serde_json::Value::String("operator-0".to_string()); + std::fs::write(&descriptors[2], serde_json::to_vec(&third).unwrap()).unwrap(); + + let mut out = Vec::new(); + let error = run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::GenerateCustodyComposition { + authority_key: authority_key_path, + nodes: descriptors, + data_dir: runtime_data_dir.clone(), + valid_days: 365, + }, + &mut out, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("distinct"), "{error}"); + assert!(!runtime_data_dir + .join("protected-content/custody-composition.json") + .exists()); + } + + #[cfg(unix)] + async fn generate_test_composition(dir: &tempfile::TempDir) -> PathBuf { + let runtime_data_dir = dir.path().join("runtime/data"); + std::fs::create_dir_all(runtime_data_dir.parent().unwrap()).unwrap(); + owner_only_dir(&runtime_data_dir); + let authority_key_path = dir.path().join("policy-authority.key"); + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::CreatePolicyAuthorityKey { + key: authority_key_path.clone(), + }, + &mut out, + ) + .await + .unwrap(); + let descriptors = provision_three_node_descriptors( + dir.path(), + &test_runtime_issuer_hex(), + [None, Some(test_peer_did(0x51)), Some(test_peer_did(0x52))], + ) + .await; + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::GenerateCustodyComposition { + authority_key: authority_key_path, + nodes: descriptors, + data_dir: runtime_data_dir.clone(), + valid_days: 365, + }, + &mut out, + ) + .await + .unwrap(); + runtime_data_dir + } + + #[cfg(unix)] + #[tokio::test] + async fn verify_custody_composition_reports_the_installed_composition() { + let dir = safe_ancestor_tempdir(); + let runtime_data_dir = generate_test_composition(&dir).await; + + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::VerifyCustodyComposition { + data_dir: runtime_data_dir, + }, + &mut out, + ) + .await + .unwrap(); + + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.custody-composition-verified/v1" + ); + assert_eq!(receipt["verified"], true); + assert_eq!( + receipt["node_public_key_hexes"].as_array().unwrap().len(), + 3 + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn verify_custody_composition_fails_closed_when_the_composition_is_missing() { + let dir = safe_ancestor_tempdir(); + let runtime_data_dir = dir.path().join("runtime/data"); + std::fs::create_dir_all(runtime_data_dir.parent().unwrap()).unwrap(); + owner_only_dir(&runtime_data_dir); + + let mut out = Vec::new(); + let error = run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::VerifyCustodyComposition { + data_dir: runtime_data_dir, + }, + &mut out, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("missing"), "{error}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn show_runtime_issuer_prints_the_derived_issuer_for_an_existing_device_key() { + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + let device_key = elastos_identity::load_or_create_device_key(&data_dir).unwrap(); + let expected = + crate::protected_content_runtime::derive_protected_content_runtime_issuer(&device_key) + .unwrap(); + + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ShowRuntimeIssuer { + data_dir: data_dir.clone(), + }, + &mut out, + ) + .await + .unwrap(); + + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.runtime-issuer/v1" + ); + assert_eq!( + receipt["trusted_runtime_issuer"], + format!("0x{}", hex::encode(expected.as_bytes())) + ); + } + + #[cfg(unix)] + fn chain_config_command( + data_dir: &std::path::Path, + evidence_rpc_urls: Vec, + ) -> ProtectedContentConfigCommand { + ProtectedContentConfigCommand::GenerateChainConfig { + data_dir: data_dir.to_path_buf(), + id: "base-mainnet".to_string(), + display_name: "Base".to_string(), + chain_id: 8453, + native_symbol: "ETH".to_string(), + mainnet: true, + rpc_url: "https://private-primary.example.invalid".to_string(), + evidence_rpc_urls, + rights_contract: "0x09dbe796f40eceffeaccf243c3d758c4c1d8d87d".to_string(), + rights_selector: "0x54d42821".to_string(), + authority_gateway_contract: "0x09dbe796f40eceffeaccf243c3d758c4c1d8d87d".to_string(), + mint_ledger: "0x0000000000000000000000000000000000000022".to_string(), + mint_pay_token: "0x0000000000000000000000000000000000000033".to_string(), + mint_asset_created_emitter: "0x0000000000000000000000000000000000000044".to_string(), + } + } + + #[cfg(unix)] + #[tokio::test] + async fn generate_chain_config_installs_a_loadable_owner_only_config() { + use std::os::unix::fs::PermissionsExt; + + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + + let mut out = Vec::new(); + run_protected_content_config_command_with_writer( + chain_config_command( + &data_dir, + vec![ + "https://evidence-a.example.invalid".to_string(), + "https://evidence-b.example.invalid".to_string(), + ], + ), + &mut out, + ) + .await + .unwrap(); + + let loaded = + crate::protected_content_runtime::load_runtime_protected_content_chain_provider_config( + &data_dir, + ) + .unwrap() + .expect("generated chain config must load"); + let network = loaded.protected_content_network(); + assert_eq!(network["chain_id"], 8453); + assert_eq!(network["rights_methods"][0]["selector"], "0x54d42821"); + assert_eq!( + network["rights_methods"][0]["protected_content_policies"][0]["evidence_rpc_urls"] + .as_array() + .unwrap() + .len(), + 2 + ); + + let path = data_dir.join("protected-content/chain-provider.json"); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + + let receipt: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + receipt["schema"], + "elastos.protected-content.chain-provider-config-generated/v1" + ); + assert_eq!(receipt["created"], true); + } + + #[cfg(unix)] + #[tokio::test] + async fn generate_chain_config_rejects_a_single_evidence_source() { + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + + let mut out = Vec::new(); + let error = run_protected_content_config_command_with_writer( + chain_config_command( + &data_dir, + vec!["https://evidence-a.example.invalid".to_string()], + ), + &mut out, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("evidence"), "{error}"); + assert!(!data_dir + .join("protected-content/chain-provider.json") + .exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn generate_chain_config_rejects_duplicate_evidence_origins() { + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + + let mut out = Vec::new(); + let error = run_protected_content_config_command_with_writer( + chain_config_command( + &data_dir, + vec![ + "https://evidence-a.example.invalid/one".to_string(), + "https://evidence-a.example.invalid/two".to_string(), + ], + ), + &mut out, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("origin"), "{error}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn show_runtime_issuer_fails_closed_without_a_device_key() { + let dir = safe_ancestor_tempdir(); + let data_dir = dir.path().join("data"); + owner_only_dir(&data_dir); + + let mut out = Vec::new(); + let error = run_protected_content_config_command_with_writer( + ProtectedContentConfigCommand::ShowRuntimeIssuer { data_dir }, + &mut out, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("device key"), "{error}"); + } +} diff --git a/elastos/crates/elastos-server/src/protected_content_runtime.rs b/elastos/crates/elastos-server/src/protected_content_runtime.rs index 62dec7c04..9642bea41 100644 --- a/elastos/crates/elastos-server/src/protected_content_runtime.rs +++ b/elastos/crates/elastos-server/src/protected_content_runtime.rs @@ -171,7 +171,8 @@ pub(crate) const RUNTIME_PURCHASE_SCHEMA_V1: &str = "elastos.library.runtime-cus static RUNTIME_VIEWER_LIFECYCLE_GUARDS: OnceLock< StdMutex>>>, > = OnceLock::new(); -const CUSTODY_COMPOSITION_SCHEMA_V1: &str = "elastos.protected-content.custody-composition/v1"; +pub(crate) const CUSTODY_COMPOSITION_SCHEMA_V1: &str = + "elastos.protected-content.custody-composition/v1"; const MAX_CUSTODY_COMPOSITION_BYTES: usize = 64 * 1024; const MAX_CUSTODY_COMPOSITION_BLOB_BYTES: usize = 16 * 1024; const MAX_CUSTODY_COMPOSITION_PEER_DID_BYTES: usize = 256; @@ -312,29 +313,29 @@ struct RuntimePreparedMediaProviderOutput { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -enum RuntimeCustodyRouteTransportConfig { +pub(crate) enum RuntimeCustodyRouteTransportConfig { Local, CarrierPeerDid { peer_did: String }, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -struct RuntimeCustodyRouteBindingConfig { - node_public_key_base64: String, - owner_state_root_base64: String, - transport: RuntimeCustodyRouteTransportConfig, +pub(crate) struct RuntimeCustodyRouteBindingConfig { + pub(crate) node_public_key_base64: String, + pub(crate) owner_state_root_base64: String, + pub(crate) transport: RuntimeCustodyRouteTransportConfig, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -struct RuntimeCustodyCompositionConfigFile { - schema: String, - expected_policy_authority_base64: String, - expected_committee_authorization_identity_base64: String, - signed_pool_base64: String, - signed_epoch_base64: String, - signed_committee_authorization_base64: String, - routes: Vec, +pub(crate) struct RuntimeCustodyCompositionConfigFile { + pub(crate) schema: String, + pub(crate) expected_policy_authority_base64: String, + pub(crate) expected_committee_authorization_identity_base64: String, + pub(crate) signed_pool_base64: String, + pub(crate) signed_epoch_base64: String, + pub(crate) signed_committee_authorization_base64: String, + pub(crate) routes: Vec, } struct RuntimeValidatedCustodyRouteBinding { @@ -1466,15 +1467,25 @@ async fn register_inactive_custody_runtime_provider_target( .await } -fn inactive_custody_state_root(data_dir: &Path) -> std::path::PathBuf { +/// Derive the Runtime operation issuer custody and decrypt providers must +/// trust for the Runtime rooted at this device key. +pub fn derive_protected_content_runtime_issuer( + device_key: &[u8; 32], +) -> anyhow::Result { + let (runtime_signing_key, _) = elastos_identity::derive_did(device_key); + RuntimeOperationIssuerKeyV1::new(runtime_signing_key.verifying_key().to_bytes()) + .map_err(|_| anyhow::anyhow!("active Runtime operation issuer is invalid")) +} + +pub(crate) fn inactive_custody_state_root(data_dir: &Path) -> std::path::PathBuf { data_dir.join(INACTIVE_CUSTODY_ROOT) } -fn protected_content_root(data_dir: &Path) -> PathBuf { +pub(crate) fn protected_content_root(data_dir: &Path) -> PathBuf { data_dir.join(PROTECTED_CONTENT_ROOT) } -fn runtime_custody_composition_config_path(data_dir: &Path) -> PathBuf { +pub(crate) fn runtime_custody_composition_config_path(data_dir: &Path) -> PathBuf { data_dir.join(CUSTODY_COMPOSITION_CONFIG_FILE) } @@ -1486,7 +1497,7 @@ fn invalid_inactive_custody_config(reason: impl std::fmt::Display) -> anyhow::Er anyhow::anyhow!("inactive custody provider configuration is missing or unsafe: {reason}") } -fn validate_owner_only_directory(path: &Path, label: &str) -> anyhow::Result<()> { +pub(crate) fn validate_owner_only_directory(path: &Path, label: &str) -> anyhow::Result<()> { let metadata = fs::symlink_metadata(path).map_err(|error| { let _ = error; invalid_inactive_custody_config(format!("{label} is unavailable")) @@ -2296,7 +2307,7 @@ fn decode_node_public_key_base64(encoded: &str, field: &str) -> anyhow::Result anyhow::Result { +pub(crate) fn decode_canonical_peer_did(peer_did: &str) -> anyhow::Result { if peer_did.is_empty() || peer_did.len() > MAX_CUSTODY_COMPOSITION_PEER_DID_BYTES { anyhow::bail!( "{}", @@ -6770,6 +6781,23 @@ pub async fn reconcile_runtime_custody_viewers_after_decrypt_registration( Ok(()) } +/// Boot-path hook after successful Runtime-only decrypt registration. +/// Viewer records created before decrypt came up are the state this reconcile +/// exists for. Boot keeps serving when reconciliation fails closed. +pub async fn reconcile_runtime_custody_viewers_after_decrypt_boot( + data_dir: &Path, + registry: Arc, +) { + if let Err(error) = + reconcile_runtime_custody_viewers_after_decrypt_registration(data_dir, registry).await + { + tracing::warn!( + error = %error, + "Runtime custody viewer reconciliation after decrypt registration failed closed" + ); + } +} + fn reconstructed_buy_receipt( draft: &RuntimeMintDraft, availability: &RuntimeVerifiedContentAvailability, diff --git a/elastos/crates/elastos-server/src/protected_content_runtime/tests.rs b/elastos/crates/elastos-server/src/protected_content_runtime/tests.rs index 2808f0ebb..31d140921 100644 --- a/elastos/crates/elastos-server/src/protected_content_runtime/tests.rs +++ b/elastos/crates/elastos-server/src/protected_content_runtime/tests.rs @@ -11967,6 +11967,156 @@ async fn runtime_custody_viewer_restart_reconciliation_rejects_invalid_active_re assert!(error.to_string().contains("invalid")); } +/// The boot hook must delegate to the restart reconciliation once decrypt +/// registration succeeds; the per-lifecycle settlement semantics are covered +/// by the `runtime_custody_viewer_restart_reconciliation_*` tests. +#[cfg(unix)] +#[tokio::test] +async fn runtime_custody_viewer_decrypt_boot_settles_open_pending_record_after_registration() { + let harness = runtime_custody_prebuy_availability_harness( + 0x61, + ContentAvailabilityTestConfig::accepted(), + ) + .await; + let principal_id = "person:local:runtime-custody-viewer-decrypt-boot"; + let (proof_binding_id, _) = + install_profile_authority_keeping_device_key(&harness.data_dir, principal_id); + let mint = runtime_mint_journal(&harness.data_dir) + .load(harness.mint_id) + .unwrap(); + let purchase = persist_runtime_custody_purchase_for_mint( + &harness.data_dir, + &mint, + principal_id, + &load_profile_did_for_test(&harness.data_dir, principal_id), + crate::auth::now_ts(), + ); + persist_runtime_custody_open_pending_viewer_for_purchase( + &harness.data_dir, + &mint, + &purchase, + &proof_binding_id, + "runtime-session:alpha", + "grant:alpha", + opaque_handle(0x36), + crate::auth::now_ts() + 60, + ); + let registry = Arc::new(ProviderRegistry::new()); + let decrypt = SequencedProvider::new( + "decrypt", + vec![ + Ok(ok_provider_response( + serde_json::to_value( + DecryptProviderResponseV1::new_viewer_session_already_absent( + RuntimeReleaseAuditIdV1::new(digest(0x91)).unwrap(), + opaque_handle(0x36), + ) + .unwrap(), + ) + .unwrap(), + )), + Ok(ok_provider_response( + serde_json::to_value( + DecryptProviderResponseV1::new_cancelled_prepared_recipient( + RuntimeReleaseAuditIdV1::new(digest(0x91)).unwrap(), + opaque_handle(0x36), + ) + .unwrap(), + ) + .unwrap(), + )), + ], + ); + registry + .register_runtime_provider_target(PROTECTED_CONTENT_DECRYPT_PROVIDER_ID, decrypt) + .await + .unwrap(); + + super::reconcile_runtime_custody_viewers_after_decrypt_boot(&harness.data_dir, registry).await; + + let record = super::load_runtime_custody_viewer_record( + &harness.data_dir, + principal_id, + mint.draft().mint_id(), + ) + .unwrap() + .unwrap(); + assert_eq!( + record.lifecycle_status, + super::RuntimeCustodyViewerLifecycleStatus::AlreadyAbsent + ); +} + +/// The boot hook swallows the reconcile's hard failure into a warning so boot +/// keeps serving; the underlying `Err` path is proven by +/// `runtime_custody_viewer_restart_reconciliation_rejects_invalid_active_record`. +#[cfg(unix)] +#[tokio::test] +async fn runtime_custody_viewer_decrypt_boot_keeps_serving_when_reconciliation_fails_closed() { + let harness = runtime_custody_prebuy_availability_harness( + 0x61, + ContentAvailabilityTestConfig::accepted(), + ) + .await; + let principal_id = "person:local:runtime-custody-viewer-decrypt-boot-invalid"; + let (proof_binding_id, _) = + install_profile_authority_keeping_device_key(&harness.data_dir, principal_id); + let current_profile_did = load_profile_did_for_test(&harness.data_dir, principal_id); + let mint = runtime_mint_journal(&harness.data_dir) + .load(harness.mint_id) + .unwrap(); + let purchase = persist_runtime_custody_purchase_for_mint( + &harness.data_dir, + &mint, + principal_id, + ¤t_profile_did, + crate::auth::now_ts(), + ); + persist_runtime_custody_active_viewer_for_purchase( + &harness.data_dir, + &mint, + &purchase, + &proof_binding_id, + "runtime-session:alpha", + "grant:alpha", + opaque_handle(0x32), + crate::auth::now_ts() + 60, + ); + let mut record = super::load_runtime_custody_viewer_record( + &harness.data_dir, + principal_id, + mint.draft().mint_id(), + ) + .unwrap() + .unwrap(); + record.viewer_session_handle = hex::encode([0u8; MAX_PROVIDER_OPAQUE_HANDLE_BYTES_V1]); + super::persist_runtime_custody_viewer_record( + &harness.data_dir, + principal_id, + mint.draft().mint_id(), + &record, + ) + .unwrap(); + + super::reconcile_runtime_custody_viewers_after_decrypt_boot( + &harness.data_dir, + Arc::new(ProviderRegistry::new()), + ) + .await; + + let persisted = super::load_runtime_custody_viewer_record( + &harness.data_dir, + principal_id, + mint.draft().mint_id(), + ) + .unwrap() + .unwrap(); + assert_eq!( + persisted.lifecycle_status, + super::RuntimeCustodyViewerLifecycleStatus::Active + ); +} + #[cfg(unix)] #[tokio::test] async fn runtime_custody_viewer_lifecycle_guard_serializes_same_key_and_leaves_other_keys_independent( diff --git a/elastos/crates/elastos-server/src/server_infra.rs b/elastos/crates/elastos-server/src/server_infra.rs index c6381daaf..0dc8340f3 100644 --- a/elastos/crates/elastos-server/src/server_infra.rs +++ b/elastos/crates/elastos-server/src/server_infra.rs @@ -102,15 +102,7 @@ fn model_provider_bridge_config(data_dir: &Path) -> anyhow::Result anyhow::Result { - let (runtime_signing_key, _) = elastos_identity::derive_did(device_key); - elastos_protected_content_contracts::RuntimeOperationIssuerKeyV1::new( - runtime_signing_key.verifying_key().to_bytes(), - ) - .map_err(|_| anyhow::anyhow!("active Runtime operation issuer is invalid")) -} +use elastos_server::protected_content_runtime::derive_protected_content_runtime_issuer; fn chain_provider_bridge_config_without_protected_network( runtime_operation_issuer: &elastos_protected_content_contracts::RuntimeOperationIssuerKeyV1, @@ -943,7 +935,9 @@ async fn setup_server_infrastructure_impl( Ok(None) => tracing::info!("media-provider is installed but unconfigured"), Err(_) => tracing::warn!("Skipping media-provider due to invalid private config"), }, - Ok(None) => {} + Ok(None) => tracing::warn!( + "media-provider binary is not installed; protected-content media sessions will fail closed" + ), Err(_) => tracing::warn!("Skipping media-provider due to verification failure"), } @@ -1317,7 +1311,16 @@ async fn setup_server_infrastructure_impl( tracing::info!( "protected-content-decrypt-provider registered on Runtime-only target protected-content-decrypt; provisional decrypt-provider remains on elastos://decrypt" ); + elastos_server::protected_content_runtime::reconcile_runtime_custody_viewers_after_decrypt_boot( + &data_dir, + provider_registry.clone(), + ) + .await; } + } else { + tracing::warn!( + "protected-content-decrypt-provider binary is not installed; protected-content open and play will fail closed" + ); } if let Some(path) = crate::find_installed_provider_binary("protected-content-protect-provider") @@ -1340,6 +1343,10 @@ async fn setup_server_infrastructure_impl( path.display() ); } + } else { + tracing::warn!( + "protected-content-protect-provider binary is not installed; protected-content mint will fail closed" + ); } if let Some(path) = crate::find_installed_provider_binary("custody-provider") { @@ -1365,6 +1372,10 @@ async fn setup_server_infrastructure_impl( "custody-provider registered as inactive Runtime custody route; provisional key-provider remains the product path" ); } + } else { + tracing::warn!( + "custody-provider binary is not installed; the inactive Runtime custody route will be unavailable" + ); } // Built-in Carrier node — ALWAYS starts, not conditional on spawn_host_providers. From 5675b883765a0ac56b9a012b0287f09b1bd36506 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 2 Sep 2026 22:08:07 +0100 Subject: [PATCH 03/32] feat(home): first run seeds an empty desktop and pins Marketplace New Homes start with nothing on the desktop: apps are in the dock and the Apps face, content is reached through Library. Marketplace joins the default dock pins after Library so the way to get more is one click away. Only affects the first-run layout seed; saved layouts are untouched. Co-authored-by: Cursor --- capsules/home-gui/browser/shell-core.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/capsules/home-gui/browser/shell-core.js b/capsules/home-gui/browser/shell-core.js index c05ae3374..2ddd3ce41 100644 --- a/capsules/home-gui/browser/shell-core.js +++ b/capsules/home-gui/browser/shell-core.js @@ -660,7 +660,15 @@ function normalizeTaskbarLayout(taskbar, summary) { return normalized; } -const DEFAULT_TASKBAR_PINS = ["browser", "library", "wallet", "documents", "chat-room", "system"]; +const DEFAULT_TASKBAR_PINS = [ + "browser", + "library", + "marketplace", + "wallet", + "documents", + "chat-room", + "system", +]; function defaultTaskbarPins(summary) { const knownTargets = new Set(allVisibleTargets(summary).map((target) => target.target)); @@ -683,10 +691,11 @@ function normalizeDesktopLabels(labels, summary) { return normalized; } +/* First run: an empty desktop. Apps live in the dock and the Apps face; + content (games, models) is reached through Library. Anything the person + drags out to the desktop afterwards is respected by the saved layout. */ function defaultHiddenDesktopTargets(summary) { - return allVisibleTargets(summary) - .filter((target) => target.target_kind !== "object") - .map((target) => target.target); + return allVisibleTargets(summary).map((target) => target.target); } function normalizeDesktopHiddenTargets(targetIds, summary) { From 568bb04f5033850cc8b62b6b780b8cd76979eee2 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 2 Sep 2026 22:16:45 +0100 Subject: [PATCH 04/32] test(home): regression smoke expects the empty first-run desktop Co-authored-by: Cursor --- scripts/home-shell-regression-smoke.mjs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/home-shell-regression-smoke.mjs b/scripts/home-shell-regression-smoke.mjs index c493db206..40ba0af02 100644 --- a/scripts/home-shell-regression-smoke.mjs +++ b/scripts/home-shell-regression-smoke.mjs @@ -488,13 +488,14 @@ assert( "browser", "system", "documents", + "object-target", ]), - "fresh layout did not hide all non-object visible targets and keep object targets visible", + "fresh layout did not hide every visible target (first run is an empty desktop)", shellCore.shellState.shellLayoutState.desktopHidden, ); assert( - shellCore.isTargetOnDesktop("object-target") === true, - "fresh layout hid an object target from the desktop", + shellCore.isTargetOnDesktop("object-target") === false, + "fresh layout left an object target on the desktop", shellCore.shellState.shellLayoutState.desktopHidden, ); assert( @@ -503,10 +504,8 @@ assert( shellCore.shellState.shellLayoutState.desktopHidden, ); assert( - JSON.stringify(shellCore.shellState.shellLayoutState.desktop) === JSON.stringify({ - "object-target": { x: 12, y: 12 }, - }), - "fresh layout did not seed only the visible desktop entry positions", + JSON.stringify(shellCore.shellState.shellLayoutState.desktop) === JSON.stringify({}), + "fresh layout seeded desktop entry positions for an empty desktop", shellCore.shellState.shellLayoutState.desktop, ); From d8c37cb1d13cec919b4e3540d2e1dfad66e9a616 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 2 Sep 2026 22:45:51 +0100 Subject: [PATCH 05/32] fix(apps): clear the unified-sidebar chrome overlay in Documents, Inbox and Chat Room The Home unified-sidebar chrome paints the traffic lights over the first 52px of the capsule frame. Library, Marketplace, System, People and Services already reserve that band; Documents, Inbox and Chat Room did not, so the lights sat on top of the first sidebar control (Documents' New button overlapped outright). Documents follows the Library convention (--window-chrome-safe-top, minus its own 0.75rem page inset). Inbox and Chat Room use a capsule-local 52px, matching System and the entropy gate's rule that those two keep capsule-local top padding only. Co-authored-by: Cursor --- capsules/chat-room/browser/style.css | 4 +++- capsules/documents/browser/index.html | 3 +++ capsules/inbox/browser/index.html | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/capsules/chat-room/browser/style.css b/capsules/chat-room/browser/style.css index e2ee27f9a..fa3f41526 100644 --- a/capsules/chat-room/browser/style.css +++ b/capsules/chat-room/browser/style.css @@ -135,7 +135,9 @@ p { min-height: 0; flex-direction: column; gap: 0.45rem; - padding: 12px 10px 14px; + /* Unified-sidebar chrome paints the traffic lights over the first 52px; + capsule-local value on purpose (see home-entropy-check: Chat/Inbox). */ + padding: 52px 10px 14px; overflow: auto; border-right: 1px solid color-mix(in srgb, var(--line) 55%, transparent); background: var(--sidebar-bg); diff --git a/capsules/documents/browser/index.html b/capsules/documents/browser/index.html index 859313444..3403daa56 100644 --- a/capsules/documents/browser/index.html +++ b/capsules/documents/browser/index.html @@ -115,6 +115,9 @@ min-height: 0; overflow: hidden; padding: 0.8rem; + /* Unified-sidebar chrome paints the traffic lights over this corner; + .page-shell already insets 0.75rem, so the remainder lands content at safe-top. */ + padding-top: calc(var(--window-chrome-safe-top, 52px) - 0.75rem); background: var(--sidebar-bg); border-color: var(--line); color: var(--sidebar-text); diff --git a/capsules/inbox/browser/index.html b/capsules/inbox/browser/index.html index df17fba74..3bc8b2642 100644 --- a/capsules/inbox/browser/index.html +++ b/capsules/inbox/browser/index.html @@ -95,7 +95,9 @@ display: flex; flex-direction: column; gap: 4px; - padding: 12px 12px 16px; + /* Unified-sidebar chrome paints the traffic lights over the first 52px; + capsule-local value on purpose (see home-entropy-check: Chat/Inbox). */ + padding: 52px 12px 16px; border: 0; border-right: 1px solid var(--line); border-radius: 0; From 90d7f08f58ad168ab6874d5331c0ad7a76690ec2 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 2 Sep 2026 22:50:21 +0100 Subject: [PATCH 06/32] fix(inbox): request rows hug their content instead of stretching to the column height Co-authored-by: Cursor --- capsules/inbox/browser/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/capsules/inbox/browser/index.html b/capsules/inbox/browser/index.html index 3bc8b2642..d576fb2d4 100644 --- a/capsules/inbox/browser/index.html +++ b/capsules/inbox/browser/index.html @@ -269,6 +269,7 @@ .entry-rows { display: grid; + align-content: start; gap: 2px; overflow: auto; padding: 12px; From b26f5ee446bce1313ae8068b0b7e4310c5e0fb8b Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Wed, 2 Sep 2026 23:01:04 +0100 Subject: [PATCH 07/32] =?UTF-8?q?feat(home):=20lock=20face=20=E2=80=94=20w?= =?UTF-8?q?ordmark,=20full-bleed=20ground=20and=20vignette,=20large=20cloc?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the URUX lock face visual onto the Home host: ELASTOS wordmark, wallpaper as a full-bleed ground with a vignette, date and clock at their intended size, compact avatar with the name beneath. Two defects in the existing face go away with it: the generic `.home-unlock p` rule outranked `.home-unlock-date/-time` so the clock rendered at 13px grey, and the picker-flow entrance animation left a transform on the face, making it the containing block for the fixed ground and clipping the wallpaper to a centred column. Date/time rules are now scoped under .home-unlock and the entrance animates the brand/clock/account instead of the face. No behaviour change: shell-auth.js, the explicit-click passkey flow, and the person/person-name ordering are as before; no account directory. Co-authored-by: Cursor --- capsules/home/browser/index.html | 34 +++-- capsules/home/browser/style.css | 220 ++++++++++++++++++++++--------- 2 files changed, 177 insertions(+), 77 deletions(-) diff --git a/capsules/home/browser/index.html b/capsules/home/browser/index.html index 36230abf1..d5c63dc7f 100644 --- a/capsules/home/browser/index.html +++ b/capsules/home/browser/index.html @@ -62,21 +62,27 @@

Home didn't open