diff --git a/.keys/marketplace_operator_ed25519.pub b/.keys/marketplace_operator_ed25519.pub
new file mode 100644
index 0000000000..f9cd71ad83
--- /dev/null
+++ b/.keys/marketplace_operator_ed25519.pub
@@ -0,0 +1,2 @@
+§É-²M™çºî‹E mÃSÌÔ&"Á©
+Rµ2}²æÑx
\ No newline at end of file
diff --git a/src/security/market_capsule/client/install_ready.rs b/src/security/market_capsule/client/install_ready.rs
index 208bbfbf66..c949f1de59 100644
--- a/src/security/market_capsule/client/install_ready.rs
+++ b/src/security/market_capsule/client/install_ready.rs
@@ -14,12 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! `OP_INSTALL_READY`. The userland capsule evaluates a hard AND
-//! of nine install gates and returns the verdict as six bytes:
-//! one for the AND-result followed by the per-check bits. The
-//! kernel surfaces the result as a structured value so a caller
-//! can short-circuit on the AND-result while still being able to
-//! tell which gate refused.
+//! `OP_INSTALL_READY`.
use alloc::vec::Vec;
@@ -30,7 +25,7 @@ use super::seq::next_request_id;
use super::status_map::lift;
use super::transport::round_trip;
-const READINESS_LEN: usize = 6;
+const READINESS_LEN: usize = 7;
#[derive(Debug, Clone, Copy)]
pub struct InstallReadiness {
@@ -40,6 +35,8 @@ pub struct InstallReadiness {
pub publisher_signature_present: bool,
pub validation_passed: bool,
pub arch_match: bool,
+ /// The release offers a zk trailer for its own measurement.
+ pub attestation_present: bool,
}
pub fn install_ready(listing_id: &str, release_id: &str) -> Result {
@@ -66,5 +63,6 @@ pub fn install_ready(listing_id: &str, release_id: &str) -> Result.
+"""Build the marketplace index JSON from what actually exists on disk.
+
+Three sources, one catalogue:
+
+ nonos capsules this tree builds and signs, read out of the trust
+ directory so a listing exists only for something that has a
+ manifest and a certificate
+
+ linux distribution packages, fetched and hashed here. A listing
+ asserts "these bytes, this hash", and a hash nobody computed
+ is not an assertion, so a package that has not been fetched
+ is not listed
+
+ community submissions under nonos-data/marketplace/community, each a
+ JSON file naming a publisher key and a release the operator
+ has already validated
+
+The output is the plain JSON the `marketplace-index` CLI encodes and
+signs. Nothing here signs anything: the operator key never touches a
+generator.
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+import tarfile
+import time
+import urllib.request
+from pathlib import Path
+
+MIRROR = "https://dl-cdn.alpinelinux.org/alpine"
+BRANCHES = ("main", "community")
+# 2: the release carries the hash of its zk trailer, and the publisher
+# signature covers it.
+SCHEMA = 2
+
+# Capsules that are not applications. A driver or a transport is part of
+# the system, cannot be installed or removed by a user, and listing one
+# would offer an install that cannot happen.
+NOT_APPS = (
+ "driver_",
+ "net_",
+ "input_",
+ "proof_",
+ "std_proof",
+ "egui_proof",
+ "tokio-smoke",
+ "hello",
+ "gui_demo",
+ "boot_splash",
+ "compositor",
+ "wm",
+ "vfs",
+ "ramfs",
+ "keyring",
+ "policy",
+ "entropy",
+ "market",
+ "login",
+ "setup_wizard",
+ "toolkit",
+ "wallpaper",
+ "wallpaper_catalog",
+ "image_codec",
+ "audio_server",
+)
+
+FREE = {"kind": "free", "amount_atomic": "0", "period_seconds": 0}
+NOX = {"symbol": "NOX", "decimals": 18, "chain_id": 1, "contract_address": ""}
+
+
+def blake3(data: bytes) -> str:
+ """BLAKE3-256, the hash every other artifact in this tree is named by.
+
+ b3sum is what the signing tools use, so the digest in a listing is
+ the same one a person gets checking the artifact by hand.
+ """
+ try:
+ done = subprocess.run(
+ ["b3sum", "--no-names", "--raw"],
+ input=data, stdout=subprocess.PIPE, check=True,
+ )
+ except FileNotFoundError:
+ sys.exit("b3sum not found: install it, or the digests would be guesses")
+ return done.stdout[:32].hex()
+
+
+def validation(note: str, validator: str, when_ms: int) -> dict:
+ return {
+ "status": "validated",
+ "note": note,
+ "validator_id": validator,
+ "validated_at_ms": when_ms,
+ }
+
+
+def release(rid, manifest, package, url, arches, caps, note, validator,
+ when_ms, trailer=""):
+ return {
+ "release_id": rid,
+ "manifest_hash": manifest,
+ "package_hash": package,
+ "package_url": url,
+ "publisher_signature": "",
+ "supported_arches": arches,
+ "kernel_abi_min": 1,
+ "required_capabilities": caps,
+ "zk_trailer_hash": trailer,
+ "validation": validation(note, validator, when_ms),
+ }
+
+
+def entry(listing, capsule_id, name, publisher, pubkey, text, releases):
+ return {
+ "listing_id": listing,
+ "capsule_id": capsule_id,
+ "name": name,
+ "publisher_name": publisher,
+ "publisher_pubkey": pubkey,
+ "publisher_eth_address": "00" * 20,
+ "description": text,
+ "price": FREE,
+ "token": NOX,
+ "releases": releases,
+ }
+
+
+def is_app(slug: str) -> bool:
+ return not any(slug == n or slug.startswith(n) for n in NOT_APPS)
+
+
+def nonos_entries(trust: Path, pubkey: str, when_ms: int) -> list:
+ """One listing per signed capsule that is an application."""
+ out = []
+ for manifest in sorted(trust.glob("*.manifest.bin")):
+ slug = manifest.name[: -len(".manifest.bin")]
+ if not is_app(slug):
+ continue
+ cert = trust / f"{slug}.nonos_id_cert.bin"
+ trailer = trust / f"{slug}.zk_trailer.bin"
+ if not cert.exists() or not trailer.exists():
+ # No proof, no listing. An entry whose install is going to
+ # be refused at the spawn gate is worse than no entry: the
+ # refusal arrives after the download and reads like a bug.
+ print(f" skip {slug}: no trailer", file=sys.stderr)
+ continue
+ mhash = blake3(manifest.read_bytes())
+ thash = blake3(trailer.read_bytes())
+ out.append(
+ entry(
+ f"nonos.app.{slug}",
+ blake3(cert.read_bytes()),
+ slug.replace("_", " "),
+ "NONOS",
+ pubkey,
+ f"NONOS capsule {slug}, signed and attested in this image.",
+ [
+ release(
+ f"{slug}@builtin",
+ mhash,
+ mhash,
+ "",
+ ["x86_64-nonos"],
+ [],
+ "built and signed by this tree",
+ "nonos.build",
+ when_ms,
+ thash,
+ )
+ ],
+ )
+ )
+ return out
+
+
+def apkindex(cache: Path, release_name: str, arch: str, branch: str) -> dict:
+ """name -> record, from one branch's APKINDEX."""
+ base = f"{MIRROR}/{release_name}/{branch}/{arch}"
+ tgz = cache / f"{branch}-APKINDEX.tar.gz"
+ if not tgz.exists():
+ tgz.parent.mkdir(parents=True, exist_ok=True)
+ with urllib.request.urlopen(f"{base}/APKINDEX.tar.gz", timeout=120) as r:
+ tgz.write_bytes(r.read())
+ with tarfile.open(tgz) as t:
+ raw = t.extractfile("APKINDEX").read().decode(errors="replace")
+ out, rec = {}, {}
+ for line in raw.split("\n"):
+ if not line:
+ if rec.get("P"):
+ rec["base"] = base
+ out[rec["P"]] = rec
+ rec = {}
+ continue
+ if len(line) > 2 and line[1] == ":":
+ rec[line[0]] = line[2:]
+ return out
+
+
+def linux_trailer(cache: Path, apk: str) -> str:
+ """The trailer an operator minted for this package, if they have.
+
+ A Linux package carries no NONOS proof of its own, so somebody has
+ to enrol its measurement before the machine will run it. Until that
+ has happened there is nothing truthful to put in the field, and the
+ listing is held back rather than shipped as ready.
+ """
+ at = cache / f"{apk}.zk_trailer.bin"
+ return blake3(at.read_bytes()) if at.exists() else ""
+
+
+def linux_entries(cache, names, release_name, arch, pubkey, when_ms) -> list:
+ """One listing per package, fetched so its hash is a measured fact."""
+ table = {}
+ for branch in BRANCHES:
+ table.update(apkindex(cache, release_name, arch, branch))
+ out = []
+ for name in names:
+ rec = table.get(name)
+ if rec is None:
+ print(f" skip {name}: not in the index", file=sys.stderr)
+ continue
+ apk = f"{rec['P']}-{rec['V']}.apk"
+ url = f"{rec['base']}/{apk}"
+ blob = cache / apk
+ if not blob.exists():
+ try:
+ with urllib.request.urlopen(url, timeout=180) as r:
+ blob.write_bytes(r.read())
+ except OSError as e:
+ print(f" skip {name}: {e}", file=sys.stderr)
+ continue
+ raw = blob.read_bytes()
+ digest = blake3(raw)
+ out.append(
+ entry(
+ f"linux.{rec['P']}",
+ digest,
+ rec["P"],
+ f"Alpine {release_name}",
+ pubkey,
+ rec.get("T", "").strip() or f"Linux package {rec['P']}",
+ [
+ release(
+ f"{rec['P']}@{rec['V']}",
+ digest,
+ digest,
+ url,
+ ["x86_64-linux"],
+ ["ForeignExec"],
+ f"fetched and hashed at {len(raw)} bytes",
+ "nonos.operator.linux",
+ when_ms,
+ linux_trailer(cache, apk),
+ )
+ ],
+ )
+ )
+ return out
+
+
+def community_entries(where: Path) -> list:
+ """Submissions, passed through as the operator validated them."""
+ out = []
+ for path in sorted(where.glob("*.json")):
+ item = json.loads(path.read_text())
+ if not item.get("listing_id", "").startswith("community."):
+ sys.exit(f"{path}: listing_id must start with 'community.'")
+ out.append(item)
+ return out
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--out", type=Path, required=True)
+ ap.add_argument("--trust", type=Path, default=Path("nonos-data/trust/capsules"))
+ ap.add_argument("--community", type=Path,
+ default=Path("nonos-data/marketplace/community"))
+ ap.add_argument("--cache", type=Path, default=Path("target/market-cache"))
+ ap.add_argument("--operator-pubkey", required=True,
+ help="hex Ed25519 key the index will be signed under")
+ ap.add_argument("--alpine-release", default="v3.21")
+ ap.add_argument("--alpine-arch", default="x86_64")
+ ap.add_argument("--linux-package", action="append", default=[],
+ help="repeatable; a package to fetch, hash and list")
+ ap.add_argument("--linux-list", type=Path,
+ help="file of package names, one per line")
+ ap.add_argument("--serial", type=int, required=True)
+ ap.add_argument("--no-nonos", action="store_true")
+ args = ap.parse_args()
+
+ when_ms = int(time.time() * 1000)
+ key = args.operator_pubkey.removeprefix("0x").lower()
+ if len(key) != 64:
+ sys.exit("--operator-pubkey must be 32 hex bytes")
+
+ entries = []
+ if not args.no_nonos and args.trust.is_dir():
+ found = nonos_entries(args.trust, key, when_ms)
+ print(f"nonos: {len(found)} capsules", file=sys.stderr)
+ entries += found
+
+ names = list(args.linux_package)
+ if args.linux_list and args.linux_list.exists():
+ names += [
+ line.split("#", 1)[0].strip()
+ for line in args.linux_list.read_text().splitlines()
+ if line.split("#", 1)[0].strip()
+ ]
+ if names:
+ args.cache.mkdir(parents=True, exist_ok=True)
+ found = linux_entries(args.cache, names, args.alpine_release,
+ args.alpine_arch, key, when_ms)
+ print(f"linux: {len(found)} of {len(names)} packages", file=sys.stderr)
+ entries += found
+
+ if args.community.is_dir():
+ found = community_entries(args.community)
+ print(f"community: {len(found)} submissions", file=sys.stderr)
+ entries += found
+
+ index = {
+ "schema_version": SCHEMA,
+ "operator_id": "nonos.marketplace.v1",
+ "published_at_ms": when_ms,
+ "serial": args.serial,
+ "entries": entries,
+ }
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+ args.out.write_text(json.dumps(index, indent=2) + "\n")
+ print(f"{args.out}: {len(entries)} listings, serial {args.serial}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/nonos-market-sign-releases b/tools/nonos-market-sign-releases
new file mode 100755
index 0000000000..6d532a9ccf
--- /dev/null
+++ b/tools/nonos-market-sign-releases
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# NONOS Operating System
+# Copyright (C) 2026 NONOS Contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+"""Attach a publisher signature to every release in the index.
+
+The generator cannot do this: it never touches a key. The CLI signs one
+release per invocation, deliberately, so the loop lives here rather than
+inside a command that would then be holding a key across a whole
+catalogue.
+
+Every release this signs is one whose publisher is the operator, which
+is true for the capsules this tree builds and for packages the operator
+fetched and hashed itself. A third-party submission arrives already
+signed by its own publisher and is skipped: re-signing it here would
+replace the submitter's authority with the operator's, quietly.
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--cli", type=Path, required=True)
+ ap.add_argument("--index", type=Path, required=True)
+ ap.add_argument("--key-file", type=Path, required=True)
+ ap.add_argument("--operator-pubkey", required=True)
+ args = ap.parse_args()
+
+ key = args.operator_pubkey.removeprefix("0x").lower()
+ doc = json.loads(args.index.read_text())
+ todo = []
+ for entry in doc["entries"]:
+ if entry["publisher_pubkey"].lower() != key:
+ continue
+ for rel in entry["releases"]:
+ if not rel.get("publisher_signature"):
+ todo.append((entry["listing_id"], rel["release_id"]))
+
+ signed = 0
+ for listing_id, release_id in todo:
+ done = subprocess.run(
+ [
+ str(args.cli), "sign-release",
+ "--in", str(args.index),
+ "--listing-id", listing_id,
+ "--release-id", release_id,
+ "--key-file", str(args.key_file),
+ "--out", str(args.index),
+ ],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ )
+ if done.returncode != 0:
+ print(f" {listing_id}: {done.stderr.decode().strip()}", file=sys.stderr)
+ continue
+ signed += 1
+
+ doc = json.loads(args.index.read_text())
+ total = sum(len(e["releases"]) for e in doc["entries"])
+ have = sum(
+ 1 for e in doc["entries"] for r in e["releases"] if r.get("publisher_signature")
+ )
+ print(f"signed {signed}; {have} of {total} releases now carry a signature")
+ return 0 if have == total else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/userland/assets/icons/store.a8 b/userland/assets/icons/store.a8
new file mode 100644
index 0000000000..e086216b2c
Binary files /dev/null and b/userland/assets/icons/store.a8 differ
diff --git a/userland/assets/icons/store.svg b/userland/assets/icons/store.svg
new file mode 100644
index 0000000000..9db2b1c838
--- /dev/null
+++ b/userland/assets/icons/store.svg
@@ -0,0 +1 @@
+
diff --git a/userland/capsule_market/Capsule.mk b/userland/capsule_market/Capsule.mk
index 3782855c50..cc66a62043 100644
--- a/userland/capsule_market/Capsule.mk
+++ b/userland/capsule_market/Capsule.mk
@@ -17,4 +17,11 @@ CAPSULE_REPLY_ENDPOINT := reply:4107:endpoint.4294967303
CAPSULE_REQUIRED_CAPS := 0x39
CAPSULE_KERNEL_MIRROR := src/security/market_capsule
+# The capsule embeds the signed catalogue, so a newer index has to
+# rebuild it. Cargo tracks the include_bytes! path, but the make rule
+# lists only sources, and without this line a freshly signed catalogue
+# was silently left out of the image: the build succeeded, the boot
+# succeeded, and the machine served the previous one.
+CAPSULE_EXTRA_DEPS := $(wildcard nonos-data/marketplace/index.bin)
+
include nonos-mk/capsule.mk
diff --git a/userland/capsule_market/Cargo.lock b/userland/capsule_market/Cargo.lock
index ccce193879..d24abf9bdf 100644
--- a/userland/capsule_market/Cargo.lock
+++ b/userland/capsule_market/Cargo.lock
@@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "libm",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "linked_list_allocator"
version = "0.10.6"
@@ -20,10 +55,19 @@ dependencies = [
"scopeguard",
]
+[[package]]
+name = "nonos_app_skeleton"
+version = "0.3.0"
+dependencies = [
+ "nonos_toolkit",
+ "nonos_userland_libc",
+]
+
[[package]]
name = "nonos_capsule_market"
version = "0.3.0"
dependencies = [
+ "nonos_app_skeleton",
"nonos_ed25519",
"nonos_marketplace_abi",
"nonos_userland_libc",
@@ -33,18 +77,27 @@ dependencies = [
name = "nonos_ed25519"
version = "0.1.0"
dependencies = [
- "nonos_hd",
+ "nonos_hash",
"spin",
]
[[package]]
-name = "nonos_hd"
-version = "0.3.0"
+name = "nonos_hash"
+version = "0.1.0"
[[package]]
name = "nonos_marketplace_abi"
version = "0.3.0"
+[[package]]
+name = "nonos_toolkit"
+version = "0.3.0"
+dependencies = [
+ "ab_glyph",
+ "nonos_userland_libc",
+ "spin",
+]
+
[[package]]
name = "nonos_userland_libc"
version = "0.3.0"
@@ -52,6 +105,15 @@ dependencies = [
"linked_list_allocator",
]
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -63,6 +125,9 @@ name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
[[package]]
name = "spinning_top"
@@ -72,3 +137,12 @@ checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0"
dependencies = [
"lock_api",
]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+dependencies = [
+ "core_maths",
+]
diff --git a/userland/capsule_market/Cargo.toml b/userland/capsule_market/Cargo.toml
index 94640ad717..ebd837c5be 100644
--- a/userland/capsule_market/Cargo.toml
+++ b/userland/capsule_market/Cargo.toml
@@ -25,6 +25,7 @@ path = "src/main.rs"
nonos_ed25519 = { path = "../nonos_ed25519" }
nonos_libc = { package = "nonos_userland_libc", path = "../libc" }
nonos_marketplace_abi = { path = "../marketplace_abi" }
+nonos_app_skeleton = { path = "../app_skeleton" }
[features]
default = []
diff --git a/userland/capsule_market/src/boot_index.rs b/userland/capsule_market/src/boot_index.rs
new file mode 100644
index 0000000000..0ede60340d
--- /dev/null
+++ b/userland/capsule_market/src/boot_index.rs
@@ -0,0 +1,54 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The catalogue this capsule starts with.
+
+use nonos_app_skeleton::clients::vfs::read_file;
+use nonos_libc::mk_getpid;
+
+use crate::ingest::load_verified;
+use crate::store::Store;
+use crate::verify::Verifier;
+
+/// Where an operator drops a catalogue newer than the built-in one.
+const PATH: &[u8] = b"/nonos/marketplace/index.bin";
+
+/// A catalogue of every listing a machine could offer is still small next to
+/// one package.
+const MAX: u32 = 8 << 20;
+
+/// The catalogue this image shipped with. Empty when the build had none,
+/// which reads as "no baseline" rather than as a failure.
+static BASELINE: &[u8] = include_bytes!("../../../nonos-data/marketplace/index.bin");
+
+pub fn load(store: &mut Store, verifier: &V) {
+ if !BASELINE.is_empty() {
+ take(store, verifier, BASELINE);
+ }
+ if let Ok(blob) = read_file(mk_getpid(), PATH, MAX) {
+ take(store, verifier, &blob);
+ }
+}
+
+fn take(store: &mut Store, verifier: &V, blob: &[u8]) {
+ /*
+ * A serial no newer than the one already held is the ordinary outcome, not
+ * an error: it means no operator has published since this image was built.
+ */
+ if let Ok(v) = load_verified(blob, verifier, store.last_serial()) {
+ store.install(v.index, v.signature_verified, v.publisher_signature_verified);
+ }
+}
diff --git a/userland/capsule_market/src/bootstrap_trust/keys.rs b/userland/capsule_market/src/bootstrap_trust/keys.rs
index 777e184243..b03b429a5d 100644
--- a/userland/capsule_market/src/bootstrap_trust/keys.rs
+++ b/userland/capsule_market/src/bootstrap_trust/keys.rs
@@ -14,9 +14,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+//! The operators whose catalogues this machine will read.
+
+/// Marketplace operator, v1.
pub(super) const NOX_OPERATOR_V1: [u8; 32] = [
- 0x29, 0x5f, 0x84, 0xc9, 0x7c, 0x62, 0x01, 0x3c, 0x43, 0x8b, 0xca, 0x3d, 0x81, 0xc1, 0x80, 0x98,
- 0x1b, 0x9f, 0x0a, 0x04, 0x3b, 0xa1, 0xfa, 0xe2, 0x54, 0xad, 0x0e, 0x12, 0xea, 0x8e, 0x07, 0x63,
+ 0xa7, 0xc9, 0x2d, 0xb2, 0x4d, 0x99, 0xe7, 0xba, 0xee, 0x8b, 0x45, 0xa0, 0x6d, 0xc3, 0x53, 0xcc,
+ 0xd4, 0x14, 0x26, 0x22, 0xc1, 0xa9, 0x0a, 0x52, 0xb5, 0x32, 0x7d, 0xb2, 0xe6, 0xd1, 0x78, 0x11,
];
+// One entry, deliberately.
pub(super) const TRUSTED_OPERATORS: &[[u8; 32]] = &[NOX_OPERATOR_V1];
diff --git a/userland/capsule_market/src/install_ready/arch.rs b/userland/capsule_market/src/install_ready/arch.rs
index 7f0ca8f16d..47c0020d6d 100644
--- a/userland/capsule_market/src/install_ready/arch.rs
+++ b/userland/capsule_market/src/install_ready/arch.rs
@@ -17,6 +17,14 @@
#[cfg(target_arch = "x86_64")]
pub const RUNNING_ARCH: &str = "x86_64-nonos";
+/// The other triple this machine runs: a Linux binary of the same hardware
+/// arch, hosted by the personality capsule.
+#[cfg(target_arch = "x86_64")]
+pub const HOSTED_ARCH: &str = "x86_64-linux";
+
+#[cfg(not(target_arch = "x86_64"))]
+pub const HOSTED_ARCH: &str = "";
+
#[cfg(target_arch = "aarch64")]
pub const RUNNING_ARCH: &str = "aarch64-nonos";
diff --git a/userland/capsule_market/src/install_ready/checks.rs b/userland/capsule_market/src/install_ready/checks.rs
index 7c9675c958..e16dba2982 100644
--- a/userland/capsule_market/src/install_ready/checks.rs
+++ b/userland/capsule_market/src/install_ready/checks.rs
@@ -16,10 +16,14 @@
use nonos_marketplace_abi::{CapsuleRelease, InstallReadiness, ValidationStatus};
-use super::arch::RUNNING_ARCH;
+use super::arch::{HOSTED_ARCH, RUNNING_ARCH};
pub const RUNNING_KERNEL_ABI: u32 = 1;
+/// Releases carrying this arch are distribution packages the personality
+/// hosts.
+const LOCAL_ARCH: &str = HOSTED_ARCH;
+
pub fn evaluate(
signature_verified: bool,
release: &CapsuleRelease,
@@ -30,8 +34,15 @@ pub fn evaluate(
let package_url_present = !release.package_url.is_empty();
let package_hash_present = release.package_hash.iter().any(|&b| b != 0);
let manifest_hash_present = release.manifest_hash.iter().any(|&b| b != 0);
- let arch_match = release.supported_arches.iter().any(|a| a.as_str() == RUNNING_ARCH);
+ let runs_here = |a: &alloc::string::String| {
+ a.as_str() == RUNNING_ARCH || (!HOSTED_ARCH.is_empty() && a.as_str() == HOSTED_ARCH)
+ };
+ let arch_match = release.supported_arches.iter().any(runs_here);
let kernel_abi_compatible = release.kernel_abi_min <= RUNNING_KERNEL_ABI;
+ // Everything above this line is somebody's word.
+ let minted_locally = release.supported_arches.iter().any(|a| a.as_str() == LOCAL_ARCH);
+ let ships_proof = release.zk_trailer_hash.iter().any(|&b| b != 0);
+ let attestation_present = ships_proof || minted_locally;
let install_ready = index_signature_valid
&& validation_passed
@@ -40,7 +51,8 @@ pub fn evaluate(
&& manifest_hash_present
&& publisher_signature_verified
&& arch_match
- && kernel_abi_compatible;
+ && kernel_abi_compatible
+ && attestation_present;
InstallReadiness {
install_ready,
@@ -49,5 +61,6 @@ pub fn evaluate(
publisher_signature_present: publisher_signature_verified,
validation_passed,
arch_match: arch_match && kernel_abi_compatible,
+ attestation_present,
}
}
diff --git a/userland/capsule_market/src/main.rs b/userland/capsule_market/src/main.rs
index acf2e9fe00..ef5c13927f 100644
--- a/userland/capsule_market/src/main.rs
+++ b/userland/capsule_market/src/main.rs
@@ -19,6 +19,7 @@
extern crate alloc;
+mod boot_index;
mod bootstrap_trust;
mod ingest;
mod install_ready;
@@ -46,5 +47,9 @@ pub unsafe extern "C" fn _start() -> ! {
let mut store = Store::empty();
let verifier = DefaultVerifier;
+ // Before the first query arrives, so a client never sees an empty
+ // catalogue on a machine that has one.
+ boot_index::load(&mut store, &verifier);
+
server::run(&mut store, &verifier);
}
diff --git a/userland/capsule_market/src/server/handlers/install_ready/constants.rs b/userland/capsule_market/src/server/handlers/install_ready/constants.rs
index dce0b67089..a24d162638 100644
--- a/userland/capsule_market/src/server/handlers/install_ready/constants.rs
+++ b/userland/capsule_market/src/server/handlers/install_ready/constants.rs
@@ -14,4 +14,4 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-pub(super) const READINESS_LEN: usize = 6;
+pub(super) const READINESS_LEN: usize = 7;
diff --git a/userland/capsule_market/src/server/handlers/install_ready/find_release.rs b/userland/capsule_market/src/server/handlers/install_ready/find_release.rs
index 9d68acb58e..abcc297cc0 100644
--- a/userland/capsule_market/src/server/handlers/install_ready/find_release.rs
+++ b/userland/capsule_market/src/server/handlers/install_ready/find_release.rs
@@ -25,10 +25,12 @@ pub(super) fn find_release<'a>(
if e.listing_id != listing_id {
return None;
}
- e.releases
- .iter()
- .enumerate()
- .find(|(_, r)| r.release_id == release_id)
- .map(|(release_index, r)| (entry_index, release_index, r))
+ // An empty id asks for the default, which the index defines as the
+ // first release.
+ let wanted = match release_id.is_empty() {
+ true => e.releases.first().map(|r| (0usize, r)),
+ false => e.releases.iter().enumerate().find(|(_, r)| r.release_id == release_id),
+ };
+ wanted.map(|(release_index, r)| (entry_index, release_index, r))
})
}
diff --git a/userland/capsule_market/src/server/handlers/install_ready/handle.rs b/userland/capsule_market/src/server/handlers/install_ready/handle.rs
index 6eaa63456c..99be95d450 100644
--- a/userland/capsule_market/src/server/handlers/install_ready/handle.rs
+++ b/userland/capsule_market/src/server/handlers/install_ready/handle.rs
@@ -49,5 +49,6 @@ pub(crate) fn handle(store: &Store, body: &[u8], req: &Request, tx: &mut [u8]) {
slot[3] = verdict.publisher_signature_present as u8;
slot[4] = verdict.validation_passed as u8;
slot[5] = verdict.arch_match as u8;
+ slot[6] = verdict.attestation_present as u8;
reply_with_body(tx, req, READINESS_LEN);
}
diff --git a/userland/marketplace_abi/src/codec/decode_index.rs b/userland/marketplace_abi/src/codec/decode_index.rs
index 9b05a1299a..cdc53ebee3 100644
--- a/userland/marketplace_abi/src/codec/decode_index.rs
+++ b/userland/marketplace_abi/src/codec/decode_index.rs
@@ -25,7 +25,9 @@ use super::strings::{bounded_bytes, bounded_count, bounded_string};
use crate::limits::{MAX_ENTRIES, MAX_INDEX_BLOB, MAX_PUBLISHER, MAX_SIGNATURE};
use crate::types::{MarketplaceEntry, MarketplaceIndex};
-const SUPPORTED_SCHEMA: u32 = 1;
+// 2: every release carries the hash of the zk trailer binding its own
+// measurement to the enrolled set, and the publisher signature covers it.
+const SUPPORTED_SCHEMA: u32 = 2;
pub struct DecodedIndex<'a> {
pub index: MarketplaceIndex,
diff --git a/userland/marketplace_abi/src/codec/decode_release.rs b/userland/marketplace_abi/src/codec/decode_release.rs
index a9e705b90f..9c74dc40be 100644
--- a/userland/marketplace_abi/src/codec/decode_release.rs
+++ b/userland/marketplace_abi/src/codec/decode_release.rs
@@ -50,6 +50,7 @@ pub(super) fn read(r: &mut Reader<'_>) -> Result {
required_capabilities.push(bounded_string(r, MAX_PUBLISHER)?);
}
+ let zk_trailer_hash = r.fixed::<32>()?;
let validation = decode_validation::read(r)?;
Ok(CapsuleRelease {
@@ -61,6 +62,7 @@ pub(super) fn read(r: &mut Reader<'_>) -> Result {
supported_arches,
kernel_abi_min,
required_capabilities,
+ zk_trailer_hash,
validation,
})
}
diff --git a/userland/marketplace_abi/src/codec/encode_release.rs b/userland/marketplace_abi/src/codec/encode_release.rs
index 6bebe2ea82..4a21730dae 100644
--- a/userland/marketplace_abi/src/codec/encode_release.rs
+++ b/userland/marketplace_abi/src/codec/encode_release.rs
@@ -37,5 +37,6 @@ pub(super) fn write(w: &mut Writer<'_>, release: &CapsuleRelease) {
w.lp_string(cap);
}
+ w.fixed(&release.zk_trailer_hash);
encode_validation::write(w, &release.validation);
}
diff --git a/userland/marketplace_abi/src/codec/release_signing.rs b/userland/marketplace_abi/src/codec/release_signing.rs
index c06d228d26..82f7d85cf4 100644
--- a/userland/marketplace_abi/src/codec/release_signing.rs
+++ b/userland/marketplace_abi/src/codec/release_signing.rs
@@ -14,9 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! Canonical bytes a publisher signs for one release. Publisher
-//! authority covers artifact identity and requested authority.
-//! Marketplace validation is signed by the enclosing operator index.
+//! Canonical bytes a publisher signs for one release.
extern crate alloc;
@@ -25,7 +23,8 @@ use alloc::vec::Vec;
use super::writer::Writer;
use crate::types::CapsuleRelease;
-const RELEASE_SIGNING_DOMAIN: &[u8] = b"NONOS.marketplace.release.v1";
+// v2 because the signed bytes gained the trailer hash below.
+const RELEASE_SIGNING_DOMAIN: &[u8] = b"NONOS.marketplace.release.v2";
pub fn release_signing_bytes(release: &CapsuleRelease) -> Vec {
let mut out = Vec::new();
@@ -46,5 +45,6 @@ pub fn release_signing_bytes(release: &CapsuleRelease) -> Vec {
for cap in &release.required_capabilities {
w.lp_string(cap);
}
+ w.fixed(&release.zk_trailer_hash);
out
}
diff --git a/userland/marketplace_abi/src/types/readiness.rs b/userland/marketplace_abi/src/types/readiness.rs
index 8b7fdd4c7d..b0d286b77e 100644
--- a/userland/marketplace_abi/src/types/readiness.rs
+++ b/userland/marketplace_abi/src/types/readiness.rs
@@ -14,10 +14,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! Verdict the capsule emits when a caller asks "is this release
-//! ready to install?". Five independent gates must all pass; the
-//! report carries which ones tripped so a UI can explain the
-//! refusal precisely.
+//! Verdict the capsule emits when a caller asks "is this release ready to
+//! install?".
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct InstallReadiness {
@@ -28,13 +26,14 @@ pub struct InstallReadiness {
/// `package_url` is non-empty.
pub package_url_present: bool,
/// Publisher signature verifies against the listing pubkey.
- /// The field name is kept for wire compatibility with earlier
- /// six-byte readiness replies.
pub publisher_signature_present: bool,
/// Operator's `validation_status` is `Validated`.
pub validation_passed: bool,
/// Running kernel arch is in the release's `supported_arches`.
pub arch_match: bool,
+ /// The release names a zk trailer binding its own measurement to the
+ /// enrolled set.
+ pub attestation_present: bool,
}
impl InstallReadiness {
@@ -46,10 +45,11 @@ impl InstallReadiness {
publisher_signature_present: false,
validation_passed: false,
arch_match: false,
+ attestation_present: false,
}
}
- /// Compose a verdict from the five checks. `install_ready` is
+ /// Compose a verdict from the six checks. `install_ready` is
/// the AND of the inputs; anything `false` blocks install.
pub fn from_checks(
index_signature_valid: bool,
@@ -57,12 +57,14 @@ impl InstallReadiness {
publisher_signature_verified: bool,
validation_passed: bool,
arch_match: bool,
+ attestation_present: bool,
) -> Self {
let install_ready = index_signature_valid
&& package_url_present
&& publisher_signature_verified
&& validation_passed
- && arch_match;
+ && arch_match
+ && attestation_present;
Self {
install_ready,
index_signature_valid,
@@ -70,6 +72,7 @@ impl InstallReadiness {
publisher_signature_present: publisher_signature_verified,
validation_passed,
arch_match,
+ attestation_present,
}
}
}
diff --git a/userland/marketplace_abi/src/types/release.rs b/userland/marketplace_abi/src/types/release.rs
index 8102b1c0c9..27f4719e68 100644
--- a/userland/marketplace_abi/src/types/release.rs
+++ b/userland/marketplace_abi/src/types/release.rs
@@ -14,10 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! One concrete release of a marketplace entry. A release is the
-//! signed unit the future capsule_installer fetches and verifies;
-//! everything an installer needs to refuse a stale, mistargeted, or
-//! tampered package lives here.
+//! One concrete release of a marketplace entry.
extern crate alloc;
@@ -39,10 +36,6 @@ pub struct CapsuleRelease {
/// the entry is index-only (no fetchable artifact).
pub package_url: String,
/// Publisher's Ed25519 signature over `release_signing_bytes`.
- /// This covers the artifact hashes, URL, supported arches,
- /// kernel ABI, and requested capabilities. It deliberately does
- /// not cover the marketplace-operator validation report, which
- /// is signed by the enclosing index.
pub publisher_signature: Vec,
/// Architecture triples the release supports (e.g.
/// "x86_64-nonos"). At least one entry is required.
@@ -51,6 +44,9 @@ pub struct CapsuleRelease {
pub kernel_abi_min: u32,
/// Capability names the manifest requests at install time.
pub required_capabilities: Vec,
+ /// BLAKE3-256 of the zk trailer that proves this package's own measurement
+ /// is enrolled under the trust root the kernel enforces at spawn.
+ pub zk_trailer_hash: [u8; 32],
/// Marketplace operator's validation report.
pub validation: ValidationReport,
}
diff --git a/userland/toolkit/src/icons/all.rs b/userland/toolkit/src/icons/all.rs
index 96b0b05b14..546133e3ed 100644
--- a/userland/toolkit/src/icons/all.rs
+++ b/userland/toolkit/src/icons/all.rs
@@ -17,7 +17,7 @@
use super::id::IconId;
impl IconId {
- pub const ALL: [IconId; 47] = [
+ pub const ALL: [IconId; 48] = [
IconId::About,
IconId::AudioPlayer,
IconId::Browser,
@@ -31,6 +31,7 @@ impl IconId {
IconId::Processes,
IconId::Settings,
IconId::Snake,
+ IconId::Store,
IconId::Terminal,
IconId::VideoPlayer,
IconId::Wallet,
diff --git a/userland/toolkit/src/icons/id.rs b/userland/toolkit/src/icons/id.rs
index a89ec54229..9fdb511f84 100644
--- a/userland/toolkit/src/icons/id.rs
+++ b/userland/toolkit/src/icons/id.rs
@@ -29,6 +29,7 @@ pub enum IconId {
Processes,
Settings,
Snake,
+ Store,
Terminal,
VideoPlayer,
Wallet,
diff --git a/userland/toolkit/src/icons/name.rs b/userland/toolkit/src/icons/name.rs
index 572e46af8d..398205a5cf 100644
--- a/userland/toolkit/src/icons/name.rs
+++ b/userland/toolkit/src/icons/name.rs
@@ -32,6 +32,7 @@ impl IconId {
IconId::Processes => "processes",
IconId::Settings => "settings",
IconId::Snake => "snake",
+ IconId::Store => "store",
IconId::Terminal => "terminal",
IconId::VideoPlayer => "video_player",
IconId::Wallet => "wallet",
diff --git a/userland/toolkit/src/icons/table.rs b/userland/toolkit/src/icons/table.rs
index e3c10cdd4a..2b37aa5ef9 100644
--- a/userland/toolkit/src/icons/table.rs
+++ b/userland/toolkit/src/icons/table.rs
@@ -14,10 +14,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-/// One 8-bit coverage mask per icon, ordered to match `IconId`. The host test
-/// compares every entry against the file `IconId::name` points at, so the
-/// ordinal indexing below is proven rather than assumed.
-pub(super) const MASKS: [&[u8]; 47] = [
+/// One 8-bit coverage mask per icon, ordered to match `IconId`.
+pub(super) const MASKS: [&[u8]; 48] = [
include_bytes!("../../../assets/icons/about.a8"),
include_bytes!("../../../assets/icons/audio_player.a8"),
include_bytes!("../../../assets/icons/browser.a8"),
@@ -31,6 +29,7 @@ pub(super) const MASKS: [&[u8]; 47] = [
include_bytes!("../../../assets/icons/processes.a8"),
include_bytes!("../../../assets/icons/settings.a8"),
include_bytes!("../../../assets/icons/snake.a8"),
+ include_bytes!("../../../assets/icons/store.a8"),
include_bytes!("../../../assets/icons/terminal.a8"),
include_bytes!("../../../assets/icons/video_player.a8"),
include_bytes!("../../../assets/icons/wallet.a8"),
diff --git a/userland/toolkit/tests/host/icon_table.rs b/userland/toolkit/tests/host/icon_table.rs
index 2fdbdbef2e..dbe6afd271 100644
--- a/userland/toolkit/tests/host/icon_table.rs
+++ b/userland/toolkit/tests/host/icon_table.rs
@@ -15,8 +15,11 @@ use mask::{dim, mask};
fn main() {
let all = IconId::ALL;
let mut fail = 0usize;
- if all.len() != 42 {
- println!("expected 42 icons, got {}", all.len());
+ // Kept in step with IconId::ALL by hand, which is the point: a count that
+ // updated itself would not catch an icon added to one list and not the
+ // other.
+ if all.len() != 48 {
+ println!("expected 48 icons, got {}", all.len());
fail += 1;
}
for (i, a) in all.iter().enumerate() {